chore(runner,runner-ct): reduce duplication with packages/runner-shared (#16866)

* move snapshot-controls to shared package

* share visit-failure component

* share blank-contents

* share message component

* move message styling to shared

* stub scss in unit tests

* remove whitespace

* make dup files match

* share selector playground

* share script error

* share automation-disconnected

* remove old file

* share no-automation

* share error messages

* share errors

* make iframe model files similar

* share iframe-model

* share selector playground

* share style

* share highlight in selector playground

* share dom file

* update import

* share aut-iframe

* wip: shared event manager

* remove CT event manager

* move studio to shared runner package

* fix tests

* use shared event manager in CT runner

* comment back in code

* rename viewporth width/height to width/height

* fix ts errors

* share viewport info

* share header

* revert changed test

* remove old code

* fix tests and move test to shared package

* move tests to shared package

* make container files similar

* share container in runner

* share container

* move test

* move spec

* update tsconfig]

* update headeR

* fix styling

* style

* refactor

* refactor

* reduce public modules

* Update packages/runner-shared/src/event-manager.js

Co-authored-by: Zach Bloomquist <github@chary.us>

* fix percy regression

* fix regression in style

* improve types, try reverting style

* add runner-shared tests to pipeline

Co-authored-by: Zach Bloomquist <github@chary.us>
Co-authored-by: Barthélémy Ledoux <bart@cypress.io>
This commit is contained in:
Lachlan Miller
2021-06-16 10:22:09 +10:00
committed by GitHub
parent 3029f01479
commit 929cac807a
98 changed files with 1075 additions and 3199 deletions

View File

@@ -46,7 +46,7 @@
"stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src,__snapshots__ --exclude e2e.ts,cypress-tests.ts,unwritten.spec.ts",
"stop-only-all": "yarn stop-only --folder packages",
"pretest": "yarn ensure-deps",
"test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,net-stubbing,network,proxy,rewriter,runner,socket}'\"",
"test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,net-stubbing,network,proxy,rewriter,runner,runner-shared,socket}'\"",
"test-debug": "lerna exec yarn test-debug --ignore \"'@packages/{desktop-gui,driver,root,static,web-config}'\"",
"pretest-e2e": "yarn ensure-deps",
"test-e2e": "lerna exec yarn test-e2e --ignore \"'@packages/{desktop-gui,driver,root,static,web-config}'\"",

View File

@@ -2,4 +2,5 @@
/// <reference path="../ts/index.d.ts" />
export const $Cypress: Cypress.Cypress
export default $Cypress
export const $: typeof JQuery
export default $Cypress

View File

@@ -3,7 +3,7 @@ import React from 'react'
import { mount } from '@cypress/react'
import RunnerCt from '../../src/app/RunnerCt'
import '@packages/runner/src/main.scss'
import eventManager from '../../src/lib/event-manager'
import { eventManager } from '@packages/runner-shared'
import { testSpecFile } from '../fixtures/testSpecFile'
import { makeState, fakeConfig, getPort } from './utils'

View File

@@ -3,7 +3,7 @@ import React from 'react'
import { mount } from '@cypress/react'
import RunnerCt from '../../src/app/RunnerCt'
import '@packages/runner/src/main.scss'
import eventManager from '../../src/lib/event-manager'
import { eventManager } from '@packages/runner-shared'
import { testSpecFile } from '../fixtures/testSpecFile'
import { makeState, fakeConfig, getPort } from './utils'

View File

@@ -2,7 +2,7 @@
import React from 'react'
import { mount } from '@cypress/react'
import ScriptError from '../../src/errors/script-error'
import { ScriptError } from '@packages/runner-shared'
describe('ScriptError', () => {
it('renders an error', () => {

View File

@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import State from '../lib/state'
import { Hidden } from '../lib/Hidden'
import { namedObserver } from '../lib/mobx'
import { namedObserver } from '@packages/runner-shared'
import { PLUGIN_BAR_HEIGHT } from './RunnerCt'
import styles from './RunnerCt.module.scss'

View File

@@ -3,10 +3,8 @@ import cs from 'classnames'
import { ReporterHeaderProps } from '@packages/reporter/src/header/header'
import { Reporter } from '@packages/reporter/src/main'
import errorMessages from '../errors/error-messages'
import EventManager from '../lib/event-manager'
import { errorMessages, namedObserver, eventManager as EventManager } from '@packages/runner-shared'
import State from '../lib/state'
import { namedObserver } from '../lib/mobx'
import { ReporterHeader } from './ReporterHeader'
import { NoSpec } from './NoSpec'
@@ -15,7 +13,10 @@ import styles from './RunnerCt.module.scss'
interface ReporterContainerProps {
state: State
eventManager: typeof EventManager
config: Cypress.RuntimeConfigOptions
config: {
configFile: string
[key: string]: unknown
}
}
export const ReporterContainer = namedObserver('ReporterContainer',

View File

@@ -3,7 +3,7 @@ import { ReporterHeaderProps } from '@packages/reporter/src/header/header'
import Stats from '@packages/reporter/src/header/stats'
import Controls from '@packages/reporter/src/header/controls'
import { StatsStore } from '@packages/reporter/src/header/stats-store'
import { namedObserver } from '../lib/mobx'
import { namedObserver } from '@packages/runner-shared'
import styles from './ReporterHeader.module.scss'
export const EmptyReporterHeader: React.FC = () => {

View File

@@ -14,13 +14,12 @@ library.add(fas)
library.add(fab)
import State from '../lib/state'
import EventManager from '../lib/event-manager'
import { eventManager as EventManager, namedObserver } from '@packages/runner-shared'
import { useGlobalHotKey } from '../lib/useHotKey'
import { animationFrameDebounce } from '../lib/debounce'
import { LeftNavMenu } from './LeftNavMenu'
import { SpecContent } from './SpecContent'
import { hideIfScreenshotting, hideSpecsListIfNecessary } from '../lib/hideGuard'
import { namedObserver } from '../lib/mobx'
import { SpecList } from './SpecList/SpecList'
import { NoSpec } from './NoSpec'

View File

@@ -1,27 +1,27 @@
import cs from 'classnames'
import * as React from 'react'
import SplitPane from 'react-split-pane'
import { Message, namedObserver, eventManager as EventManager, Header } from '@packages/runner-shared'
import Header from '../header/header'
import { Iframes } from '../iframe/iframes'
import { animationFrameDebounce } from '../lib/debounce'
import { Message } from '../message/message'
import { KeyboardHelper } from './KeyboardHelper'
import { NoSpec } from './NoSpec'
import { Plugins } from './Plugins'
import { ReporterContainer } from './ReporterContainer'
import { PLUGIN_BAR_HEIGHT } from './RunnerCt'
import State from '../lib/state'
import EventManager from '../lib/event-manager'
import { hideIfScreenshotting, hideReporterIfNecessary } from '../lib/hideGuard'
import styles from './RunnerCt.module.scss'
import { namedObserver } from '../lib/mobx'
interface SpecContentProps {
state: State
eventManager: typeof EventManager
config: Cypress.RuntimeConfigOptions
config: {
configFile: string
[key: string]: unknown
}
}
interface SpecContentWrapperProps {
@@ -62,7 +62,7 @@ export const SpecContent = namedObserver('SpecContent', (props: SpecContentProps
},
)}
>
<Header {...props} />
<Header {...props} runner='ct' />
{props.state.spec
? <Iframes {...props} />
: (
@@ -70,7 +70,19 @@ export const SpecContent = namedObserver('SpecContent', (props: SpecContentProps
<KeyboardHelper />
</NoSpec>
)}
<Message state={props.state} />
<Message
state={{
messageTitle: props.state.messageTitle,
messageControls: props.state.messageControls,
messageDescription: props.state.messageDescription,
messageType: props.state.messageType,
messageStyles: {
state: props.state.messageStyles.state,
styles: props.state.messageStyles.styles,
messageType: props.state.messageType,
},
}}
/>
</div>
<Plugins
key="plugins"

View File

@@ -1,6 +1,6 @@
import * as React from 'react'
import { runInAction } from 'mobx'
import EventManager from '../lib/event-manager'
import { eventManager as EventManager } from '@packages/runner-shared'
import State from '../lib/state'
/**

View File

@@ -1,24 +0,0 @@
export default {
reporterError (err, specPath) {
if (!err) return null
switch (err.type) {
case 'BUNDLE_ERROR':
return {
title: 'Oops...we found an error preparing this test file:',
link: 'https://on.cypress.io/we-found-an-error-preparing-your-test-file',
callout: specPath,
message: `
This occurred while Cypress was compiling and bundling your test code. This is usually caused by:
* A missing file or dependency
* A syntax error in the file or one of its dependencies
Fix the error in your code and re-run your tests.
`,
}
default:
return null
}
},
}

View File

@@ -1,111 +0,0 @@
import cs from 'classnames'
import { action, observable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import Tooltip from '@cypress/react-tooltip'
import State from '../lib/state'
import { configFileFormatted } from '../lib/config-file-formatted'
import SelectorPlayground from '../selector-playground/selector-playground'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
interface HeaderProps {
state: State
config: Cypress.RuntimeConfigOptions
}
@observer
export default class Header extends Component<HeaderProps> {
headerRef = React.createRef()
@observable showingViewportMenu = false
render () {
const { state, config } = this.props
return (
<header
ref={this.headerRef}
className={cs({
'showing-selector-playground': selectorPlaygroundModel.isOpen,
'display-none': state.screenshotting,
})}
>
<div className='sel-url-wrap'>
<Tooltip
title='Open Selector Playground'
visible={selectorPlaygroundModel.isOpen ? false : null}
wrapperClassName='selector-playground-toggle-tooltip-wrapper'
className='cy-tooltip'
>
<button
aria-label='Open Selector Playground'
className='header-button selector-playground-toggle'
disabled={state.isLoading || state.isRunning}
onClick={this._togglePlaygroundOpen}
>
<i aria-hidden="true" className='fas fa-crosshairs' />
</button>
</Tooltip>
</div>
<ul className='menu'>
<li className={cs('viewport-info', { 'menu-open': this.showingViewportMenu })}>
<button onClick={this._toggleViewportMenu}>
{`${state.viewportWidth} `}
<span className='the-x'>x</span>
{` ${state.viewportHeight}`}
<i className='fas fa-fw fa-info-circle'></i>
</button>
<div className='popup-menu viewport-menu'>
{/* eslint-disable react/jsx-one-expression-per-line */}
<p>The <strong>viewport</strong> determines the width and height of your application. By default the viewport will be
<strong>{state.defaults.viewportWidth}px</strong> by
<strong>{state.defaults.viewportHeight}px</strong> unless specified by a
<code>cy.viewport</code> command.
</p>
<p>Additionally you can override the default viewport dimensions by specifying these values in your {configFileFormatted(config.configFile)}.</p>
<pre>{/* eslint-disable indent */}
{`{
"viewportWidth": ${state.defaults.viewportWidth},
"viewportHeight": ${state.defaults.viewportHeight}
}`}
</pre>
{/* eslint-enable indent */}
<p>
<a href='https://on.cypress.io/viewport' target='_blank' rel='noreferrer'>
<i className='fas fa-info-circle'></i>
Read more about viewport here.
</a>
</p>
{/* eslint-enable react/jsx-one-expression-per-line */}
</div>
</li>
</ul>
<SelectorPlayground model={selectorPlaygroundModel} />
</header>
)
}
componentDidMount () {
this.previousSelectorPlaygroundOpen = selectorPlaygroundModel.isOpen
}
componentDidUpdate () {
if (selectorPlaygroundModel.isOpen !== this.previousSelectorPlaygroundOpen) {
this.props.state.updateWindowDimensions({
headerHeight: this.headerRef.current.offsetHeight,
})
this.previousSelectorPlaygroundOpen = selectorPlaygroundModel.isOpen
}
}
_togglePlaygroundOpen = () => {
selectorPlaygroundModel.toggleOpen()
}
@action _toggleViewportMenu = () => {
this.showingViewportMenu = !this.showingViewportMenu
}
}

View File

@@ -1,399 +0,0 @@
import _ from 'lodash'
import { $ } from '@packages/driver'
import dom from '../lib/dom'
import logger from '../lib/logger'
import eventManager from '../lib/event-manager'
import visitFailure from './visit-failure'
import blankContents from './blank-contents'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
export default class AutIframe {
constructor (config) {
this.config = config
this.debouncedToggleSelectorPlayground = _.debounce(this.toggleSelectorPlayground, 300)
}
create () {
this.$iframe = $('<iframe>', {
id: `Your App: '${this.config.projectName}'`,
class: 'aut-iframe',
})
return this.$iframe
}
showBlankContents () {
this._showContents(blankContents())
}
showVisitFailure = (props) => {
this._showContents(visitFailure(props))
}
_showContents (contents) {
this._body().html(contents)
}
_contents () {
return this.$iframe && this.$iframe.contents()
}
_window () {
return this.$iframe.prop('contentWindow')
}
_document () {
return this.$iframe.prop('contentDocument')
}
_body () {
return this._contents() && this._contents().find('body')
}
detachDom = () => {
const Cypress = eventManager.getCypress()
if (!Cypress) return
return Cypress.cy.detachDom(this._contents())
}
restoreDom = (snapshot) => {
const Cypress = eventManager.getCypress()
const { headStyles, bodyStyles } = Cypress ? Cypress.cy.getStyles(snapshot) : {}
const { body, htmlAttrs } = snapshot
const contents = this._contents()
const $html = contents.find('html')
this._replaceHtmlAttrs($html, htmlAttrs)
this._replaceHeadStyles(headStyles)
// remove the old body and replace with restored one
this._body().remove()
this._insertBodyStyles(body.get(), bodyStyles)
$html.append(body.get())
this.debouncedToggleSelectorPlayground(selectorPlaygroundModel.isEnabled)
}
_replaceHtmlAttrs ($html, htmlAttrs) {
let oldAttrs = {}
// remove all attributes
if ($html[0]) {
oldAttrs = _.map($html[0].attributes, (attr) => {
return attr.name
})
}
_.each(oldAttrs, (attr) => {
$html.removeAttr(attr)
})
// set the ones specified
_.each(htmlAttrs, (value, key) => {
$html.attr(key, value)
})
}
_replaceHeadStyles (styles = []) {
const $head = this._contents().find('head')
const existingStyles = $head.find('link[rel="stylesheet"],style')
_.each(styles, (style, index) => {
if (style.href) {
// make a best effort at not disturbing <link> stylesheets
// if possible by checking to see if the existing head has a
// stylesheet with the same href in the same position
this._replaceLink($head, existingStyles[index], style)
} else {
// for <style> tags, just replace them completely since the contents
// could be different and it shouldn't cause a FOUC since
// there's no http request involved
this._replaceStyle($head, existingStyles[index], style)
}
})
// remove any extra stylesheets
if (existingStyles.length > styles.length) {
existingStyles.slice(styles.length).remove()
}
}
_replaceLink ($head, existingStyle, style) {
const linkTag = this._linkTag(style)
if (!existingStyle) {
// no existing style at this index, so no more styles at all in
// the head, so just append it
$head.append(linkTag)
return
}
if (existingStyle.href !== style.href) {
$(existingStyle).replaceWith(linkTag)
}
}
_replaceStyle ($head, existingStyle, style) {
const styleTag = this._styleTag(style)
if (existingStyle) {
$(existingStyle).replaceWith(styleTag)
} else {
// no existing style at this index, so no more styles at all in
// the head, so just append it
$head.append(styleTag)
}
}
_insertBodyStyles ($body, styles = []) {
_.each(styles, (style) => {
$body.append(style.href ? this._linkTag(style) : this._styleTag(style))
})
}
_linkTag (style) {
return `<link rel="stylesheet" href="${style.href}" />`
}
_styleTag (style) {
return `<style>${style}</style>`
}
highlightEl = ({ body }, { $el, coords, highlightAttr, scrollBy }) => {
this.removeHighlights()
if (body) {
$el = body.get().find(`[${highlightAttr}]`)
} else {
body = { get: () => this._body() }
}
// normalize
const el = $el.get(0)
const $body = body.get()
body = $body.get(0)
// scroll the top of the element into view
if (el) {
el.scrollIntoView()
// if we have a scrollBy on our command
// then we need to additional scroll the window
// by these offsets
if (scrollBy) {
this.$iframe.prop('contentWindow').scrollBy(scrollBy.x, scrollBy.y)
}
}
$el.each((__, element) => {
const $_el = $(element)
// bail if our el no longer exists in the parent body
if (!$.contains(body, element)) return
// switch to using outerWidth + outerHeight
// because we want to highlight our element even
// if it only has margin and zero content height / width
const dimensions = dom.getOuterSize($_el)
// dont show anything if our element displaces nothing
if (dimensions.width === 0 || dimensions.height === 0 || $_el.css('display') === 'none') {
return
}
dom.addElementBoxModelLayers($_el, $body).attr('data-highlight-el', true)
})
if (coords) {
requestAnimationFrame(() => {
dom.addHitBoxLayer(coords, $body).attr('data-highlight-hitbox', true)
})
}
}
removeHighlights = () => {
this._contents() && this._contents().find('.__cypress-highlight').remove()
}
toggleSelectorPlayground = (isEnabled) => {
const $body = this._body()
if (!$body) return
if (isEnabled) {
$body.on('mouseenter', this._resetShowHighlight)
$body.on('mousemove', this._onSelectorMouseMove)
$body.on('mouseleave', this._clearHighlight)
} else {
$body.off('mouseenter', this._resetShowHighlight)
$body.off('mousemove', this._onSelectorMouseMove)
$body.off('mouseleave', this._clearHighlight)
if (this._highlightedEl) {
this._clearHighlight()
}
}
}
_resetShowHighlight = () => {
selectorPlaygroundModel.setShowingHighlight(false)
}
_onSelectorMouseMove = (e) => {
const $body = this._body()
if (!$body) return
let el = e.target
let $el = $(el)
const $ancestorHighlight = $el.closest('.__cypress-selector-playground')
if ($ancestorHighlight.length) {
$el = $ancestorHighlight
}
if ($ancestorHighlight.length || $el.hasClass('__cypress-selector-playground')) {
const $highlight = $el
$highlight.css('display', 'none')
el = this._document().elementFromPoint(e.clientX, e.clientY)
$el = $(el)
$highlight.css('display', 'block')
}
if (this._highlightedEl === el) return
this._highlightedEl = el
const Cypress = eventManager.getCypress()
const selector = Cypress.SelectorPlayground.getSelector($el)
dom.addOrUpdateSelectorPlaygroundHighlight({
$el,
selector,
$body,
showTooltip: true,
onClick: () => {
selectorPlaygroundModel.setNumElements(1)
selectorPlaygroundModel.resetMethod()
selectorPlaygroundModel.setSelector(selector)
},
})
}
_clearHighlight = () => {
const $body = this._body()
if (!$body) return
dom.addOrUpdateSelectorPlaygroundHighlight({ $el: null, $body })
if (this._highlightedEl) {
this._highlightedEl = null
}
}
toggleSelectorHighlight (isShowingHighlight) {
if (!isShowingHighlight) {
this._clearHighlight()
return
}
const Cypress = eventManager.getCypress()
const $el = this.getElements(Cypress.dom)
selectorPlaygroundModel.setValidity(!!$el)
if ($el) {
selectorPlaygroundModel.setNumElements($el.length)
if ($el.length) {
dom.scrollIntoView(this._window(), $el[0])
}
}
dom.addOrUpdateSelectorPlaygroundHighlight({
$el: $el && $el.length ? $el : null,
selector: selectorPlaygroundModel.selector,
$body: this._body(),
showTooltip: false,
})
}
getElements (cypressDom) {
const { selector, method } = selectorPlaygroundModel
const $contents = this._contents()
if (!$contents || !selector) return
return dom.getElementsForSelector({
method,
selector,
cypressDom,
$root: $contents,
})
}
printSelectorElementsToConsole () {
logger.clearLog()
const Cypress = eventManager.getCypress()
const $el = this.getElements(Cypress.dom)
const command = `cy.${selectorPlaygroundModel.method}('${selectorPlaygroundModel.selector}')`
if (!$el) {
return logger.logFormatted({
Command: command,
Yielded: 'Nothing',
})
}
logger.logFormatted({
Command: command,
Elements: $el.length,
Yielded: Cypress.dom.getElements($el),
})
}
beforeScreenshot = (config) => {
// could fail if iframe is cross-origin, so fail gracefully
try {
if (config.disableTimersAndAnimations) {
dom.addCssAnimationDisabler(this._body())
}
_.each(config.blackout, (selector) => {
dom.addBlackout(this._body(), selector)
})
} catch (err) {
/* eslint-disable no-console */
console.error('Failed to modify app dom:')
console.error(err)
/* eslint-disable no-console */
}
}
afterScreenshot = (config) => {
// could fail if iframe is cross-origin, so fail gracefully
try {
if (config.disableTimersAndAnimations) {
dom.removeCssAnimationDisabler(this._body())
}
dom.removeBlackouts(this._body())
} catch (err) {
/* eslint-disable no-console */
console.error('Failed to modify app dom:')
console.error(err)
/* eslint-disable no-console */
}
}
}

View File

@@ -1,232 +0,0 @@
import _ from 'lodash'
import { action } from 'mobx'
import eventManager from '../lib/event-manager'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
export default class IframeModel {
constructor ({ state, detachDom, restoreDom, highlightEl, snapshotControls }) {
this.state = state
this.detachDom = detachDom
this.restoreDom = restoreDom
this.highlightEl = highlightEl
this.snapshotControls = snapshotControls
this._reset()
}
listen () {
eventManager.on('run:start', action('run:start', this._beforeRun))
eventManager.on('run:end', action('run:end', this._afterRun))
eventManager.on('viewport:changed', action('viewport:changed', this._updateViewport))
eventManager.on('config', action('config', (config) => {
this._updateViewport(_.map(config, 'viewportHeight', 'viewportWidth'))
}))
eventManager.on('url:changed', action('url:changed', this._updateUrl))
eventManager.on('page:loading', action('page:loading', this._updateLoadingUrl))
eventManager.on('show:snapshot', action('show:snapshot', this._setSnapshots))
eventManager.on('hide:snapshot', action('hide:snapshot', this._clearSnapshots))
eventManager.on('pin:snapshot', action('pin:snapshot', this._pinSnapshot))
eventManager.on('unpin:snapshot', action('unpin:snapshot', this._unpinSnapshot))
}
_beforeRun = () => {
this.state.isLoading = false
this.state.isRunning = true
this.state.resetUrl()
selectorPlaygroundModel.setEnabled(false)
this._reset()
this._clearMessage()
}
_afterRun = () => {
this.state.isRunning = false
}
_updateViewport = ({ viewportWidth, viewportHeight }, cb) => {
this.state.updateAutViewportDimensions({ viewportWidth, viewportHeight })
if (cb) {
this.state.setCallbackAfterUpdate(cb)
}
}
_updateUrl = (url) => {
this.state.url = url
}
_updateLoadingUrl = (isLoadingUrl) => {
this.state.isLoadingUrl = isLoadingUrl
}
_clearMessage = () => {
this.state.clearMessage()
}
_setSnapshots = (snapshotProps) => {
if (this.isSnapshotPinned) return
if (this.state.isRunning) {
return this._testsRunningError()
}
const { snapshots } = snapshotProps
if (!snapshots || !snapshots.length) {
this._clearSnapshots()
this._setMissingSnapshotMessage()
return
}
this.state.highlightUrl = true
if (!this.originalState) {
this._storeOriginalState()
}
this.detachedId = snapshotProps.id
this._updateViewport(snapshotProps)
this._updateUrl(snapshotProps.url)
clearInterval(this.intervalId)
const revert = action('revert:snapshot', this._showSnapshot)
if (snapshots.length > 1) {
let i = 0
this.intervalId = setInterval(() => {
if (this.isSnapshotPinned) return
i += 1
if (!snapshots[i]) {
i = 0
}
revert(snapshots[i], snapshotProps)
}, 800)
}
revert(snapshots[0], snapshotProps)
}
_showSnapshot = (snapshot, snapshotProps) => {
this.state.messageTitle = 'DOM Snapshot'
this.state.messageDescription = snapshot.name
this.state.messageType = ''
this._restoreDom(snapshot, snapshotProps)
}
_restoreDom (snapshot, snapshotProps) {
this.restoreDom(snapshot)
if (snapshotProps.$el) {
this.highlightEl(snapshot, snapshotProps)
}
}
_clearSnapshots = () => {
if (this.isSnapshotPinned) return
clearInterval(this.intervalId)
this.state.highlightUrl = false
if (!this.originalState || !this.originalState.body) {
return this._clearMessage()
}
const previousDetachedId = this.detachedId
// process on next tick so we don't restore the dom if we're
// about to receive another 'show:snapshot' event, else that would
// be a huge waste
setTimeout(action('clear:snapshots:next:tick', () => {
// we want to only restore the dom if we haven't received
// another snapshot by the time this function runs
if (previousDetachedId !== this.detachedId) return
this._updateViewport(this.originalState)
this._updateUrl(this.originalState.url)
this.restoreDom(this.originalState.snapshot)
this._clearMessage()
this.originalState = null
this.detachedId = null
}))
}
_pinSnapshot = (snapshotProps) => {
const { snapshots } = snapshotProps
if (!snapshots || !snapshots.length) {
eventManager.snapshotUnpinned()
this._setMissingSnapshotMessage()
return
}
clearInterval(this.intervalId)
this.isSnapshotPinned = true
this.state.snapshot.showingHighlights = true
this.state.snapshot.stateIndex = 0
this.state.messageTitle = 'DOM Snapshot'
this.state.messageDescription = 'pinned'
this.state.messageType = 'info'
this.state.messageControls = this.snapshotControls(snapshotProps)
this._restoreDom(snapshots[0], snapshotProps)
}
_setMissingSnapshotMessage () {
this.state.messageTitle = 'The snapshot is missing. Displaying current state of the DOM.'
this.state.messageDescription = ''
this.state.messageType = 'warning'
}
_unpinSnapshot = () => {
this.isSnapshotPinned = false
this.state.messageTitle = 'DOM Snapshot'
this.state.messageDescription = ''
this.state.messageControls = null
}
_testsRunningError () {
this.state.messageTitle = 'Cannot show Snapshot while tests are running'
this.state.messageType = 'warning'
}
_storeOriginalState () {
const finalSnapshot = this.detachDom()
if (!finalSnapshot) return
const { body, htmlAttrs } = finalSnapshot
this.originalState = {
body,
htmlAttrs,
snapshot: finalSnapshot,
url: this.state.url,
viewportWidth: this.state.viewportWidth,
viewportHeight: this.state.viewportHeight,
}
}
_reset () {
this.detachedId = null
this.intervalId = null
this.originalState = null
this.isSnapshotPinned = false
}
}

View File

@@ -2,16 +2,18 @@ import cs from 'classnames'
import { action, when, autorun } from 'mobx'
import React, { useRef, useEffect } from 'react'
import { default as $Cypress } from '@packages/driver'
import {
SnapshotControls,
ScriptError,
namedObserver,
IframeModel,
selectorPlaygroundModel,
AutIframe,
eventManager as EventManager,
} from '@packages/runner-shared'
import State from '../../src/lib/state'
import AutIframe from './aut-iframe'
import { ScriptError } from '../errors/script-error'
import SnapshotControls from './snapshot-controls'
import IframeModel from './iframe-model'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import styles from '../app/RunnerCt.module.scss'
import eventManager from '../lib/event-manager'
import { namedObserver } from '../lib/mobx'
import './iframes.scss'
export function getSpecUrl ({ namespace, spec }, prefix = '') {
@@ -20,7 +22,7 @@ export function getSpecUrl ({ namespace, spec }, prefix = '') {
interface IFramesProps {
state: State
eventManager: typeof eventManager
eventManager: typeof EventManager
config: Cypress.RuntimeConfigOptions
}
@@ -168,7 +170,7 @@ export const Iframes = namedObserver('Iframes', ({
state.callbackAfterUpdate?.()
})
const { viewportHeight, viewportWidth, scriptError, scale, screenshotting } = state
const { height, width, scriptError, scale, screenshotting } = state
return (
<div
@@ -188,8 +190,8 @@ export const Iframes = namedObserver('Iframes', ({
})
}
style={{
height: viewportHeight,
width: viewportWidth,
height,
width,
transform: `scale(${screenshotting ? 1 : scale})`,
}}
/>

View File

@@ -1,78 +0,0 @@
import cs from 'classnames'
import _ from 'lodash'
import { action } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import Tooltip from '@cypress/react-tooltip'
@observer
class SnapshotControls extends Component {
render () {
return (
<span
className={cs('snapshot-controls', {
'showing-selection': this.props.state.snapshot.showingHighlights,
})}
>
{this._selectionToggle()}
{this._states()}
<Tooltip title='Unpin' className='cy-tooltip'>
<button className='unpin' onClick={this._unpin}>
<i className='fas fa-times' />
</button>
</Tooltip>
</span>
)
}
_selectionToggle () {
if (!this.props.snapshotProps.$el) return null
const showingHighlights = this.props.state.snapshot.showingHighlights
return (
<Tooltip title={`${showingHighlights ? 'Hide' : 'Show'} Highlights`} className='cy-tooltip'>
<button className='toggle-selection' onClick={this._toggleHighlights}>
<i className='far fa-object-group' />
</button>
</Tooltip>
)
}
_states () {
const { snapshots } = this.props.snapshotProps
if (snapshots.length < 2) return null
return (
<span className='snapshot-state-picker'>
{_.map(snapshots, (snapshot, index) => (
<button
key={snapshot.name ?? index}
className={cs({
'state-is-selected': this.props.state.snapshot.stateIndex === index,
})}
href="#"
onClick={this._changeState(index)}
>
{snapshot.name ?? index + 1}
</button>
))}
</span>
)
}
_unpin = () => {
this.props.eventManager.snapshotUnpinned()
}
@action _toggleHighlights = () => {
this.props.onToggleHighlights(this.props.snapshotProps)
}
_changeState = (index) => action('change:snapshot:state', () => {
this.props.onStateChange(this.props.snapshotProps, index)
})
}
export default SnapshotControls

View File

@@ -1,33 +0,0 @@
import React from 'react'
import { isUndefined } from 'lodash'
const configFileFormatted = (configFile) => {
if (configFile === false) {
return (
<>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
<code>cypress.json</code> file (currently disabled by <code>--config-file false</code>)
</>
)
}
if (isUndefined(configFile) || configFile === 'cypress.json') {
return (
<>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
<code>cypress.json</code> file
</>
)
}
return (
<>
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
custom config file <code>{configFile}</code>
</>
)
}
export {
configFileFormatted,
}

View File

@@ -1,457 +0,0 @@
import _ from 'lodash'
import { $ } from '@packages/driver'
import selectorPlaygroundHighlight from '@packages/runner/src/selector-playground/highlight'
// The '!' tells webpack to disable normal loaders, and keep loaders with `enforce: 'pre'` and `enforce: 'post'`
// This disables the CSSExtractWebpackPlugin and allows us to get the CSS as a raw string instead of saving it to a separate file.
import selectorPlaygroundCSS from '!@packages/runner/src/selector-playground/selector-playground.scss'
const styles = (styleString) => {
return styleString.replace(/\s*\n\s*/g, '')
}
const resetStyles = `
border: none !important;
margin: 0 !important;
padding: 0 !important;
`
function addHitBoxLayer (coords, $body) {
$body = $body || $('body')
const height = 10
const width = 10
const dotHeight = 4
const dotWidth = 4
const top = coords.y - height / 2
const left = coords.x - width / 2
const dotTop = height / 2 - dotHeight / 2
const dotLeft = width / 2 - dotWidth / 2
const boxStyles = styles(`
${resetStyles}
position: absolute;
top: ${top}px;
left: ${left}px;
width: ${width}px;
height: ${height}px;
background-color: red;
border-radius: 5px;
box-shadow: 0 0 5px #333;
z-index: 2147483647;
`)
const $box = $(`<div class="__cypress-highlight" style="${boxStyles}" />`)
const wrapper = $(`<div style="${styles(resetStyles)} position: relative" />`).appendTo($box)
const dotStyles = styles(`
${resetStyles}
position: absolute;
top: ${dotTop}px;
left: ${dotLeft}px;
height: ${dotHeight}px;
width: ${dotWidth}px;
height: ${dotHeight}px;
background-color: pink;
border-radius: 5px;
`)
$(`<div style="${dotStyles}">`).appendTo(wrapper)
return $box.appendTo($body)
}
function addElementBoxModelLayers ($el, $body) {
$body = $body || $('body')
const dimensions = getElementDimensions($el)
const $container = $('<div class="__cypress-highlight">')
.css({
opacity: 0.7,
position: 'absolute',
zIndex: 2147483647,
})
const layers = {
Content: '#9FC4E7',
Padding: '#C1CD89',
Border: '#FCDB9A',
Margin: '#F9CC9D',
}
// create the margin / bottom / padding layers
_.each(layers, (color, attr) => {
let obj
switch (attr) {
case 'Content':
// rearrange the contents offset so
// its inside of our border + padding
obj = {
width: dimensions.width,
height: dimensions.height,
top: dimensions.offset.top + dimensions.borderTop + dimensions.paddingTop,
left: dimensions.offset.left + dimensions.borderLeft + dimensions.paddingLeft,
}
break
default:
obj = {
width: getDimensionsFor(dimensions, attr, 'width'),
height: getDimensionsFor(dimensions, attr, 'height'),
top: dimensions.offset.top,
left: dimensions.offset.left,
}
}
// if attr is margin then we need to additional
// subtract what the actual marginTop + marginLeft
// values are, since offset disregards margin completely
if (attr === 'Margin') {
obj.top -= dimensions.marginTop
obj.left -= dimensions.marginLeft
}
if (attr === 'Padding') {
obj.top += dimensions.borderTop
obj.left += dimensions.borderLeft
}
// bail if the dimensions of this layer match the previous one
// so we dont create unnecessary layers
if (dimensionsMatchPreviousLayer(obj, $container)) return
return createLayer($el, attr, color, $container, obj)
})
$container.appendTo($body)
$container.children().each((index, el) => {
const $el = $(el)
const top = $el.data('top')
const left = $el.data('left')
// dont ask... for some reason we
// have to run offset twice!
_.times(2, () => {
return $el.offset({ top, left })
})
})
return $container
}
function getOrCreateSelectorHelperDom ($body) {
let $container = $body.find('.__cypress-selector-playground')
if ($container.length) {
const shadowRoot = $container[0].shadowRoot
return {
$container,
shadowRoot,
$reactContainer: $(shadowRoot).find('.react-container'),
}
}
$container = $('<div />')
.addClass('__cypress-selector-playground')
.css({ position: 'static' })
.appendTo($body)
const shadowRoot = $container[0].attachShadow({ mode: 'open' })
const $reactContainer = $('<div />')
.addClass('react-container')
.appendTo(shadowRoot)
$('<style />', { html: selectorPlaygroundCSS.toString() }).prependTo(shadowRoot)
return { $container, shadowRoot, $reactContainer }
}
function addOrUpdateSelectorPlaygroundHighlight ({ $el, $body, selector, showTooltip, onClick }) {
const { $container, shadowRoot, $reactContainer } = getOrCreateSelectorHelperDom($body)
if (!$el) {
selectorPlaygroundHighlight.unmount($reactContainer[0])
$reactContainer.off('click')
$container.remove()
return
}
const borderSize = 2
const styles = $el.map((__, el) => {
const $el = $(el)
const offset = $el.offset()
return {
position: 'absolute',
margin: 0,
padding: 0,
width: $el.outerWidth(),
height: $el.outerHeight(),
top: offset.top - borderSize,
left: offset.left - borderSize,
transform: $el.css('transform'),
zIndex: getZIndex($el),
}
}).get()
if ($el.length === 1) {
$reactContainer
.off('click')
.on('click', onClick)
}
selectorPlaygroundHighlight.render($reactContainer[0], {
selector,
appendTo: shadowRoot,
showTooltip,
styles,
})
}
function createLayer ($el, attr, color, container, dimensions) {
const transform = $el.css('transform')
const css = {
transform,
width: dimensions.width,
height: dimensions.height,
position: 'absolute',
zIndex: getZIndex($el),
backgroundColor: color,
}
return $('<div>')
.css(css)
.attr('data-top', dimensions.top)
.attr('data-left', dimensions.left)
.attr('data-layer', attr)
.prependTo(container)
}
function dimensionsMatchPreviousLayer (obj, container) {
// since we're prepending to the container that
// means the previous layer is actually the first child element
const previousLayer = container.children().first().get(0)
// bail if there is no previous layer
if (!previousLayer) {
return
}
return obj.width === previousLayer.offsetWidth &&
obj.height === previousLayer.offsetHeight
}
function getDimensionsFor (dimensions, attr, dimension) {
return dimensions[`${dimension}With${attr}`]
}
function getZIndex (el) {
if (/^(auto|0)$/.test(el.css('zIndex'))) {
return 2147483647
}
return _.toNumber(el.css('zIndex'))
}
function getElementDimensions ($el) {
const el = $el.get(0)
const { offsetHeight, offsetWidth } = el
const box = {
offset: $el.offset(), // offset disregards margin but takes into account border + padding
// dont use jquery here for width/height because it uses getBoundingClientRect() which returns scaled values.
// TODO: switch back to using jquery when upgrading to jquery 3.4+
paddingTop: getPadding($el, 'top'),
paddingRight: getPadding($el, 'right'),
paddingBottom: getPadding($el, 'bottom'),
paddingLeft: getPadding($el, 'left'),
borderTop: getBorder($el, 'top'),
borderRight: getBorder($el, 'right'),
borderBottom: getBorder($el, 'bottom'),
borderLeft: getBorder($el, 'left'),
marginTop: getMargin($el, 'top'),
marginRight: getMargin($el, 'right'),
marginBottom: getMargin($el, 'bottom'),
marginLeft: getMargin($el, 'left'),
}
// NOTE: offsetWidth/height always give us content + padding + border, so subtract them
// to get the true "clientHeight" and "clientWidth".
// we CANNOT just use "clientHeight" and "clientWidth" because those always return 0
// for inline elements >_<
//
box.width = offsetWidth - (box.paddingLeft + box.paddingRight + box.borderLeft + box.borderRight)
box.height = offsetHeight - (box.paddingTop + box.paddingBottom + box.borderTop + box.borderBottom)
// innerHeight: Get the current computed height for the first
// element in the set of matched elements, including padding but not border.
// outerHeight: Get the current computed height for the first
// element in the set of matched elements, including padding, border,
// and optionally margin. Returns a number (without 'px') representation
// of the value or null if called on an empty set of elements.
box.heightWithPadding = box.height + box.paddingTop + box.paddingBottom
box.heightWithBorder = box.heightWithPadding + box.borderTop + box.borderBottom
box.heightWithMargin = box.heightWithBorder + box.marginTop + box.marginBottom
box.widthWithPadding = box.width + box.paddingLeft + box.paddingRight
box.widthWithBorder = box.widthWithPadding + box.borderLeft + box.borderRight
box.widthWithMargin = box.widthWithBorder + box.marginLeft + box.marginRight
return box
}
function getNumAttrValue ($el, attr) {
// nuke anything thats not a number or a negative symbol
const num = _.toNumber($el.css(attr).replace(/[^0-9\.-]+/, ''))
if (!_.isFinite(num)) {
throw new Error('Element attr did not return a valid number')
}
return num
}
function getPadding ($el, dir) {
return getNumAttrValue($el, `padding-${dir}`)
}
function getBorder ($el, dir) {
return getNumAttrValue($el, `border-${dir}-width`)
}
function getMargin ($el, dir) {
return getNumAttrValue($el, `margin-${dir}`)
}
function getOuterSize ($el) {
return {
width: $el.outerWidth(true),
height: $el.outerHeight(true),
}
}
function isInViewport (win, el) {
let rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= win.innerHeight &&
rect.right <= win.innerWidth
)
}
function scrollIntoView (win, el) {
if (!el || isInViewport(win, el)) return
el.scrollIntoView()
}
const sizzleRe = /sizzle/i
function getElementsForSelector ({ $root, selector, method, cypressDom }) {
let $el = null
try {
if (method === 'contains') {
$el = $root.find(cypressDom.getContainsSelector(selector))
if ($el.length) {
$el = cypressDom.getFirstDeepestElement($el)
}
} else {
$el = $root.find(selector)
}
} catch (err) {
// if not a sizzle error, ignore it and let $el be null
if (!sizzleRe.test(err.stack)) throw err
}
return $el
}
function addCssAnimationDisabler ($body) {
$(`
<style id="__cypress-animation-disabler">
*, *:before, *:after {
transition-property: none !important;
animation: none !important;
}
</style>
`).appendTo($body)
}
function removeCssAnimationDisabler ($body) {
$body.find('#__cypress-animation-disabler').remove()
}
function addBlackoutForElement ($body, $el) {
const dimensions = getElementDimensions($el)
const width = dimensions.widthWithBorder
const height = dimensions.heightWithBorder
const top = dimensions.offset.top
const left = dimensions.offset.left
const style = styles(`
${resetStyles}
position: absolute;
top: ${top}px;
left: ${left}px;
width: ${width}px;
height: ${height}px;
background-color: black;
z-index: 2147483647;
`)
$(`<div class="__cypress-blackout" style="${style}">`).appendTo($body)
}
function addBlackout ($body, selector) {
let $el
try {
$el = $body.find(selector)
if (!$el.length) return
} catch (err) {
// if it's an invalid selector, just ignore it
return
}
$el.each(function () {
addBlackoutForElement($body, $(this))
})
}
function removeBlackouts ($body) {
$body.find('.__cypress-blackout').remove()
}
export default {
addBlackout,
removeBlackouts,
addElementBoxModelLayers,
addHitBoxLayer,
addOrUpdateSelectorPlaygroundHighlight,
addCssAnimationDisabler,
removeCssAnimationDisabler,
getElementsForSelector,
getOuterSize,
scrollIntoView,
}

View File

@@ -1,491 +0,0 @@
import _ from 'lodash'
import { EventEmitter } from 'events'
import Promise from 'bluebird'
import { action } from 'mobx'
import { client } from '@packages/socket'
import automation from './automation'
import logger from './logger'
import $Cypress, { $ } from '@packages/driver'
const ws = client.connect({
path: '/__socket.io',
transports: ['websocket'],
})
ws.on('connect', () => {
ws.emit('runner:connected')
})
const driverToReporterEvents = 'paused before:firefox:force:gc after:firefox:force:gc'.split(' ')
const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ')
const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame'.split(' ')
const driverTestEvents = 'test:before:run:async test:after:run'.split(' ')
const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed'.split(' ')
const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ')
const socketToDriverEvents = 'net:event script:error'.split(' ')
const localBus = new EventEmitter()
const reporterBus = new EventEmitter()
// NOTE: this is exposed for testing, ideally we should only expose this if a test flag is set
window.runnerWs = ws
// NOTE: this is for testing Cypress-in-Cypress, window.Cypress is undefined here
// unless Cypress has been loaded into the AUT frame
if (window.Cypress) {
window.eventManager = { reporterBus, localBus }
}
/**
* @type {Cypress.Cypress}
*/
let Cypress
const eventManager = {
reporterBus,
getCypress () {
return Cypress
},
addGlobalListeners (state, connectionInfo) {
const rerun = () => {
if (!this) {
// if the tests have been reloaded
// then nothing to rerun
return
}
return this._reRun(state)
}
ws.emit('is:automation:client:connected', connectionInfo, action('automationEnsured', (isConnected) => {
state.automation = isConnected ? automation.CONNECTED : automation.MISSING
ws.on('automation:disconnected', action('automationDisconnected', () => {
state.automation = automation.DISCONNECTED
}))
}))
ws.on('change:to:url', (url) => {
window.location.href = url
})
ws.on('automation:push:message', (msg, data = {}) => {
if (!Cypress) return
switch (msg) {
case 'change:cookie':
Cypress.Cookies.log(data.message, data.cookie, data.removed)
break
default:
break
}
})
ws.on('component:specs:changed', (specs) => {
state.setSpecs(specs)
})
ws.on('dev-server:hmr:error', (error) => {
Cypress.stop()
localBus.emit('script:error', error)
})
_.each(socketRerunEvents, (event) => {
ws.on(event, rerun)
})
_.each(socketToDriverEvents, (event) => {
ws.on(event, (...args) => {
Cypress.emit(event, ...args)
})
})
const logCommand = (logId) => {
const consoleProps = Cypress.runner.getConsolePropsForLogById(logId)
logger.logFormatted(consoleProps)
}
reporterBus.on('runner:console:error', ({ err, commandId }) => {
if (!Cypress) return
if (commandId || err) logger.clearLog()
if (commandId) logCommand(commandId)
if (err) logger.logError(err.stack)
})
reporterBus.on('runner:console:log', (logId) => {
if (!Cypress) return
logger.clearLog()
logCommand(logId)
})
reporterBus.on('focus:tests', this.focusTests)
reporterBus.on('get:user:editor', (cb) => {
ws.emit('get:user:editor', cb)
})
reporterBus.on('set:user:editor', (editor) => {
ws.emit('set:user:editor', editor)
})
reporterBus.on('runner:restart', rerun)
function sendEventIfSnapshotProps (logId, event) {
if (!Cypress) return
const snapshotProps = Cypress.runner.getSnapshotPropsForLogById(logId)
if (snapshotProps) {
localBus.emit(event, snapshotProps)
}
}
reporterBus.on('runner:show:snapshot', (logId) => {
sendEventIfSnapshotProps(logId, 'show:snapshot')
})
reporterBus.on('runner:hide:snapshot', this._hideSnapshot.bind(this))
reporterBus.on('runner:pin:snapshot', (logId) => {
sendEventIfSnapshotProps(logId, 'pin:snapshot')
})
reporterBus.on('runner:unpin:snapshot', this._unpinSnapshot.bind(this))
reporterBus.on('runner:resume', () => {
if (!Cypress) return
Cypress.emit('resume:all')
})
reporterBus.on('runner:next', () => {
if (!Cypress) return
Cypress.emit('resume:next')
})
reporterBus.on('runner:stop', () => {
if (!Cypress) return
Cypress.stop()
})
reporterBus.on('save:state', (state) => {
this.saveState(state)
})
reporterBus.on('external:open', (url) => {
ws.emit('external:open', url)
})
reporterBus.on('open:file', (url) => {
ws.emit('open:file', url)
})
const $window = $(window)
// when we actually unload then
// nuke all of the cookies again
// so we clear out unload
$window.on('unload', () => {
this._clearAllCookies()
})
// when our window triggers beforeunload
// we know we've change the URL and we need
// to clear our cookies
// additionally we set unload to true so
// that Cypress knows not to set any more
// cookies
$window.on('beforeunload', () => {
reporterBus.emit('reporter:restart:test:run')
this._clearAllCookies()
this._setUnload()
})
},
start (config) {
if (config.socketId) {
ws.emit('app:connect', config.socketId)
}
},
setup (config) {
Cypress = this.Cypress = $Cypress.create(config)
// expose Cypress globally
// since CT AUT shares the window with the spec, we don't want to overwrite
// our spec Cypress instance with the component's Cypress instance
if (window.top === window) {
window.Cypress = Cypress
}
this._addCypressListeners(Cypress)
ws.emit('watch:test:file', config.spec)
},
isBrowser (browserName) {
if (!this.Cypress) return false
return this.Cypress.isBrowser(browserName)
},
initialize ($autIframe, config) {
performance.mark('initialize-start')
return Cypress.initialize({
$autIframe,
onSpecReady: () => {
// get the current runnable in case we reran mid-test due to a visit
// to a new domain
ws.emit('get:existing:run:state', (state = {}) => {
if (!Cypress.runner) {
// the tests have been reloaded
return
}
const runnables = Cypress.runner.normalizeAll(state.tests)
const run = () => {
performance.mark('initialize-end')
performance.measure('initialize', 'initialize-start', 'initialize-end')
this._runDriver(state)
}
reporterBus.emit('runnables:ready', runnables)
if (state.numLogs) {
Cypress.runner.setNumLogs(state.numLogs)
}
if (state.startTime) {
Cypress.runner.setStartTime(state.startTime)
}
if (config.isTextTerminal && !state.currentId) {
// we are in run mode and it's the first load
// store runnables in backend and maybe send to dashboard
return ws.emit('set:runnables:and:maybe:record:tests', runnables, run)
}
if (state.currentId) {
// if we have a currentId it means
// we need to tell the Cypress to skip
// ahead to that test
Cypress.runner.resumeAtTest(state.currentId, state.emissions)
}
run()
})
},
})
},
_addCypressListeners (Cypress) {
Cypress.on('message', (msg, data, cb) => {
ws.emit('client:request', msg, data, cb)
})
_.each(driverToSocketEvents, (event) => {
Cypress.on(event, (...args) => {
return ws.emit(event, ...args)
})
})
Cypress.on('collect:run:state', () => {
if (Cypress.env('NO_COMMAND_LOG')) {
return Promise.resolve()
}
return new Promise((resolve) => {
reporterBus.emit('reporter:collect:run:state', resolve)
})
})
Cypress.on('log:added', (log) => {
const displayProps = Cypress.runner.getDisplayPropsForLog(log)
reporterBus.emit('reporter:log:add', displayProps)
})
Cypress.on('log:changed', (log) => {
const displayProps = Cypress.runner.getDisplayPropsForLog(log)
reporterBus.emit('reporter:log:state:changed', displayProps)
})
Cypress.on('before:screenshot', (config, cb) => {
const beforeThenCb = () => {
localBus.emit('before:screenshot', config)
cb()
}
if (Cypress.env('NO_COMMAND_LOG')) {
return beforeThenCb()
}
const wait = !config.appOnly && config.waitForCommandSynchronization
if (!config.appOnly) {
reporterBus.emit('test:set:state', _.pick(config, 'id', 'isOpen'), wait ? beforeThenCb : undefined)
}
if (!wait) beforeThenCb()
})
Cypress.on('after:screenshot', (config) => {
localBus.emit('after:screenshot', config)
})
_.each(driverToReporterEvents, (event) => {
Cypress.on(event, (...args) => {
reporterBus.emit(event, ...args)
})
})
_.each(driverTestEvents, (event) => {
Cypress.on(event, (test, cb) => {
reporterBus.emit(event, test, cb)
})
})
_.each(driverToLocalAndReporterEvents, (event) => {
Cypress.on(event, (...args) => {
localBus.emit(event, ...args)
reporterBus.emit(event, ...args)
})
})
_.each(driverToLocalEvents, (event) => {
Cypress.on(event, (...args) => {
return localBus.emit(event, ...args)
})
})
Cypress.on('script:error', (err) => {
Cypress.stop()
localBus.emit('script:error', err)
})
},
_runDriver (state) {
performance.mark('run-s')
Cypress.run(() => {
performance.mark('run-e')
performance.measure('run', 'run-s', 'run-e')
})
reporterBus.emit('reporter:start', {
firefoxGcInterval: Cypress.getFirefoxGcInterval(),
startTime: Cypress.runner.getStartTime(),
numPassed: state.passed,
numFailed: state.failed,
numPending: state.pending,
autoScrollingEnabled: state.autoScrollingEnabled,
scrollTop: state.scrollTop,
})
},
stop () {
localBus.removeAllListeners()
ws.off()
},
_reRun (state) {
if (!Cypress) return
state.setIsLoading(true)
// when we are re-running we first
// need to stop cypress always
Cypress.stop()
return this._restart()
.then(() => {
// this probably isn't 100% necessary
// since Cypress will fall out of scope
// but we want to be aggressive here
// and force GC early and often
Cypress.removeAllListeners()
localBus.emit('restart')
})
},
_restart () {
return new Promise((resolve) => {
reporterBus.once('reporter:restarted', resolve)
reporterBus.emit('reporter:restart:test:run')
})
},
emit (event, ...args) {
localBus.emit(event, ...args)
},
on (event, ...args) {
localBus.on(event, ...args)
},
off (event, ...args) {
localBus.off(event, ...args)
},
notifyRunningSpec (specFile) {
ws.emit('spec:changed', specFile)
},
focusTests () {
ws.emit('focus:tests')
},
snapshotUnpinned () {
this._unpinSnapshot()
this._hideSnapshot()
reporterBus.emit('reporter:snapshot:unpinned')
},
_unpinSnapshot () {
localBus.emit('unpin:snapshot')
},
_hideSnapshot () {
localBus.emit('hide:snapshot')
},
launchBrowser (browser) {
ws.emit('reload:browser', window.location.toString(), browser && browser.name)
},
// clear all the cypress specific cookies
// whenever our app starts
// and additional when we stop running our tests
_clearAllCookies () {
if (!Cypress) return
Cypress.Cookies.clearCypressCookies()
},
_setUnload () {
if (!Cypress) return
Cypress.Cookies.setCy('unload', true)
},
saveState (state) {
ws.emit('save:app:state', state)
},
}
export default eventManager

View File

@@ -1,8 +1,8 @@
import { action, computed, observable } from 'mobx'
import _ from 'lodash'
import automation from './automation'
import { UIPlugin } from '../plugins/UIPlugin'
import { nanoid } from 'nanoid'
import { automation } from '@packages/runner-shared'
import {
DEFAULT_REPORTER_WIDTH,
LEFT_NAV_WIDTH,
@@ -21,16 +21,13 @@ interface Defaults {
messageType: string
messageControls: unknown
width: number
height: number
reporterWidth: number | null
pluginsHeight: number | null
specListWidth: number | null
isSpecsListOpen: boolean
viewportHeight: number
viewportWidth: number
height: number
width: number
url: string
highlightUrl: boolean
@@ -48,11 +45,8 @@ const _defaults: Defaults = {
messageType: '',
messageControls: null,
width: 500,
height: 500,
viewportHeight: 500,
viewportWidth: 500,
width: 500,
pluginsHeight: PLUGIN_BAR_HEIGHT,
@@ -111,9 +105,6 @@ export default class State {
@observable windowWidth = 0
@observable windowHeight = 0
@observable viewportWidth = _defaults.viewportWidth
@observable viewportHeight = _defaults.viewportHeight
@observable automation = automation.CONNECTING
@observable.ref scriptError: string | undefined
@@ -175,10 +166,10 @@ export default class State {
return 1
}
if (autAreaWidth < this.viewportWidth || autAreaHeight < this.viewportHeight) {
if (autAreaWidth < this.width || autAreaHeight < this.height) {
return Math.min(
autAreaWidth / this.viewportWidth,
autAreaHeight / this.viewportHeight,
autAreaWidth / this.width,
autAreaHeight / this.height,
)
}
@@ -218,9 +209,9 @@ export default class State {
this.screenshotting = screenshotting
}
@action updateAutViewportDimensions (dimensions: { viewportWidth: number, viewportHeight: number }) {
this.viewportHeight = dimensions.viewportHeight
this.viewportWidth = dimensions.viewportWidth
@action updateDimensions (width: number, height: number) {
this.height = height
this.width = width
}
@action toggleIsSpecsListOpen () {
@@ -249,7 +240,11 @@ export default class State {
this.specListWidth = width
}
@action updateWindowDimensions ({ windowWidth, windowHeight }: { windowWidth?: number, windowHeight?: number }) {
@action updateWindowDimensions ({
windowWidth,
windowHeight,
headerHeight,
}: { windowWidth?: number, windowHeight?: number, headerHeight?: number }) {
if (windowWidth) {
this.windowWidth = windowWidth
}
@@ -257,6 +252,10 @@ export default class State {
if (windowHeight) {
this.windowHeight = windowHeight
}
if (headerHeight) {
this.headerHeight = headerHeight
}
}
@action clearMessage () {
@@ -330,7 +329,7 @@ export default class State {
}
runMultiMode = async () => {
const eventManager = require('./event-manager').default
const eventManager = require('@packages/runner-shared').eventManager
const waitForRunEnd = () => new Promise((res) => eventManager.on('run:end', res))
this.setSpec(null)

View File

@@ -4,8 +4,9 @@ import { render } from 'react-dom'
import { utils as driverUtils } from '@packages/driver'
import defaultEvents from '@packages/reporter/src/lib/events'
import App from './app/RunnerCt'
import State from './lib/state'
import Container from './app/container'
import { Container, eventManager } from '@packages/runner-shared'
import util from './lib/util'
// to support async/await
@@ -62,9 +63,20 @@ const Runner = {
Runner.state = state
Runner.configureMobx = configure
state.updateAutViewportDimensions({ viewportWidth: config.viewportWidth, viewportHeight: config.viewportHeight })
state.updateDimensions(config.viewportWidth, config.viewportHeight)
render(<Container config={config} state={state} />, el)
const container = (
<Container
config={config}
runner='ct'
state={state}
App={App}
hasSpecFile={util.hasSpecFile}
eventManager={eventManager}
/>
)
render(container, el)
})()
},
}

View File

@@ -27,7 +27,7 @@
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
// "traceResolution": true,
"strict": false,
"strict": true,
"forceConsistentCasingInFileNames": true,
/**
* Skip type checking of all declaration files (*.d.ts).
@@ -56,6 +56,8 @@
},
"include": [
"./lib/*.ts",
"./src*.ts",
"./src*.tsx",
"./index.ts",
"./index.d.ts",
"./../ts/index.d.ts"

View File

@@ -21,20 +21,31 @@ babelLoader.use.options.plugins.push([require.resolve('babel-plugin-prismjs'), {
css: false,
}])
let pngRule
// @ts-ignore
const nonPngRules = _.filter(commonConfig.module.rules, (rule) => {
// @ts-ignore
if (rule.test.toString().includes('png')) {
pngRule = rule
return false
const { pngRule, nonPngRules } = commonConfig!.module!.rules!.reduce<{
nonPngRules: webpack.RuleSetRule[]
pngRule: webpack.RuleSetRule | undefined
}>((acc, rule) => {
if (rule?.test?.toString().includes('png')) {
return {
...acc,
pngRule: rule,
}
}
return true
return {
...acc,
nonPngRules: [...acc.nonPngRules, rule],
}
}, {
nonPngRules: [],
pngRule: undefined,
})
pngRule.use[0].options = {
if (!pngRule || !pngRule.use) {
throw Error('Could not find png loader')
}
(pngRule.use as webpack.RuleSetLoader[])[0].options = {
name: '[name].[ext]',
outputPath: 'img',
publicPath: '/__cypress/runner/img/',

View File

@@ -0,0 +1,137 @@
{
"plugins": [
"cypress",
"@cypress/dev"
],
"extends": [
"plugin:@cypress/dev/general",
"plugin:@cypress/dev/tests",
"plugin:@cypress/dev/react",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"../reporter/src/.eslintrc.json"
],
"parser": "@typescript-eslint/parser",
"env": {
"cypress/globals": true
},
"rules": {
"react/display-name": "off",
"react/function-component-definition": [
"error",
{
"namedComponents": "arrow-function",
"unnamedComponents": "arrow-function"
}
],
"react/jsx-boolean-value": [
"error",
"always"
],
"react/jsx-closing-bracket-location": [
"error",
"line-aligned"
],
"react/jsx-closing-tag-location": "error",
"react/jsx-curly-brace-presence": [
"error",
{
"props": "never",
"children": "never"
}
],
"react/jsx-curly-newline": "error",
"react/jsx-filename-extension": [
"warn",
{
"extensions": [
".js",
".jsx",
".tsx"
]
}
],
"react/jsx-first-prop-new-line": "error",
"react/jsx-max-props-per-line": [
"error",
{
"maximum": 1,
"when": "multiline"
}
],
"react/jsx-no-bind": [
"error",
{
"ignoreDOMComponents": true
}
],
"react/jsx-no-useless-fragment": "error",
"react/jsx-one-expression-per-line": [
"error",
{
"allow": "literal"
}
],
"react/jsx-sort-props": [
"error",
{
"callbacksLast": true,
"ignoreCase": true,
"noSortAlphabetically": true,
"reservedFirst": true
}
],
"react/jsx-tag-spacing": [
"error",
{
"closingSlash": "never",
"beforeSelfClosing": "always"
}
],
"react/jsx-wrap-multilines": [
"error",
{
"declaration": "parens-new-line",
"assignment": "parens-new-line",
"return": "parens-new-line",
"arrow": "parens-new-line",
"condition": "parens-new-line",
"logical": "parens-new-line",
"prop": "parens-new-line"
}
],
"react/no-array-index-key": "error",
"react/no-unescaped-entities": "off",
"react/prop-types": "off",
"quote-props": [
"error",
"as-needed"
]
},
"overrides": [
{
"files": [
"lib/*"
],
"rules": {
"no-console": 1
}
},
{
"files": [
"**/*.json"
],
"rules": {
"quotes": "off",
"comma-dangle": "off"
}
},
{
"files": "*.tsx",
"rules": {
"no-unused-vars": "off",
"react/jsx-no-bind": "off"
}
}
]
}

View File

@@ -0,0 +1,32 @@
{
"name": "@packages/runner-shared",
"version": "0.0.0-development",
"private": true,
"main": "src/index.ts",
"scripts": {
"test": "yarn test-unit",
"test-unit": "mocha --config test/.mocharc.json src/**/*.spec.* --exit"
},
"dependencies": {
"@cypress/react-tooltip": "0.5.3",
"ansi-to-html": "0.6.14",
"classnames": "2.3.1",
"lodash": "4.17.21",
"mobx": "5.15.4",
"mobx-react": "6.1.8",
"react": "16.8.6",
"react-dom": "16.8.6"
},
"devDependencies": {
"@packages/driver": "0.0.0-development",
"@packages/socket": "0.0.0-development",
"@packages/web-config": "0.0.0-development",
"chai": "4.2.0",
"chai-enzyme": "1.0.0-beta.1",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.2",
"mocha": "7.0.1",
"sinon": "7.5.0",
"sinon-chai": "3.3.0"
}
}

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import AutomationDisconnected from './automation-disconnected'
import { AutomationDisconnected } from '.'
describe('<AutomationDisconnected />', () => {
it('renders the message', () => {

View File

@@ -1,6 +1,6 @@
import React from 'react'
export default ({ onReload }) => (
export const AutomationDisconnected = ({ onReload }) => (
<div className='runner automation-failure'>
<div className='automation-message automation-disconnected'>
<p>Whoops, the Cypress extension has disconnected.</p>

View File

@@ -0,0 +1,17 @@
import React from 'react'
export const automationElementId = '__cypress-string'
interface AutomationElementProps {
randomString: string
}
export const AutomationElement: React.FC<AutomationElementProps> = ({
randomString,
}) => {
return (
<div id={automationElementId} style={{ display: 'none' }}>
{randomString}
</div>
)
}

View File

@@ -1,4 +1,4 @@
export default {
export const automation = {
CONNECTING: 'CONNECTING',
MISSING: 'MISSING',
CONNECTED: 'CONNECTED',

View File

@@ -1,4 +1,4 @@
export default () => {
export const blankContents = () => {
return `
<style>
body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,button,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img,a img{border:none;}address,caption,cite,code,dfn,em,strong,th,var,optgroup{font-style:inherit;font-weight:inherit;}del,ins{text-decoration:none;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym{border:0;font-variant:normal;}sup{vertical-align:baseline;}sub{vertical-align:baseline;}legend{color:#000;}

View File

@@ -0,0 +1,40 @@
import React from 'react'
import { isUndefined } from 'lodash'
const configFileFormatted = (configFile) => {
if (configFile === false) {
return (
<>
<code>cypress.json</code>
{' '}
file (currently disabled by
{' '}
<code>--config-file false</code>
)
</>
)
}
if (isUndefined(configFile) || configFile === 'cypress.json') {
return (
<>
<code>cypress.json</code>
{' '}
file
</>
)
}
return (
<>
custom config file
<code>
{configFile}
</code>
</>
)
}
export {
configFileFormatted,
}

View File

@@ -2,16 +2,18 @@ import React from 'react'
import { mount, shallow } from 'enzyme'
import sinon from 'sinon'
import App from './app'
import automation from '../lib/automation'
import AutomationDisconnected from '../errors/automation-disconnected'
import NoAutomation from '../errors/no-automation'
import NoSpec from '../errors/no-spec'
import State from '../lib/state'
import App from '@packages/runner/src/app/app'
import { AutomationDisconnected } from '../automation-disconnected'
import { automation } from '../automation'
import { NoAutomation } from '../no-automation'
import { automationElementId } from '../automation-element'
import NoSpec from '@packages/runner/src/errors/no-spec'
import Container, { automationElementId } from './container'
import { Container } from '.'
const createProps = () => ({
runner: 'e2e',
hasSpecFile: sinon.stub(),
config: {
browsers: [],
integrationFolder: '',
@@ -19,6 +21,15 @@ const createProps = () => ({
projectName: '',
viewportHeight: 0,
viewportWidth: 0,
spec: {
name: 'test/spec.js',
relative: './this/is/a/test/spec.js',
absolute: '/Users/me/code/this/is/a/test/spec.js',
},
state: {
autoScrollingEnabled: true,
reporterWidth: 300,
},
},
eventManager: {
addGlobalListeners: sinon.spy(),
@@ -28,11 +39,19 @@ const createProps = () => ({
emit: () => {},
on: () => {},
},
on: () => {},
start: () => {},
setup: () => {},
},
state: new State(),
util: {
hasSpecFile: sinon.stub(),
state: {
automation: undefined,
defaults: {
width: 500,
height: 500,
},
},
App,
NoSpec,
})
describe('<Container />', () => {
@@ -53,12 +72,11 @@ describe('<Container />', () => {
const props = createProps()
props.state.automation = automation.CONNECTING
component = shallow(<Container {...props} />)
component = mount(<Container {...props} />)
})
it('renders the automation element alone', () => {
expect(component.find(`#${automationElementId}`)).to.exist
expect(component.find(`#${automationElementId}`).parent()).not.to.exist
})
})
@@ -117,7 +135,7 @@ describe('<Container />', () => {
beforeEach(() => {
props = createProps()
props.state.automation = automation.CONNECTED
props.util.hasSpecFile.returns(false)
props.hasSpecFile.returns(false)
component = shallow(<Container {...props} />)
})
@@ -125,35 +143,11 @@ describe('<Container />', () => {
expect(component.find(NoSpec)).to.have.prop('config', props.config)
})
it('renders the automation element', () => {
expect(component.find(`#${automationElementId}`)).to.exist
})
it('renders the app when hash changes with and has a spec file', () => {
props.util.hasSpecFile.returns(true)
props.hasSpecFile.returns(true)
component.find(NoSpec).prop('onHashChange')()
component.update()
expect(component.find(App)).to.exist
})
})
describe('when automation is connected and there is a spec file', () => {
let props
let component
beforeEach(() => {
props = createProps()
props.state.automation = automation.CONNECTED
props.util.hasSpecFile.returns(true)
component = shallow(<Container {...props} />)
})
it('renders <App />', () => {
expect(component.find(App)).to.exist
})
it('renders the automation element', () => {
expect(component.find(`#${automationElementId}`)).to.exist
})
})
})

View File

@@ -1,20 +1,13 @@
import { observer } from 'mobx-react'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import automation from '../lib/automation'
import eventManager from '../lib/event-manager'
import State from '../lib/state'
import util from '../lib/util'
import RunnerCt from './RunnerCt'
import AutomationDisconnected from '../errors/automation-disconnected'
import NoAutomation from '../errors/no-automation'
const automationElementId = '__cypress-string'
import { AutomationDisconnected } from '../automation-disconnected'
import { automation } from '../automation'
import { NoAutomation } from '../no-automation'
import { automationElementId, AutomationElement } from '../automation-element'
@observer
class Container extends Component {
export class Container extends Component {
constructor (...args) {
super(...args)
@@ -40,30 +33,34 @@ class Container extends Component {
return this._automationDisconnected()
case automation.CONNECTED:
default:
return this._app()
if (this.props.runner === 'e2e') {
return this.props.hasSpecFile()
? this._app()
: this._noSpec()
}
if (this.props.runner === 'ct') {
return this._app()
}
throw Error(`runner prop is required and must be 'e2e' or 'ct'. You passed: ${this.props.runner}.`)
}
}
_automationElement () {
return (
<div id={automationElementId} style={{ display: 'none' }}>
{this.randomString}
</div>
<AutomationElement randomString={this.randomString} />
)
}
_app () {
return (
<RunnerCt {...this.props}>
{this._automationElement()}
</RunnerCt>
)
}
const { App, ...rest } = this.props
_checkSpecFile = () => {
if (this.props.util.hasSpecFile()) {
this.forceUpdate()
}
return (
<App {...rest}>
{this._automationElement()}
</App>
)
}
_noAutomation () {
@@ -82,18 +79,23 @@ class Container extends Component {
_automationDisconnected () {
return <AutomationDisconnected onReload={this.props.eventManager.launchBrowser} />
}
// This two functions, _noSpec anad _checkSpecFile, are only used by the E2E runner.
// TODO: remove any runner specific code from this file.
_noSpec () {
const { NoSpec } = this.props
return (
<NoSpec config={this.props.config} onHashChange={this._checkSpecFile}>
{this._automationElement()}
</NoSpec>
)
}
// This is only used by the E2E runner.
_checkSpecFile = () => {
if (this.props.hasSpecFile()) {
this.forceUpdate()
}
}
}
Container.defaultProps = {
eventManager,
util,
}
Container.propTypes = {
config: PropTypes.object.isRequired,
state: PropTypes.instanceOf(State),
}
export { automationElementId }
export default Container

View File

@@ -1,10 +1,10 @@
import _ from 'lodash'
import { $ } from '@packages/driver'
import selectorPlaygroundHighlight from '../selector-playground/highlight'
import { selectorPlaygroundHighlight } from './selector-playground/highlight'
// The '!' tells webpack to disable normal loaders, and keep loaders with `enforce: 'pre'` and `enforce: 'post'`
// This disables the CSSExtractWebpackPlugin and allows us to get the CSS as a raw string instead of saving it to a separate file.
import selectorPlaygroundCSS from '!../selector-playground/selector-playground.scss'
import selectorPlaygroundCSS from '!./selector-playground/selector-playground.scss'
const styles = (styleString) => {
return styleString.replace(/\s*\n\s*/g, '')
@@ -443,7 +443,7 @@ function removeBlackouts ($body) {
$body.find('.__cypress-blackout').remove()
}
export default {
export const dom = {
addBlackout,
removeBlackouts,
addElementBoxModelLayers,

View File

@@ -1,4 +1,4 @@
export default {
export const errorMessages = {
reporterError (err, specPath) {
if (!err) return null

View File

@@ -5,10 +5,10 @@ import { action } from 'mobx'
import { client } from '@packages/socket'
import automation from './automation'
import logger from './logger'
import studioRecorder from '../studio/studio-recorder'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import { studioRecorder } from './studio'
import { automation } from './automation'
import { logger } from './logger'
import { selectorPlaygroundModel } from './selector-playground'
import $Cypress, { $ } from '@packages/driver'
@@ -26,7 +26,7 @@ const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ')
const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame'.split(' ')
const driverTestEvents = 'test:before:run:async test:after:run'.split(' ')
const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed'.split(' ')
const socketRerunEvents = 'runner:restart'.split(' ')
const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ')
const socketToDriverEvents = 'net:event script:error'.split(' ')
const localToReporterEvents = 'reporter:log:add reporter:log:state:changed reporter:log:remove'.split(' ')
@@ -47,7 +47,7 @@ if (window.Cypress) {
*/
let Cypress
const eventManager = {
export const eventManager = {
reporterBus,
getCypress () {
@@ -99,6 +99,15 @@ const eventManager = {
rerun()
})
ws.on('component:specs:changed', (specs) => {
state.setSpecs(specs)
})
ws.on('dev-server:hmr:error', (error) => {
Cypress.stop()
localBus.emit('script:error', error)
})
_.each(socketRerunEvents, (event) => {
ws.on(event, rerun)
})
@@ -290,9 +299,13 @@ const eventManager = {
Cypress = this.Cypress = $Cypress.create(config)
// expose Cypress globally
window.Cypress = Cypress
// since CT AUT shares the window with the spec, we don't want to overwrite
// our spec Cypress instance with the component's Cypress instance
if (window.top === window) {
window.Cypress = Cypress
}
this._addListeners()
this._addListeners(Cypress)
ws.emit('watch:test:file', config.spec)
},
@@ -585,6 +598,10 @@ const eventManager = {
localBus.on(event, ...args)
},
off (event, ...args) {
localBus.off(event, ...args)
},
notifyRunningSpec (specFile) {
ws.emit('spec:changed', specFile)
},
@@ -630,5 +647,3 @@ const eventManager = {
ws.emit('save:app:state', state)
},
}
export default eventManager

View File

@@ -5,12 +5,10 @@ import sinon from 'sinon'
import driver from '@packages/driver'
import Tooltip from '@cypress/react-tooltip'
import eventManager from '../lib/event-manager'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import studioRecorder from '../studio/studio-recorder'
import Header from './header'
import Studio from '../studio/studio'
import { eventManager } from '../event-manager'
import { Studio, studioRecorder } from '../studio'
import { selectorPlaygroundModel } from '../selector-playground'
import { Header } from '.'
const getState = (props) => _.extend({
defaults: {},
@@ -21,6 +19,7 @@ const propsWithState = (stateProps, configProps = {}) =>
({
state: getState(stateProps),
config: configProps,
runner: 'e2e',
})
describe('<Header />', () => {
@@ -297,7 +296,7 @@ describe('<Header />', () => {
describe('viewport info', () => {
it('has menu-open class on button click', () => {
const component = shallow(<Header {...propsWithState()} />)
const component = mount(<Header {...propsWithState()} />)
component.find('.viewport-info button').simulate('click')
expect(component.find('.viewport-info')).to.have.className('menu-open')
@@ -305,14 +304,14 @@ describe('<Header />', () => {
it('displays width, height, and display scale', () => {
const state = { width: 1, height: 2, displayScale: 3 }
const component = shallow(<Header {...propsWithState(state)} />)
const component = mount(<Header {...propsWithState(state)} />)
expect(component.find('.viewport-info button').text()).to.contain('1 x 2 (3%)')
})
it('displays default width and height in menu', () => {
const state = { defaults: { width: 4, height: 5 } }
const component = shallow(<Header {...propsWithState(state)} />)
const component = mount(<Header {...propsWithState(state)} />)
expect(component.find('.viewport-menu pre').text()).to.contain('"viewportWidth": 4')
expect(component.find('.viewport-menu pre').text()).to.contain('"viewportHeight": 5')

View File

@@ -0,0 +1,242 @@
import cs from 'classnames'
import { action, computed, observable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component, createRef } from 'react'
import Tooltip from '@cypress/react-tooltip'
import { $ } from '@packages/driver'
import { ViewportInfo } from '../viewport-info'
import { SelectorPlayground } from '../selector-playground/SelectorPlayground'
import { selectorPlaygroundModel } from '../selector-playground'
import { Studio, studioRecorder } from '../studio'
import { eventManager } from '../event-manager'
interface BaseState {
isLoading: boolean
isRunning: boolean
width: number
height: number
displayScale: number | undefined
defaults: {
width: number
height: number
}
updateWindowDimensions: (payload: { headerHeight: number }) => void
}
interface StateCT {
runner: 'ct'
state: {
screenshotting: boolean
} & BaseState
}
interface StateE2E {
runner: 'e2e'
state: {
url: string
isLoadingUrl: boolean
highlightUrl: boolean
} & BaseState
}
interface HeaderBaseProps {
config: {
configFile: string
[k: string]: unknown
}
}
type CtHeaderProps = StateCT & HeaderBaseProps
type E2EHeaderProps = StateE2E & HeaderBaseProps
type HeaderProps = CtHeaderProps | E2EHeaderProps
@observer
export class Header extends Component<HeaderProps> {
@observable showingViewportMenu = false
@observable urlInput = ''
@observable previousSelectorPlaygroundOpen: boolean = false
@observable previousRecorderIsOpen: boolean = false
urlInputRef = createRef<HTMLInputElement>()
headerRef = createRef<HTMLHeadElement>()
get studioForm () {
if (this.props.runner !== 'e2e') {
return
}
return (
<form
className={cs('url-container', {
loading: this.props.runner === 'e2e' && this.props.state.isLoadingUrl,
highlighted: this.props.runner === 'e2e' && this.props.state.highlightUrl,
'menu-open': this._studioNeedsUrl,
})}
onSubmit={this._visitUrlInput}
>
<input
ref={this.urlInputRef}
type='text'
className={cs('url', { 'input-active': this._studioNeedsUrl })}
value={this._studioNeedsUrl ? this.urlInput : this.props.state.url}
readOnly={!this._studioNeedsUrl}
onChange={this._onUrlInput}
onClick={this._openUrl}
/>
<div className='popup-menu url-menu'>
<p>
<strong>Please enter a valid URL to visit.</strong>
</p>
<div className='menu-buttons'>
<button type='button' className='btn-cancel' onClick={this._cancelStudio}>Cancel</button>
<button type='submit' className='btn-submit' disabled={!this.urlInput}>
{`Go `}
<i className='fas fa-arrow-right' />
</button>
</div>
</div>
<span className='loading-container'>
...loading
{' '}
<i className='fas fa-spinner fa-pulse' />
</span>
</form>
)
}
render () {
const { config, state } = this.props
return (
<header
ref={this.headerRef}
className={cs({
'showing-selector-playground': selectorPlaygroundModel.isOpen,
'showing-studio': studioRecorder.isOpen,
'display-none': this.props.runner === 'ct' && this.props.state.screenshotting,
})}
>
<div className='sel-url-wrap'>
<Tooltip
title='Open Selector Playground'
visible={selectorPlaygroundModel.isOpen || studioRecorder.isOpen ? false : null}
wrapperClassName='selector-playground-toggle-tooltip-wrapper'
className='cy-tooltip'
>
<button
aria-label='Open Selector Playground'
className='header-button selector-playground-toggle'
disabled={this.props.state.isLoading || state.isRunning || studioRecorder.isOpen}
onClick={this._togglePlaygroundOpen}
>
<i aria-hidden="true" className='fas fa-crosshairs' />
</button>
</Tooltip>
<div className={cs('menu-cover', { 'menu-cover-display': this._studioNeedsUrl })} />
{this.studioForm}
</div>
<ViewportInfo
showingViewportMenu={this.showingViewportMenu}
width={state.width}
height={state.height}
config={config}
displayScale={this.props.runner === 'e2e' ? state.displayScale : undefined}
defaults={{
width: state.defaults.width,
height: state.defaults.height,
}}
toggleViewportMenu={this._toggleViewportMenu}
/>
<SelectorPlayground
model={selectorPlaygroundModel}
eventManager={eventManager}
/>
{this.props.runner === 'e2e' &&
<Studio model={studioRecorder} hasUrl={!!this.props.state.url} />}
</header>
)
}
@action componentDidMount () {
this.previousSelectorPlaygroundOpen = selectorPlaygroundModel.isOpen
this.previousRecorderIsOpen = studioRecorder.isOpen
this.urlInput = this.props.config.baseUrl ? `${this.props.config.baseUrl}/` : ''
}
@action componentDidUpdate () {
if (selectorPlaygroundModel.isOpen !== this.previousSelectorPlaygroundOpen) {
this._updateWindowDimensions()
this.previousSelectorPlaygroundOpen = selectorPlaygroundModel.isOpen
}
if (studioRecorder.isOpen !== this.previousRecorderIsOpen) {
this._updateWindowDimensions()
this.previousRecorderIsOpen = studioRecorder.isOpen
}
if (this._studioNeedsUrl && this.urlInputRef.current) {
this.urlInputRef.current.focus()
}
}
_togglePlaygroundOpen = () => {
selectorPlaygroundModel.toggleOpen()
}
@action _toggleViewportMenu = () => {
this.showingViewportMenu = !this.showingViewportMenu
}
_updateWindowDimensions = () => {
if (!this.headerRef.current) {
return
}
this.props.state.updateWindowDimensions({
headerHeight: $(this.headerRef.current).outerHeight(),
})
}
_openUrl = () => {
if (this._studioNeedsUrl || this.props.runner !== 'e2e') {
return
}
window.open(this.props.state.url)
}
@computed get _studioNeedsUrl () {
if (this.props.runner !== 'e2e') {
return
}
return studioRecorder.needsUrl && !this.props.state.url
}
@action _onUrlInput = (e) => { // : React.FormEvent<HTMLInputElement>) => {
if (!this._studioNeedsUrl) return
this.urlInput = e.target.value
}
@action _visitUrlInput = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!this._studioNeedsUrl) return
studioRecorder.visitUrl(this.urlInput)
this.urlInput = ''
}
_cancelStudio = () => {
eventManager.emit('studio:cancel')
}
}

View File

@@ -1,15 +1,14 @@
import _ from 'lodash'
import { $ } from '@packages/driver'
import { blankContents } from '../blank-contents'
import { visitFailure } from '../visit-failure'
import { selectorPlaygroundModel } from '../selector-playground'
import { eventManager } from '../event-manager'
import { dom } from '../dom'
import { logger } from '../logger'
import { studioRecorder } from '../studio'
import dom from '../lib/dom'
import logger from '../lib/logger'
import eventManager from '../lib/event-manager'
import visitFailure from './visit-failure'
import blankContents from './blank-contents'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import studioRecorder from '../studio/studio-recorder'
export default class AutIframe {
export class AutIframe {
constructor (config) {
this.config = config
this.debouncedToggleSelectorPlayground = _.debounce(this.toggleSelectorPlayground, 300)

View File

@@ -1,15 +1,14 @@
import _ from 'lodash'
import { action } from 'mobx'
import eventManager from '../lib/event-manager'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import studioRecorder from '../studio/studio-recorder'
import { selectorPlaygroundModel } from '../selector-playground'
import { studioRecorder } from '../studio'
import { eventManager } from '../event-manager'
export default class IframeModel {
constructor ({ state, detachDom, removeHeadStyles, restoreDom, highlightEl, snapshotControls }) {
export class IframeModel {
constructor ({ state, detachDom, restoreDom, highlightEl, snapshotControls }) {
this.state = state
this.detachDom = detachDom
this.removeHeadStyles = removeHeadStyles
this.restoreDom = restoreDom
this.highlightEl = highlightEl
this.snapshotControls = snapshotControls
@@ -229,6 +228,8 @@ export default class IframeModel {
htmlAttrs,
snapshot: finalSnapshot,
url: this.state.url,
// TODO: use same attr for both runner and runner-ct states.
// these refer to the same thing - the viewport dimensions.
viewportWidth: this.state.width,
viewportHeight: this.state.height,
}

View File

@@ -0,0 +1,3 @@
export * from './iframe-model'
export * from './aut-iframe'

View File

@@ -0,0 +1,35 @@
export * from './snapshot-controls'
export * from './visit-failure'
export * from './blank-contents'
export * from './message'
export * from './selector-playground'
export * from './script-error'
export * from './mobx'
export * from './error-messages'
export * from './iframe'
export * from './dom'
export * from './logger'
export * from './event-manager'
export * from './automation'
export * from './studio'
export * from './viewport-info'
export * from './config-file-formatted'
export * from './header'
export * from './container'

View File

@@ -2,7 +2,7 @@
import _ from 'lodash'
export default {
export const logger = {
log (...args) {
console.log(...args)
},

View File

@@ -1,10 +1,20 @@
import cs from 'classnames'
import { observer } from 'mobx-react'
import React, { forwardRef } from 'react'
import State from '../lib/state'
import './message.scss'
interface MessageProps {
state: State
state: {
messageTitle?: string
messageControls?: unknown
messageDescription: string
messageType?: string
messageStyles: {
state: string
styles: React.CSSProperties
messageType: string
}
}
}
export const Message = observer(forwardRef<HTMLDivElement, MessageProps>(({ state }, ref) => {

View File

@@ -1,3 +1,5 @@
@import '../variables.scss';
.runner {
.message-container {
display: flex;

View File

@@ -0,0 +1,4 @@
@mixin button-active {
background-color: #e9e9e9;
box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
}

View File

@@ -48,7 +48,7 @@ const browserPicker = (browsers, onLaunchBrowser) => {
)
}
export default ({ browsers, onLaunchBrowser }) => (
export const NoAutomation = ({ browsers, onLaunchBrowser }) => (
<div className='runner automation-failure'>
<div className='automation-message'>
<p>Whoops, we can't run your tests.</p>

View File

@@ -3,7 +3,7 @@ import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import NoAutomation from './no-automation'
import { NoAutomation } from '.'
const noBrowsers = []
const browsersWithChosen = [

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { namedObserver } from '../lib/mobx'
import { namedObserver } from '../mobx'
const ansiToHtml = require('ansi-to-html')
const convert = new ansiToHtml({
@@ -24,5 +24,3 @@ export const ScriptError: React.FC<{ error: string }> = namedObserver('ScriptErr
/>
)
})
export default ScriptError

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { shallow } from 'enzyme'
import ScriptError from './script-error'
import { ScriptError } from '@packages/runner-shared'
describe('<ScriptError />', () => {
it('renders nothing when there is no script error', () => {
@@ -12,9 +12,9 @@ describe('<ScriptError />', () => {
})
it('renders ansi as colors', () => {
const state = { error: { error: `Webpack Compilation Error
const state = { error: `Webpack Compilation Error
  11 |  it('is true for actual jquery instances', () => 
@ multi ./cypress/integration/dom/jquery_spec.js main[0]` } }
@ multi ./cypress/integration/dom/jquery_spec.js main[0]` }
const component = shallow(<ScriptError {...state} />)
const { dangerouslySetInnerHTML } = component.props()

View File

@@ -4,8 +4,7 @@ import { action, observable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import Tooltip from '@cypress/react-tooltip'
import eventManager from '../lib/event-manager'
import './selector-playground.scss'
const defaultCopyText = 'Copy to clipboard'
const defaultPrintText = 'Print to console'
@@ -113,7 +112,7 @@ class SelectorPlayground extends Component {
{' Learn more'}
</a>
<button className='close' onClick={this._togglePlaygroundOpen}>
x
x
</button>
</div>
)
@@ -155,7 +154,7 @@ x
>
<button onClick={this._toggleMethodPicker}>
<i className='fas fa-caret-down'></i>
{` cy. ${model.method}`}
{` cy.${model.method}`}
</button>
<div className='method-picker'>
{_.map(methods, (method) => (
@@ -216,7 +215,7 @@ x
}
_printToConsole = () => {
eventManager.emit('print:selector:elements:to:console')
this.props.eventManager.emit('print:selector:elements:to:console')
this._setPrintText('Printed!')
}
@@ -244,4 +243,4 @@ x
}
}
export default SelectorPlayground
export { SelectorPlayground }

View File

@@ -31,7 +31,7 @@ function renderHighlight (container, props) {
render(<Highlight {...props} />, container)
}
export default {
export const selectorPlaygroundHighlight = {
render: renderHighlight,
unmount: unmountComponentAtNode,
}

View File

@@ -0,0 +1 @@
export * from './selector-playground-model'

View File

@@ -77,4 +77,4 @@ class SelectorPlaygroundModel {
}
}
export default new SelectorPlaygroundModel()
export const selectorPlaygroundModel = new SelectorPlaygroundModel()

View File

@@ -4,9 +4,7 @@ import { mount, shallow } from 'enzyme'
import sinon from 'sinon'
import Tooltip from '@cypress/react-tooltip'
import eventManager from '../lib/event-manager'
import SelectorPlayground from './selector-playground'
import { SelectorPlayground } from './SelectorPlayground'
const createModel = (props) => _.extend({
method: 'get',
@@ -184,8 +182,15 @@ describe('<SelectorPlayground />', () => {
})
it('prints to console when clicked', () => {
sinon.stub(eventManager, 'emit')
const component = mount(<SelectorPlayground model={model} />)
const eventManager = {
emit: sinon.stub(),
}
const component = mount(
<SelectorPlayground
model={model}
eventManager={eventManager}
/>,
)
component.find('.print-to-console').simulate('click')
expect(eventManager.emit).to.be.calledWith('print:selector:elements:to:console')
@@ -194,14 +199,31 @@ describe('<SelectorPlayground />', () => {
})
it('sets tooltip text to "Printed!" when successful', () => {
const component = mount(<SelectorPlayground model={model} />)
const eventManager = {
emit: sinon.stub(),
}
const component = mount(
<SelectorPlayground
model={model}
eventManager={eventManager}
/>,
)
component.find('.print-to-console').simulate('click')
expect(component.find(Tooltip).at(3)).to.have.prop('title', 'Printed!')
})
it('resets tooltip text when mousing out of button', () => {
const component = mount(<SelectorPlayground model={model} />)
const eventManager = {
emit: sinon.stub(),
}
const component = mount(
<SelectorPlayground
model={model}
eventManager={eventManager}
/>,
)
const randomEl = document.createElement('div')
component.find('.print-to-console').simulate('click')

View File

@@ -0,0 +1 @@
export * from './snapshot-controls'

View File

@@ -4,6 +4,7 @@ import { action } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import Tooltip from '@cypress/react-tooltip'
import './snapshot-controls.scss'
@observer
class SnapshotControls extends Component {
@@ -48,10 +49,10 @@ class SnapshotControls extends Component {
<span className='snapshot-state-picker'>
{_.map(snapshots, (snapshot, index) => (
<button
key={snapshot.name || index}
className={cs({
'state-is-selected': this.props.state.snapshot.stateIndex === index,
})}
key={snapshot.name || index}
href="#"
onClick={this._changeState(index)}
>
@@ -75,4 +76,4 @@ class SnapshotControls extends Component {
})
}
export default SnapshotControls
export { SnapshotControls }

View File

@@ -1,3 +1,6 @@
@import "../variables.scss";
@import "../mixins.scss";
.runner {
.snapshot-controls {
display: flex;

View File

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 387 KiB

View File

@@ -0,0 +1,5 @@
export * from './studio'
export * from './studio-modals'
export * from './studio-recorder'

View File

@@ -3,9 +3,9 @@ import { observer } from 'mobx-react'
import React, { Component } from 'react'
import { Dialog } from '@reach/dialog'
import VisuallyHidden from '@reach/visually-hidden'
import { eventManager } from '@packages/runner-shared'
import eventManager from '../lib/event-manager'
import studioRecorder from './studio-recorder'
import { studioRecorder } from './studio-recorder'
@observer
export class StudioInstructionsModal extends Component {
@@ -19,7 +19,10 @@ export class StudioInstructionsModal extends Component {
>
<div className='body'>
<h1 className='title'>
<i className='fas fa-magic icon' /> Studio <span className='beta'>BETA</span>
<i className='fas fa-magic icon' />
{' '}
Studio
<span className='beta'>BETA</span>
</h1>
<div className='content center'>
<div className='text'>
@@ -27,15 +30,29 @@ export class StudioInstructionsModal extends Component {
</div>
<div className='text center-box'>
<ul>
<li><pre>.check()</pre></li>
<li><pre>.click()</pre></li>
<li><pre>.select()</pre></li>
<li><pre>.type()</pre></li>
<li><pre>.uncheck()</pre></li>
<li>
<pre>.check()</pre>
</li>
<li>
<pre>.click()</pre>
</li>
<li>
<pre>.select()</pre>
</li>
<li>
<pre>.type()</pre>
</li>
<li>
<pre>.uncheck()</pre>
</li>
</ul>
</div>
<div className='text'>
This feature is currently in Beta and we will be adding more commands and abilities in the future. Your <a href='https://on.cypress.io/studio-beta' target='_blank'>feedback</a> will be highly influential to our team.
This feature is currently in Beta and we will be adding more commands and abilities in the future. Your
{' '}
<a href='https://on.cypress.io/studio-beta' target='_blank' rel="noreferrer">feedback</a>
{' '}
will be highly influential to our team.
</div>
</div>
<div className='controls'>
@@ -44,7 +61,7 @@ export class StudioInstructionsModal extends Component {
</div>
<button className='close-button' onClick={this.props.close}>
<VisuallyHidden>Close</VisuallyHidden>
<span aria-hidden>
<span aria-hidden={true}>
<i className='fas fa-times' />
</span>
</button>
@@ -65,10 +82,13 @@ export class StudioInitModal extends Component {
>
<div className='body'>
<h1 className='title'>
<i className='fas fa-magic icon' /> Studio <span className='beta'>BETA</span>
<i className='fas fa-magic icon' />
{' '}
Studio
<span className='beta'>BETA</span>
</h1>
<div className='gif'>
<img src={require('../../static/studio.gif')} alt='Studio' />
<img src={require('../static/studio.gif')} alt='Studio' />
</div>
<div className='content center'>
<div className='text'>
@@ -81,7 +101,7 @@ export class StudioInitModal extends Component {
</div>
<button className='close-button' onClick={this._close}>
<VisuallyHidden>Close</VisuallyHidden>
<span aria-hidden>
<span aria-hidden={true}>
<i className='fas fa-times' />
</span>
</button>
@@ -111,13 +131,15 @@ export class StudioSaveModal extends Component {
>
<div className='body'>
<h1 className='title'>
<i className='fas fa-magic icon' /> Save New Test
<i className='fas fa-magic icon' />
{' '}
Save New Test
</h1>
<div className='content'>
<form onSubmit={this._save}>
<div className='text'>
<label className='text-strong' htmlFor='testName'>Test Name</label>
<input id='testName' type='text' value={this.name} onChange={this._onInputChange} required />
<input id='testName' type='text' value={this.name} required={true} onChange={this._onInputChange} />
</div>
<div className='center'>
<button className='btn-main' type='submit' disabled={!this.name}>
@@ -129,7 +151,7 @@ export class StudioSaveModal extends Component {
</div>
<button className='close-button' onClick={studioRecorder.closeSaveModal}>
<VisuallyHidden>Close</VisuallyHidden>
<span aria-hidden>
<span aria-hidden={true}>
<i className='fas fa-times' />
</span>
</button>
@@ -158,4 +180,4 @@ const StudioModals = () => (
</>
)
export default StudioModals
export { StudioModals }

View File

@@ -2,10 +2,10 @@ import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import { Dialog } from '@reach/dialog'
import { eventManager } from '../event-manager'
import StudioModals, { StudioInstructionsModal, StudioInitModal, StudioSaveModal } from './studio-modals'
import studioRecorder from './studio-recorder'
import eventManager from '../lib/event-manager'
import { StudioModals, StudioInstructionsModal, StudioInitModal, StudioSaveModal } from './studio-modals'
import { studioRecorder } from './studio-recorder'
describe('<StudioModals />', () => {
beforeEach(() => {
@@ -27,7 +27,7 @@ describe('<StudioModals />', () => {
describe('<StudioInstructionsModal />', () => {
it('passes open prop to dialog', () => {
const component = shallow(<StudioInstructionsModal open={false} close={() => {}} />)
const component = shallow(<StudioInstructionsModal open={false} close={sinon.stub()} />)
expect(component.find(Dialog)).to.have.prop('isOpen', false)

View File

@@ -1,8 +1,7 @@
import { action, computed, observable } from 'mobx'
import { $ } from '@packages/driver'
import $driverUtils from '@packages/driver/src/cypress/utils'
import eventManager from '../lib/event-manager'
import { eventManager } from '@packages/runner-shared'
const saveErrorMessage = (message) => {
return `\
@@ -542,4 +541,4 @@ export class StudioRecorder {
}
}
export default new StudioRecorder()
export const studioRecorder = new StudioRecorder()

View File

@@ -1,9 +1,9 @@
import sinon from 'sinon'
import $ from 'jquery'
import driver from '@packages/driver'
import { eventManager } from '@packages/runner-shared'
import studioRecorder, { StudioRecorder } from './studio-recorder'
import eventManager from '../lib/event-manager'
import { StudioRecorder, studioRecorder } from './studio-recorder'
const createEvent = (props) => {
return {
@@ -42,7 +42,7 @@ describe('StudioRecorder', () => {
sinon.restore()
})
it('exports a singleton by default', () => {
it('exports a singleton by named export', () => {
expect(studioRecorder).to.be.instanceOf(StudioRecorder)
})

View File

@@ -1,8 +1,8 @@
import React, { Component } from 'react'
import Tooltip from '@cypress/react-tooltip'
import cs from 'classnames'
import { eventManager } from '@packages/runner-shared'
import eventManager from '../lib/event-manager'
import { StudioInstructionsModal } from './studio-modals'
class Studio extends Component {
@@ -17,19 +17,22 @@ class Studio extends Component {
return (
<div className='header-popup studio'>
{/* eslint-disable react/jsx-no-bind */}
<StudioInstructionsModal open={this.state.modalOpen} close={() => this.setState({ modalOpen: false })} />
<div className='text-block'>
<span className={cs('icon', { 'is-active': model.isActive && !model.isFailed && hasUrl })}>
<i className='fas' />
</span>{' '}
<span className='title'>Studio</span>{' '}
</span>
{' '}
<span className='title'>Studio</span>
{' '}
<span className='beta'>Beta</span>
</div>
<div className='text-block'>
<a href='#' onClick={this._showModal} className={cs('available-commands', { 'link-disabled': model.isLoading })}>Available Commands</a>
<a href='#' className={cs('available-commands', { 'link-disabled': model.isLoading })} onClick={this._showModal}>Available Commands</a>
</div>
<div className='text-block'>
<a href={!model.isLoading ? 'https://on.cypress.io/studio-beta' : undefined} target='_blank' className={cs('give-feedback', { 'link-disabled': model.isLoading })}>Give Feedback</a>
<a href={!model.isLoading ? 'https://on.cypress.io/studio-beta' : undefined} target='_blank' className={cs('give-feedback', { 'link-disabled': model.isLoading })} rel="noreferrer">Give Feedback</a>
</div>
<div className='studio-controls'>
<Tooltip
@@ -98,4 +101,4 @@ class Studio extends Component {
}
}
export default Studio
export { Studio }

View File

@@ -1,12 +1,11 @@
import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import Tooltip from '@cypress/react-tooltip'
import Studio from './studio'
import { Studio } from './studio'
import { StudioInstructionsModal } from './studio-modals'
import eventManager from '../lib/event-manager'
import { eventManager } from '../event-manager'
const createModel = (props) => {
return {

View File

@@ -0,0 +1,21 @@
$message-height: 33px;
%clearfix {
&:before,
&:after {
content: "";
display: table;
}
&:after {
clear: both;
}
}
$success: #08c18d;
$error: #e94f5f;
$link-text: #3380FF;
$reporter-min-width: 450px;
$message-height: 33px;
$font-mono: Consolas, Monaco, 'Andale Mono', monospace;
$font-sans: 'Mulish', 'Helvetica Neue', 'Arial', sans-serif;
$open-sans: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;

View File

@@ -0,0 +1,73 @@
import React from 'react'
import cs from 'classnames'
import { namedObserver } from '../mobx'
import { configFileFormatted } from '../config-file-formatted'
interface ViewportInfoProps {
showingViewportMenu: boolean
width: number
height: number
displayScale: number | undefined
config: {
configFile: unknown // Similar to Cypress.RuntimeConfigOptions, but has some extra properties
[key: string]: unknown
}
defaults: {
width: number
height: number
}
toggleViewportMenu: () => void
}
export const ViewportInfo: React.FC<ViewportInfoProps> = namedObserver('ViewportInfo', ({
showingViewportMenu,
width,
height,
displayScale,
defaults,
config,
toggleViewportMenu,
}: ViewportInfoProps) => {
return (
<ul className='menu'>
<li className={cs('viewport-info', { 'menu-open': showingViewportMenu })}>
<button onClick={toggleViewportMenu}>
{width}
{' '}
<span className='the-x'>x</span>
{' '}
{height}
{' '}
<span className='viewport-scale'>
{displayScale && `(${displayScale}%)`}
</span>
<i className='fas fa-fw fa-info-circle'></i>
</button>
<div className='popup-menu viewport-menu'>
{/* eslint-disable react/jsx-one-expression-per-line */}
<p>The <strong>viewport</strong> determines the width and height of your application. By default the viewport will be
<strong>{` ${defaults.width}`}px</strong> by
<strong>{` ${defaults.height}`}px</strong> unless specified by a
{' '}<code>cy.viewport</code> command.
</p>
<p>Additionally you can override the default viewport dimensions by specifying these values in your {configFileFormatted(config.configFile)}.</p>
<pre>{/* eslint-disable indent */}
{`{
"viewportWidth": ${defaults.width},
"viewportHeight": ${defaults.height}
}`}
</pre>
{/* eslint-enable indent */}
<p>
<a href='https://on.cypress.io/viewport' target='_blank' rel='noreferrer'>
<i className='fas fa-info-circle'></i>
Read more about viewport here.
</a>
</p>
{/* eslint-enable react/jsx-one-expression-per-line */}
</div>
</li>
</ul>
)
})

View File

@@ -1,4 +1,4 @@
export default (props) => {
export const visitFailure = (props) => {
const { status, statusText, contentType } = props
const getContentType = () => {

View File

@@ -0,0 +1,5 @@
{
"file": "test/helper.js",
"require": "../web-config/node-register",
"extension": "ts,jsx,tsx,js"
}

View File

@@ -0,0 +1,45 @@
import { returnMockRequire, register } from '@packages/web-config/node-jsdom-setup'
import sinon from 'sinon'
const driverMock = {}
register({
enzyme: require('enzyme'),
EnzymeAdapter: require('enzyme-adapter-react-16'),
chaiEnzyme: require('chai-enzyme'),
requireOverride (depPath) {
if (depPath === '@packages/driver') {
return driverMock
}
// TODO: refactor w/ regex
if (depPath.includes('.gif')) {
return ''
}
},
})
const io = returnMockRequire('@packages/socket/lib/browser', { client: {} })
io.client.connect = sinon.stub().returns({ emit: () => {}, on: () => {} })
const _useFakeTimers = sinon.useFakeTimers
let timers = []
sinon.useFakeTimers = function (...args) {
const ret = _useFakeTimers.apply(this, args)
timers.push(ret)
}
beforeEach(() => {
driverMock.$ = sinon.stub().throws('$ called without being stubbed')
})
afterEach(() => {
timers.forEach((clock) => {
return clock.restore()
})
timers = []
})

View File

@@ -0,0 +1,7 @@
{
"extends": "../ts/tsconfig.json",
"compilerOptions": {
"jsx": "react",
"experimentalDecorators": true
}
}

View File

@@ -6,16 +6,17 @@ import React, { Component } from 'react'
import { findDOMNode } from 'react-dom'
import { Reporter } from '@packages/reporter'
import { $ } from '@packages/driver'
import {
Message,
errorMessages,
StudioModals,
Header,
} from '@packages/runner-shared'
import errorMessages from '../errors/error-messages'
import util from '../lib/util'
import State from '../lib/state'
import Header from '../header/header'
import Iframes from '../iframe/iframes'
import Message from '../message/message'
import Resizer from './resizer'
import StudioModals from '../studio/studio-modals'
@observer
class App extends Component {
@@ -53,7 +54,7 @@ class App extends Component {
className='runner container'
style={{ left: this.props.state.absoluteReporterWidth }}
>
<Header ref='header' {...this.props} />
<Header ref='header' runner='e2e' {...this.props} />
<Iframes ref='iframes' {...this.props} />
<Message ref='message' state={this.props.state} />
{this.props.children}
@@ -250,7 +251,6 @@ App.propTypes = {
on: PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
state: PropTypes.instanceOf(State).isRequired,
}
export default App

View File

@@ -3,8 +3,8 @@ import { shallow } from 'enzyme'
import sinon from 'sinon'
import driver from '@packages/driver'
import { Message } from '@packages/runner-shared'
import * as reporter from '@packages/reporter'
import Message from '../message/message'
import State from '../lib/state'
const Reporter = reporter.Reporter = () => <div />

View File

@@ -1,102 +0,0 @@
import { observer } from 'mobx-react'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import automation from '../lib/automation'
import eventManager from '../lib/event-manager'
import State from '../lib/state'
import util from '../lib/util'
import App from './app'
import AutomationDisconnected from '../errors/automation-disconnected'
import NoAutomation from '../errors/no-automation'
import NoSpec from '../errors/no-spec'
const automationElementId = '__cypress-string'
@observer
class Container extends Component {
constructor (...args) {
super(...args)
this.randomString = `${Math.random()}`
}
componentDidMount () {
this.props.eventManager.addGlobalListeners(this.props.state, {
element: automationElementId,
string: this.randomString,
})
}
render () {
switch (this.props.state.automation) {
case automation.CONNECTING:
return this._automationElement()
case automation.MISSING:
return this._noAutomation()
case automation.DISCONNECTED:
return this._automationDisconnected()
case automation.CONNECTED:
default:
return this.props.util.hasSpecFile() ? this._app() : this._noSpec()
}
}
_automationElement () {
return (
<div id={automationElementId} style={{ display: 'none' }}>
{this.randomString}
</div>
)
}
_app () {
return (
<App {...this.props}>
{this._automationElement()}
</App>
)
}
_noSpec () {
return (
<NoSpec config={this.props.config} onHashChange={this._checkSpecFile}>
{this._automationElement()}
</NoSpec>
)
}
_checkSpecFile = () => {
if (this.props.util.hasSpecFile()) {
this.forceUpdate()
}
}
_noAutomation () {
return (
<NoAutomation
browsers={this.props.config.browsers}
onLaunchBrowser={(browser) => this.props.eventManager.launchBrowser(browser)}
/>
)
}
_automationDisconnected () {
return <AutomationDisconnected onReload={this.props.eventManager.launchBrowser} />
}
}
Container.defaultProps = {
eventManager,
util,
}
Container.propTypes = {
config: PropTypes.object.isRequired,
state: PropTypes.instanceOf(State),
}
export { automationElementId }
export default Container

View File

@@ -1,19 +0,0 @@
import React from 'react'
export default ({ onReload }) => (
<div className='runner automation-failure'>
<div className='automation-message automation-disconnected'>
<p>Whoops, the Cypress extension has disconnected.</p>
<p className='muted'>Cypress cannot run tests without this extension.</p>
<button onClick={onReload}>
<i className='fas fa-sync-alt'></i> Reload the Browser
</button>
<div className='helper-line'>
<a href='https://on.cypress.io/launching-browsers' target='_blank'>
<i className='fas fa-question-circle'></i>
Why am I seeing this message?
</a>
</div>
</div>
</div>
)

View File

@@ -1,61 +0,0 @@
import _ from 'lodash'
import React from 'react'
import { BrowserIcon, Dropdown } from '@packages/ui-components'
const displayName = (name) => _.capitalize(name)
const noBrowsers = () => (
<div>
<p className='muted'>
<small>
We couldn't find any supported browsers capable of running Cypress on your machine.
</small>
</p>
<a href='https://www.google.com/chrome/browser/desktop' className='btn btn-primary btn-lg' target='_blank' rel='noopener noreferrer'>
<i className='fas fa-chrome'></i>
Download Chrome
</a>
</div>
)
const browser = (browser) => (
<span>
<BrowserIcon browserName={browser.displayName} />
<span>Run {displayName(browser.displayName)} {browser.majorVersion}</span>
</span>
)
const browserPicker = (browsers, onLaunchBrowser) => {
const chosenBrowser = _.find(browsers, { default: true }) || browsers[0]
const otherBrowsers = _(browsers)
.without(chosenBrowser)
.map((browser) => _.extend({}, browser, { key: browser.name + browser.version }))
.value()
return (
<div>
<p className='muted'>This browser was not launched through Cypress. Tests cannot run.</p>
<Dropdown
chosen={chosenBrowser}
others={otherBrowsers}
onSelect={onLaunchBrowser}
renderItem={browser}
keyProperty='key'
/>
</div>
)
}
export default ({ browsers, onLaunchBrowser }) => (
<div className='runner automation-failure'>
<div className='automation-message'>
<p>Whoops, we can't run your tests.</p>
{browsers.length ? browserPicker(browsers, onLaunchBrowser) : noBrowsers()}
<div className='helper-line'>
<a className='helper-docs-link' href='https://on.cypress.io/launching-browsers' target='_blank'>
<i className='fas fa-question-circle'></i> Why am I seeing this message?
</a>
</div>
</div>
</div>
)

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'
import eventManager from '../lib/event-manager'
import { eventManager } from '@packages/runner-shared'
class NoSpec extends Component {
componentDidMount () {

View File

@@ -1,23 +0,0 @@
import { observer } from 'mobx-react'
import React from 'react'
const ansiToHtml = require('ansi-to-html')
const convert = new ansiToHtml({
fg: '#000',
bg: '#fff',
newline: false,
escapeXML: true,
stream: false,
})
const ScriptError = observer(({ error }) => {
if (!error) return null
const errorHTML = convert.toHtml(error.error)
return (
<pre className='script-error' dangerouslySetInnerHTML={{ __html: errorHTML }}>
</pre>
)
})
export default ScriptError

View File

@@ -1,175 +0,0 @@
import cs from 'classnames'
import { action, computed, observable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component, createRef } from 'react'
import Tooltip from '@cypress/react-tooltip'
import { $ } from '@packages/driver'
import { configFileFormatted } from '../lib/config-file-formatted'
import SelectorPlayground from '../selector-playground/selector-playground'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import Studio from '../studio/studio'
import studioRecorder from '../studio/studio-recorder'
import eventManager from '../lib/event-manager'
@observer
export default class Header extends Component {
@observable showingViewportMenu = false
@observable urlInput = ''
urlInputRef = createRef()
render () {
const { state, config } = this.props
return (
<header
ref='header'
className={cs({
'showing-selector-playground': selectorPlaygroundModel.isOpen,
'showing-studio': studioRecorder.isOpen,
})}
>
<div className='sel-url-wrap'>
<Tooltip
title='Open Selector Playground'
visible={selectorPlaygroundModel.isOpen || studioRecorder.isOpen ? false : null}
wrapperClassName='selector-playground-toggle-tooltip-wrapper'
className='cy-tooltip'
>
<button
aria-label='Open Selector Playground'
className='header-button selector-playground-toggle'
onClick={this._togglePlaygroundOpen}
disabled={state.isLoading || state.isRunning || studioRecorder.isOpen}
>
<i aria-hidden="true" className='fas fa-crosshairs' />
</button>
</Tooltip>
<div className={cs('menu-cover', { 'menu-cover-display': this._studioNeedsUrl })} />
<form
className={cs('url-container', {
'loading': state.isLoadingUrl,
'highlighted': state.highlightUrl,
'menu-open': this._studioNeedsUrl,
})}
onSubmit={this._visitUrlInput}
>
<input
ref={this.urlInputRef}
type='text'
className={cs('url', { 'input-active': this._studioNeedsUrl })}
value={this._studioNeedsUrl ? this.urlInput : state.url}
readOnly={!this._studioNeedsUrl}
onChange={this._onUrlInput}
onClick={this._openUrl}
/>
<div className='popup-menu url-menu'>
<p><strong>Please enter a valid URL to visit.</strong></p>
<div className='menu-buttons'>
<button type='button' className='btn-cancel' onClick={this._cancelStudio}>Cancel</button>
<button type='submit' className='btn-submit' disabled={!this.urlInput}>Go <i className='fas fa-arrow-right' /></button>
</div>
</div>
<span className='loading-container'>
...loading <i className='fas fa-spinner fa-pulse' />
</span>
</form>
</div>
<ul className='menu'>
<li className={cs('viewport-info', { 'menu-open': this.showingViewportMenu })}>
<button onClick={this._toggleViewportMenu}>
{state.width} <span className='the-x'>x</span> {state.height} <span className='viewport-scale'>({state.displayScale}%)</span>
<i className='fas fa-fw fa-info-circle' />
</button>
<div className='popup-menu viewport-menu'>
<p>The <strong>viewport</strong> determines the width and height of your application. By default the viewport will be <strong>{state.defaults.width}px</strong> by <strong>{state.defaults.height}px</strong> unless specified by a <code>cy.viewport</code> command.</p>
<p>Additionally you can override the default viewport dimensions by specifying these values in your {configFileFormatted(config.configFile)}.</p>
<pre>{/* eslint-disable indent */}
{`{
"viewportWidth": ${state.defaults.width},
"viewportHeight": ${state.defaults.height}
}`}
</pre>{/* eslint-enable indent */}
<p>
<a href='https://on.cypress.io/viewport' target='_blank'>
<i className='fas fa-info-circle' />
Read more about viewport here.
</a>
</p>
</div>
</li>
</ul>
<SelectorPlayground model={selectorPlaygroundModel} />
<Studio model={studioRecorder} hasUrl={!!state.url} />
</header>
)
}
@action componentDidMount () {
this.previousSelectorPlaygroundOpen = selectorPlaygroundModel.isOpen
this.previousRecorderIsOpen = studioRecorder.isOpen
this.urlInput = this.props.config.baseUrl ? `${this.props.config.baseUrl}/` : ''
}
componentDidUpdate () {
if (selectorPlaygroundModel.isOpen !== this.previousSelectorPlaygroundOpen) {
this._updateWindowDimensions()
this.previousSelectorPlaygroundOpen = selectorPlaygroundModel.isOpen
}
if (studioRecorder.isOpen !== this.previousRecorderIsOpen) {
this._updateWindowDimensions()
this.previousRecorderIsOpen = studioRecorder.isOpen
}
if (this._studioNeedsUrl) {
this.urlInputRef.current.focus()
}
}
_updateWindowDimensions = () => {
this.props.state.updateWindowDimensions({
headerHeight: $(this.refs.header).outerHeight(),
})
}
_togglePlaygroundOpen = () => {
selectorPlaygroundModel.toggleOpen()
}
_openUrl = () => {
if (this._studioNeedsUrl) return
window.open(this.props.state.url)
}
@computed get _studioNeedsUrl () {
return studioRecorder.needsUrl && !this.props.state.url
}
@action _onUrlInput = (e) => {
if (!this._studioNeedsUrl) return
this.urlInput = e.target.value
}
@action _visitUrlInput = (e) => {
e.preventDefault()
if (!this._studioNeedsUrl) return
studioRecorder.visitUrl(this.urlInput)
this.urlInput = ''
}
_cancelStudio = () => {
eventManager.emit('studio:cancel')
}
@action _toggleViewportMenu = () => {
this.showingViewportMenu = !this.showingViewportMenu
}
}

View File

@@ -1,99 +0,0 @@
export default () => {
return `
<style>
body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,button,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img,a img{border:none;}address,caption,cite,code,dfn,em,strong,th,var,optgroup{font-style:inherit;font-weight:inherit;}del,ins{text-decoration:none;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym{border:0;font-variant:normal;}sup{vertical-align:baseline;}sub{vertical-align:baseline;}legend{color:#000;}
* {
box-sizing: border-box;
}
body {
color: #333;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
padding: 20px;
width: 100%;
height: 100%;
}
.container {
background-color: #EEE;
border-radius: 6px;
padding: 30px 15px;
text-align: center;
}
svg {
display: inline-block;
stroke-width: 0;
stroke: currentColor;
fill: currentColor;
margin: 20px 0 10px;
width: 62px;
}
p {
font-size: 21px;
font-weight: 200;
line-height: 1.4;
margin-bottom: 15px;
}
kbd {
background-color: #777;
border-radius: 3px;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);
color: #fff;
display: inline-block;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 90%;
padding: 2px 4px;
}
a {
color: #FFF;
text-decoration: none;
}
a:hover,
a:focus,
a:active {
text-decoration: underline;
}
ul {
background-color: #DDD;
border-radius: 10px;
text-align: left;
margin: 0 auto 10px;
max-width: 220px;
padding: 20px 20px 20px 40px;
}
li {
list-style: decimal;
list-style-position: outside;
margin: 4px 0;
}
</style>
<div class='container'>
<svg viewBox="0 0 30 32">
<path d="M25.143 17.714v8.571q0 0.464-0.339 0.804t-0.804 0.339h-6.857v-6.857h-4.571v6.857h-6.857q-0.464 0-0.804-0.339t-0.339-0.804v-8.571q0-0.018 0.009-0.054t0.009-0.054l10.268-8.464 10.268 8.464q0.018 0.036 0.018 0.107zM29.125 16.482l-1.107 1.321q-0.143 0.161-0.375 0.196h-0.054q-0.232 0-0.375-0.125l-12.357-10.304-12.357 10.304q-0.214 0.143-0.429 0.125-0.232-0.036-0.375-0.196l-1.107-1.321q-0.143-0.179-0.125-0.42t0.196-0.384l12.839-10.696q0.571-0.464 1.357-0.464t1.357 0.464l4.357 3.643v-3.482q0-0.25 0.161-0.411t0.411-0.161h3.429q0.25 0 0.411 0.161t0.161 0.411v7.286l3.911 3.25q0.179 0.143 0.196 0.384t-0.125 0.42z"></path>
</svg>
<p>This is the default blank page.</p>
<p>To test your web application:</p>
<ul>
<li>Start your app's server</li>
<li>
<kbd>
<a href='https://on.cypress.io/visit' target='_blank'>cy.visit()</a>
</kbd>
your app
</li>
<li>Begin writing tests</li>
</ul>
</div>
`
}

View File

@@ -3,15 +3,16 @@ import { action, autorun } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import { $ } from '@packages/driver'
import {
SnapshotControls,
ScriptError,
IframeModel,
selectorPlaygroundModel,
AutIframe,
logger,
studioRecorder,
} from '@packages/runner-shared'
import AutIframe from './aut-iframe'
import ScriptError from '../errors/script-error'
import SnapshotControls from './snapshot-controls'
import IframeModel from './iframe-model'
import logger from '../lib/logger'
import selectorPlaygroundModel from '../selector-playground/selector-playground-model'
import studioRecorder from '../studio/studio-recorder'
import util from '../lib/util'
@observer
@@ -102,7 +103,6 @@ export default class Iframes extends Component {
this.iframeModel = new IframeModel({
state: this.props.state,
removeHeadStyles: this.autIframe.removeHeadStyles,
restoreDom: this.autIframe.restoreDom,
highlightEl: this.autIframe.highlightEl,
detachDom: this.autIframe.detachDom,
@@ -122,7 +122,13 @@ export default class Iframes extends Component {
}
@action _setScriptError = (err) => {
this.props.state.scriptError = err
if (err && 'error' in err) {
this.props.state.scriptError = err.error
}
if (!err) {
this.props.state.scriptError = null
}
}
_run = (config) => {

View File

@@ -1,86 +0,0 @@
export default (props) => {
const { status, statusText, contentType } = props
const getContentType = () => {
if (!contentType) {
return ''
}
return `(${contentType})`
}
const getStatus = () => {
if (!status) {
return ''
}
return `<p>${status} - ${statusText} ${getContentType()}</p>`
}
return `
<style>
body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,code,form,fieldset,legend,input,button,textarea,p,blockquote,th,td{margin:0;padding:0;}table{border-collapse:collapse;border-spacing:0;}fieldset,img,a img{border:none;}address,caption,cite,code,dfn,em,strong,th,var,optgroup{font-style:inherit;font-weight:inherit;}del,ins{text-decoration:none;}li{list-style:none;}caption,th{text-align:left;}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;}q:before,q:after{content:'';}abbr,acronym{border:0;font-variant:normal;}sup{vertical-align:baseline;}sub{vertical-align:baseline;}legend{color:#000;}
* {
box-sizing: border-box;
}
body {
color: #333;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.4;
padding: 20px;
width: 100%;
height: 100%;
}
.container {
background: #fcf8e3;
border-radius: 6px;
padding: 48px 60px;
text-align: center;
}
svg {
display: inline-block;
fill: currentColor;
margin: 20px 0 10px;
stroke-width: 0;
stroke: currentColor;
width: 62px;
}
p {
font-size: 21px;
font-weight: 200;
line-height: 1.4;
margin-bottom: 15px;
}
a {
color: #476fc9;
font-weight: bold;
text-decoration: none;
}
a:hover,
a:focus,
a:active {
color: #2c4d97;
text-decoration: underline;
}
</style>
<div class="container">
<svg viewBox="0 0 32 30">
<path d="M18.286 24.554v-3.393q0-0.25-0.17-0.42t-0.402-0.17h-3.429q-0.232 0-0.402 0.17t-0.17 0.42v3.393q0 0.25 0.17 0.42t0.402 0.17h3.429q0.232 0 0.402-0.17t0.17-0.42zM18.25 17.875l0.321-8.196q0-0.214-0.179-0.339-0.232-0.196-0.429-0.196h-3.929q-0.196 0-0.429 0.196-0.179 0.125-0.179 0.375l0.304 8.161q0 0.179 0.179 0.295t0.429 0.116h3.304q0.25 0 0.42-0.116t0.188-0.295zM18 1.196l13.714 25.143q0.625 1.125-0.036 2.25-0.304 0.518-0.83 0.821t-1.134 0.304h-27.429q-0.607 0-1.134-0.304t-0.83-0.821q-0.661-1.125-0.036-2.25l13.714-25.143q0.304-0.554 0.839-0.875t1.161-0.321 1.161 0.321 0.839 0.875z"></path>
</svg>
<p>Sorry, we could not load:</p>
<p>
<a href="${props.url}" target="_blank" rel="noopener noreferrer">${props.url}</a>
</p>
${getStatus()}
</div>
`
}

View File

@@ -1,6 +0,0 @@
export default {
CONNECTING: 'CONNECTING',
MISSING: 'MISSING',
CONNECTED: 'CONNECTED',
DISCONNECTED: 'DISCONNECTED',
}

View File

@@ -1,18 +0,0 @@
import React from 'react'
import { isUndefined } from 'lodash'
const configFileFormatted = (configFile) => {
if (configFile === false) {
return <><code>cypress.json</code> file (currently disabled by <code>--config-file false</code>)</>
}
if (isUndefined(configFile) || configFile === 'cypress.json') {
return <><code>cypress.json</code> file</>
}
return <>custom config file <code>{configFile}</code></>
}
export {
configFileFormatted,
}

View File

@@ -1,120 +0,0 @@
/* eslint-disable no-console */
import _ from 'lodash'
export default {
log (...args) {
console.log(...args)
},
logError (...args) {
console.error(...args)
},
clearLog () {
if (console.clear) console.clear()
},
logFormatted (consoleProps) {
if (_.isEmpty(consoleProps)) return
this._logValues(consoleProps)
this._logGroups(consoleProps)
this._logTable(consoleProps)
},
_logValues (consoleProps) {
const formattedLog = this._formatted(_.omit(consoleProps, 'groups', 'table'))
_.each(formattedLog, (value, key) => {
// don't log empty strings
// _.trim([]) returns '' but we want to log empty arrays, so account for that
if (_.trim(value) === '' && !_.isArray(value)) return
this.log(`%c${key}`, 'font-weight: bold', value)
})
},
_formatted (consoleProps) {
const maxKeyLength = this._getMaxKeyLength(consoleProps)
return _.reduce(consoleProps, (memo, value, key) => {
const append = ': '
key = _.chain(key + append).capitalize().padEnd(maxKeyLength + append.length, ' ').value()
memo[key] = value
return memo
}, {})
},
_getMaxKeyLength (obj) {
const lengths = _(obj).keys().map('length').value()
return Math.max(...lengths)
},
_logGroups (consoleProps) {
const groups = this._getGroups(consoleProps)
_.each(groups, (group) => {
console.groupCollapsed(group.name)
_.each(group.items, (value, key) => {
if (group.label === false) {
this.log(value)
} else {
this.log(`%c${key}`, 'color: blue', value)
}
})
console.groupEnd()
})
},
_getGroups (consoleProps) {
const groups = _.result(consoleProps, 'groups')
if (!groups) return
return _.map(groups, (group) => {
group.items = this._formatted(group.items)
return group
})
},
_logTable (consoleProps) {
if (isMultiEntryTable(consoleProps.table)) {
_.each(
_.sortBy(consoleProps.table, (val, key) => key),
(table) => {
return this._logTable({ table })
},
)
return
}
const table = this._getTable(consoleProps)
if (!table) return
if (_.isArray(table)) {
console.table(table)
} else {
console.groupCollapsed(table.name)
console.table(table.data, table.columns)
console.groupEnd()
}
},
_getTable (consoleProps) {
const table = _.result(consoleProps, 'table')
if (!table) return
return table
},
}
const isMultiEntryTable = (table) => !_.isFunction(table) && !_.some(_.keys(table).map(isNaN).filter(Boolean), true)

View File

@@ -1,5 +1,5 @@
import { action, computed, observable } from 'mobx'
import automation from './automation'
import { automation } from '@packages/runner-shared'
const _defaults = {
messageTitle: null,

View File

@@ -3,8 +3,11 @@ import React from 'react'
import { render } from 'react-dom'
import { utils as driverUtils } from '@packages/driver'
import App from './app/app'
import NoSpec from './errors/no-spec'
import State from './lib/state'
import Container from './app/container'
import { Container, eventManager } from '@packages/runner-shared'
import util from './lib/util'
configure({ enforceActions: 'always' })
@@ -22,7 +25,19 @@ const Runner = {
state.updateDimensions(config.viewportWidth, config.viewportHeight)
render(<Container config={config} state={state} />, el)
const container = (
<Container
config={config}
runner='e2e'
state={state}
App={App}
NoSpec={NoSpec}
hasSpecFile={util.hasSpecFile}
eventManager={eventManager}
/>
)
render(container, el)
})()
},
}

View File

@@ -1,36 +0,0 @@
import cs from 'classnames'
import { observer } from 'mobx-react'
import React, { forwardRef } from 'react'
export default observer(forwardRef(({ state }, ref) => {
if (!state.messageTitle) return null
function controls () {
if (!state.messageControls) return null
return (
<div className='message-controls'>
{state.messageControls}
</div>
)
}
return (
<div
ref={ref}
className={cs(
'message-container',
`message-${state.messageStyles.state}`,
`message-type-${state.messageType}`,
{ 'message-has-description': !!state.messageDescription },
)}
style={state.messageStyles.styles}
>
<div className='message'>
<span className='title'>{state.messageTitle}</span>
<span className='description'>{state.messageDescription}</span>
</div>
{controls()}
</div>
)
}))

View File

@@ -1,37 +0,0 @@
import _ from 'lodash'
import React from 'react'
import { render, unmountComponentAtNode } from 'react-dom'
import Tooltip from '@cypress/react-tooltip'
const Highlight = ({ selector, appendTo, styles, showTooltip = true }) => {
return (
<div>
{_.map(styles, (style, i) => {
// indicates that tooltip should change if one of these props change
const updateCue = _.values(_.pick(style, 'width', 'height', 'top', 'left', 'transform')).join()
return (
<Tooltip
key={i}
title={selector}
visible={showTooltip}
placement='top-start'
appendTo={appendTo}
updateCue={updateCue}
>
<div className='highlight' style={style} />
</Tooltip>
)
})}
</div>
)
}
function renderHighlight (container, props) {
render(<Highlight {...props} />, container)
}
export default {
render: renderHighlight,
unmount: unmountComponentAtNode,
}

View File

@@ -1,80 +0,0 @@
import { action, computed, observable } from 'mobx'
const methods = ['get', 'contains']
class SelectorPlaygroundModel {
methods = methods
@observable getSelector = 'body'
@observable containsSelector = 'Hello, World'
@observable isOpen = false
@observable isEnabled = false
@observable isShowingHighlight = false
@observable isValid = true
@observable numElements = 0
@observable method = methods[0]
@computed get selector () {
return this.method === 'get' ? this.getSelector : this.containsSelector
}
@computed get infoHelp () {
if (!this.isValid) {
return 'Invalid selector'
}
return this.numElements === 1 ? '1 matched element' : `${this.numElements} matched elements`
}
@action toggleEnabled () {
this.setEnabled(!this.isEnabled)
}
@action setEnabled (isEnabled) {
this.isEnabled = isEnabled
if (!this.isEnabled) {
this.isShowingHighlight = false
}
}
@action toggleOpen () {
this.setOpen(!this.isOpen)
}
@action setOpen (isOpen) {
this.isOpen = isOpen
this.setEnabled(this.isOpen)
}
@action setShowingHighlight (isShowingHighlight) {
this.isShowingHighlight = isShowingHighlight
}
@action setSelector (selector) {
if (this.method === 'get') {
this.getSelector = selector
} else {
this.containsSelector = selector
}
}
@action setNumElements (numElements) {
this.numElements = numElements
}
@action setValidity (isValid) {
this.isValid = isValid
}
@action setMethod (method) {
this.method = method
}
@action resetMethod () {
this.method = methods[0]
}
}
export default new SelectorPlaygroundModel()

View File

@@ -1,237 +0,0 @@
import _ from 'lodash'
import cs from 'classnames'
import { action, observable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import Tooltip from '@cypress/react-tooltip'
import eventManager from '../lib/event-manager'
const defaultCopyText = 'Copy to clipboard'
const defaultPrintText = 'Print to console'
// mouseleave fires when entering a child element, so make sure we're
// actually leaving the button and not just hovering over a child
const fixMouseOut = (fn, getTarget) => (e) => {
if (
!e.relatedTarget
|| e.relatedTarget.parentNode === getTarget()
|| e.relatedTarget === getTarget()
) return
fn(e)
}
@observer
class SelectorPlayground extends Component {
@observable copyText = defaultCopyText
@observable printText = defaultPrintText
@observable showingMethodPicker = false
render () {
const { model } = this.props
const selectorText = `cy.${model.method}('${model.selector}')`
return (
<div className={cs('header-popup selector-playground', `method-${model.method}`, {
'no-elements': !model.numElements,
'invalid-selector': !model.isValid,
})}>
<div className='selector'>
<Tooltip
title='Click an element to see a suggested selector'
className='cy-tooltip'
>
<button
className={`highlight-toggle ${model.isEnabled ? 'active' : ''}`}
onClick={this._toggleEnablingSelectorPlayground}>
<span className='fa-stack'>
<i className='far fa-square fa-stack-1x'></i>
<i className='fas fa-mouse-pointer fa-stack-1x'></i>
</span>
</button>
</Tooltip>
<div
className='wrap'
onMouseOver={this._setHighlight(true)}
>
{this._methodSelector()}
<span>(</span>
<span>{'\''}</span>
<div className='selector-input'>
<input
ref={(node) => this._input = node}
name={`${model.isEnabled}` /* fixes issue with not resizing when opening/closing selector playground */}
value={model.selector}
onChange={this._updateSelector}
onFocus={this._setHighlight(true)}
/>
</div>
<span>{'\''}</span>
<span>)</span>
<input ref='copyText' className='copy-backer' value={selectorText} readOnly />
<Tooltip title={model.infoHelp || ''} className='cy-tooltip'>
<span className='info num-elements'>
{model.isValid ?
model.numElements :
<i className='fas fa-exclamation-triangle'></i>
}
</span>
</Tooltip>
</div>
<Tooltip title={this.copyText || ''} updateCue={`${selectorText}${this.copyText}`} className='cy-tooltip'>
<button
ref={(node) => this._copyButton = node}
className='copy-to-clipboard'
onClick={this._copyToClipboard}
disabled={!model.numElements || !model.isValid}
onMouseOut={fixMouseOut(this._resetCopyText, () => this._copyButton)}
>
<i className='far fa-copy' />
</button>
</Tooltip>
<Tooltip title={this.printText || ''} updateCue={`${selectorText}${this.printText}`} className='cy-tooltip'>
<button
ref={(node) => this._printButton = node}
className='print-to-console'
onClick={this._printToConsole}
disabled={!model.numElements || !model.isValid}
onMouseOut={fixMouseOut(this._resetPrintText, () => this._printButton)}
>
<i className='fas fa-terminal' />
</button>
</Tooltip>
</div>
<a className='selector-info' href='https://on.cypress.io/selector-playground' target="_blank">
<i className='fas fa-question-circle'></i>{' '}
Learn more
</a>
<button className='close' onClick={this._togglePlaygroundOpen}>x</button>
</div>
)
}
componentDidMount () {
this._previousIsEnabled = this.props.model.isEnabled
this._previousMethod = this.props.model.method
document.body.addEventListener('click', this._onOutsideClick, false)
}
componentDidUpdate () {
if (
(this.props.model.isEnabled !== this._previousIsEnabled)
|| (this.props.model.method !== this._previousMethod)
) {
if (this.props.model.isEnabled) {
this._focusAndSelectInputText()
}
this._previousIsEnabled = this.props.model.isEnabled
this._previousMethod = this.props.model.method
}
}
componentWillUnmount () {
document.body.removeEventListener('click', this._onOutsideClick)
}
_methodSelector () {
const { model } = this.props
const methods = _.filter(model.methods, (method) => method !== model.method)
return (
<span className={cs('method', {
'is-showing': this.showingMethodPicker,
})}>
<button onClick={this._toggleMethodPicker}>
<i className='fas fa-caret-down'></i>{' '}
cy.{model.method}
</button>
<div className='method-picker'>
{_.map(methods, (method) => (
<div key={method} onClick={() => this._setMethod(method)}>
cy.{method}
</div>
))}
</div>
</span>
)
}
_focusAndSelectInputText () {
this._input.focus()
this._input.select()
}
_onOutsideClick = () => {
this._setShowingMethodPicker(false)
}
_toggleMethodPicker = () => {
this._setShowingMethodPicker(!this.showingMethodPicker)
}
@action _setShowingMethodPicker (isShowing) {
this.showingMethodPicker = isShowing
}
@action _setMethod (method) {
if (method !== this.props.model.method) {
this.props.model.setMethod(method)
}
}
_setHighlight = (isShowing) => () => {
this.props.model.setShowingHighlight(isShowing)
}
_copyToClipboard = () => {
try {
this.refs.copyText.select()
const successful = document.execCommand('copy')
this._setCopyText(successful ? 'Copied!' : 'Oops, unable to copy')
} catch (err) {
this._setCopyText('Oops, unable to copy')
}
}
@action _setCopyText (text) {
this.copyText = text
}
_resetCopyText = () => {
this._setCopyText(defaultCopyText)
}
_printToConsole = () => {
eventManager.emit('print:selector:elements:to:console')
this._setPrintText('Printed!')
}
@action _setPrintText (text) {
this.printText = text
}
_resetPrintText = () => {
this._setPrintText(defaultPrintText)
}
_toggleEnablingSelectorPlayground = () => {
this.props.model.toggleEnabled()
}
_togglePlaygroundOpen = () => {
this.props.model.toggleOpen()
}
_updateSelector = (e) => {
const { model } = this.props
model.setSelector(e.target.value)
model.setShowingHighlight(true)
}
}
export default SelectorPlayground

View File

@@ -111,6 +111,10 @@ export const register = ({
return args[0]
}
if (args[0].endsWith('.scss')) {
return args[0]
}
const ret = _load.apply(this, browserPkg)
return ret

View File

@@ -55,7 +55,7 @@ const stats = {
timings: true,
}
function makeSassLoaders ({ modules }): RuleSetRule {
function makeSassLoaders ({ modules }: { modules: boolean }): RuleSetRule {
const exclude = [/node_modules/]
if (!modules) exclude.push(/\.modules?\.s[ac]ss$/i)