Add spec header with link to open file in editor (#7515)

Co-authored-by: Chris Breiding <chrisbreiding@gmail.com>
Co-authored-by: Chris Breiding <chrisbreiding@users.noreply.github.com>
This commit is contained in:
Zach Panzarino
2020-06-05 02:33:04 -04:00
committed by GitHub
parent bcee62fe94
commit 79fecd9af9
19 changed files with 265 additions and 190 deletions
@@ -0,0 +1,7 @@
describe('special characters', () => {
it('displays file name with decoded special characters', () => {
cy.wrap(Cypress.$(window.top.document.body))
.find('.reporter .runnable-header a')
.should('have.text', 'cypress/integration/meta_&%_spec.ts')
})
})
@@ -1,131 +1,5 @@
const { EventEmitter } = require('events')
const _ = Cypress._
const itHandlesFileOpening = (containerSelector) => {
beforeEach(function () {
cy.stub(this.runner, 'emit').callThrough()
this.setError(this.commandErr)
})
describe('when user has already set opener and opens file', function () {
beforeEach(function () {
this.editor = {}
this.runner.emit.withArgs('get:user:editor').yields({
preferredOpener: this.editor,
})
cy.contains('View stack trace').click()
})
it('opens in preferred opener', function () {
cy.get(`${containerSelector} a`).first().click().then(() => {
expect(this.runner.emit).to.be.calledWith('open:file', {
where: this.editor,
file: '/me/dev/my/app.js',
line: 2,
column: 7,
})
})
})
})
describe('when user has not already set opener and opens file', function () {
const availableEditors = [
{ id: 'computer', name: 'On Computer', isOther: false, openerId: 'computer' },
{ id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' },
{ id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' },
{ id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' },
{ id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' },
{ id: 'other', name: 'Other', isOther: true, openerId: '' },
]
beforeEach(function () {
this.runner.emit.withArgs('get:user:editor').yields({ availableEditors })
// usual viewport of only reporter is a bit cramped for the modal
cy.viewport(600, 600)
cy.contains('View stack trace').click()
cy.get(`${containerSelector} a`).first().click()
})
it('opens modal with available editors', function () {
_.each(availableEditors, ({ name }) => {
cy.contains(name)
})
cy.contains('Other')
cy.contains('Set preference and open file')
})
// NOTE: this fails because mobx doesn't make the editors observable, so
// the changes to the path don't bubble up correctly. this only happens
// in the Cypress test and not when running the actual app
it.skip('updates "Other" path when typed into', function () {
cy.contains('Other').find('input[type="text"]').type('/path/to/editor')
.should('have.value', '/path/to/editor')
})
describe('when editor is not selected', function () {
it('disables submit button', function () {
cy.contains('Set preference and open file')
.should('have.class', 'is-disabled')
.click()
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'set:user:editor')
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'open:file')
})
it('shows validation message when hovering over submit button', function () {
cy.get('.editor-picker-modal .submit').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Please select a preference')
})
})
describe('when Other is selected but path is not entered', function () {
beforeEach(function () {
cy.contains('Other').click()
})
it('disables submit button', function () {
cy.contains('Set preference and open file')
.should('have.class', 'is-disabled')
.click()
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'set:user:editor')
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'open:file')
})
it('shows validation message when hovering over submit button', function () {
cy.get('.editor-picker-modal .submit').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Please enter the path for the "Other" editor')
})
})
describe('when editor is set', function () {
beforeEach(function () {
cy.contains('Visual Studio Code').click()
cy.contains('Set preference and open file').click()
})
it('closes modal', function () {
cy.contains('Set preference and open file').should('not.be.visible')
})
it('emits set:user:editor', function () {
expect(this.runner.emit).to.be.calledWith('set:user:editor', availableEditors[4])
})
it('opens file in selected editor', function () {
expect(this.runner.emit).to.be.calledWith('open:file', {
where: availableEditors[4],
file: '/me/dev/my/app.js',
line: 2,
column: 7,
})
})
})
})
}
const { itHandlesFileOpening } = require('../support/utils')
describe('test errors', function () {
beforeEach(function () {
@@ -356,7 +230,11 @@ describe('test errors', function () {
cy.get('.command-wrapper').should('be.visible')
})
itHandlesFileOpening('.runnable-err-stack-trace')
itHandlesFileOpening('.runnable-err-stack-trace', {
file: '/me/dev/my/app.js',
line: 2,
column: 7,
}, true)
})
describe('command error', function () {
@@ -408,9 +286,11 @@ describe('test errors', function () {
})
describe('code frames', function () {
it('shows code frame when included on error', function () {
beforeEach(function () {
this.setError(this.commandErr)
})
it('shows code frame when included on error', function () {
cy
.get('.test-err-code-frame')
.should('be.visible')
@@ -418,7 +298,6 @@ describe('test errors', function () {
it('does not show code frame when not included on error', function () {
this.commandErr.codeFrame = undefined
this.setError(this.commandErr)
cy
.get('.test-err-code-frame')
@@ -426,8 +305,6 @@ describe('test errors', function () {
})
it('use correct language class', function () {
this.setError(this.commandErr)
cy
.get('.test-err-code-frame pre')
.should('have.class', 'language-javascript')
@@ -435,13 +312,15 @@ describe('test errors', function () {
it('falls back to text language class', function () {
this.commandErr.codeFrame.language = null
this.setError(this.commandErr)
cy
.get('.test-err-code-frame pre')
.should('have.class', 'language-text')
})
itHandlesFileOpening('.test-err-code-frame')
itHandlesFileOpening('.test-err-code-frame', {
file: '/me/dev/my/app.js',
line: 2,
column: 7,
})
})
})
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events'
import { itHandlesFileOpening } from '../support/utils'
describe('controls', function () {
beforeEach(function () {
@@ -103,5 +104,17 @@ describe('controls', function () {
.should('be.visible')
})
})
describe('header', function () {
it('displays', function () {
cy.get('.runnable-header').find('a').should('have.text', 'cypress/integration/tests_spec.ts')
})
itHandlesFileOpening('.runnable-header', {
file: '/foo/bar',
line: 0,
column: 0,
})
})
})
})
+128
View File
@@ -0,0 +1,128 @@
const _ = Cypress._
export const itHandlesFileOpening = (containerSelector, file, stackTrace = false) => {
beforeEach(function () {
cy.stub(this.runner, 'emit').callThrough()
})
describe('when user has already set opener and opens file', function () {
beforeEach(function () {
this.editor = {}
this.runner.emit.withArgs('get:user:editor').yields({
preferredOpener: this.editor,
})
if (stackTrace) {
cy.contains('View stack trace').click()
}
})
it('opens in preferred opener', function () {
cy.get(`${containerSelector} a`).first().click().then(() => {
expect(this.runner.emit).to.be.calledWith('open:file', {
where: this.editor,
...file,
})
})
})
})
describe('when user has not already set opener and opens file', function () {
const availableEditors = [
{ id: 'computer', name: 'On Computer', isOther: false, openerId: 'computer' },
{ id: 'atom', name: 'Atom', isOther: false, openerId: 'atom' },
{ id: 'vim', name: 'Vim', isOther: false, openerId: 'vim' },
{ id: 'sublime', name: 'Sublime Text', isOther: false, openerId: 'sublime' },
{ id: 'vscode', name: 'Visual Studio Code', isOther: false, openerId: 'vscode' },
{ id: 'other', name: 'Other', isOther: true, openerId: '' },
]
beforeEach(function () {
this.runner.emit.withArgs('get:user:editor').yields({ availableEditors })
// usual viewport of only reporter is a bit cramped for the modal
cy.viewport(600, 600)
if (stackTrace) {
cy.contains('View stack trace').click()
}
cy.get(`${containerSelector} a`).first().click()
})
it('opens modal with available editors', function () {
_.each(availableEditors, ({ name }) => {
cy.contains(name)
})
cy.contains('Other')
cy.contains('Set preference and open file')
})
// NOTE: this fails because mobx doesn't make the editors observable, so
// the changes to the path don't bubble up correctly. this only happens
// in the Cypress test and not when running the actual app
it.skip('updates "Other" path when typed into', function () {
cy.contains('Other').find('input[type="text"]').type('/path/to/editor')
.should('have.value', '/path/to/editor')
})
describe('when editor is not selected', function () {
it('disables submit button', function () {
cy.contains('Set preference and open file')
.should('have.class', 'is-disabled')
.click()
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'set:user:editor')
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'open:file')
})
it('shows validation message when hovering over submit button', function () {
cy.get('.editor-picker-modal .submit').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Please select a preference')
})
})
describe('when Other is selected but path is not entered', function () {
beforeEach(function () {
cy.contains('Other').click()
})
it('disables submit button', function () {
cy.contains('Set preference and open file')
.should('have.class', 'is-disabled')
.click()
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'set:user:editor')
cy.wrap(this.runner.emit).should('not.to.be.calledWith', 'open:file')
})
it('shows validation message when hovering over submit button', function () {
cy.get('.editor-picker-modal .submit').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Please enter the path for the "Other" editor')
})
})
describe('when editor is set', function () {
beforeEach(function () {
cy.contains('Visual Studio Code').click()
cy.contains('Set preference and open file').click()
})
it('closes modal', function () {
cy.contains('Set preference and open file').should('not.be.visible')
})
it('emits set:user:editor', function () {
expect(this.runner.emit).to.be.calledWith('set:user:editor', availableEditors[4])
})
it('opens file in selected editor', function () {
expect(this.runner.emit).to.be.calledWith('open:file', {
where: availableEditors[4],
...file,
})
})
})
})
}
+2 -2
View File
@@ -18,10 +18,10 @@ export interface AnErrorProps {
const AnError = observer(({ error }: AnErrorProps) => (
<div className='error'>
<h2>
<i className='fas fa-exclamation-triangle'></i> {error.title}
<i className='fas fa-exclamation-triangle' /> {error.title}
{error.link &&
<a href={error.link} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-question-circle'></i>
<i className='fas fa-question-circle' />
</a>
}
</h2>
+1 -7
View File
@@ -2,13 +2,7 @@
import _ from 'lodash'
import { computed, observable } from 'mobx'
export interface FileDetails {
absoluteFile: string
column: number
line: number
originalFile: string
relativeFile: string
}
import { FileDetails } from '../opener/file-model'
interface ParsedStackMessageLine {
message: string
@@ -3,7 +3,7 @@ import { observer } from 'mobx-react'
import Prism from 'prismjs'
import { CodeFrame } from './err-model'
import ErrorFilePath from './error-file-path'
import FileOpener from '../opener/file-opener'
interface Props {
codeFrame: CodeFrame
@@ -24,7 +24,7 @@ class ErrorCodeFrame extends Component<Props> {
return (
<div className='test-err-code-frame'>
<ErrorFilePath fileDetails={this.props.codeFrame} />
<FileOpener className="runnable-err-file-path" fileDetails={this.props.codeFrame} />
<pre ref='codeFrame' data-line={highlightLine}>
<code className={`language-${language || 'text'}`}>{frame}</code>
</pre>
+2 -2
View File
@@ -2,7 +2,7 @@ import _ from 'lodash'
import { observer } from 'mobx-react'
import React, { ReactElement } from 'react'
import ErrorFilePath from './error-file-path'
import FileOpener from '../opener/file-opener'
import Err from './err-model'
const cypressLineRegex = /(cypress:\/\/|cypress_runner\.js)/
@@ -62,7 +62,7 @@ const ErrorStack = observer(({ err }: Props) => {
}
const link = (
<ErrorFilePath key={key} fileDetails={stackLine} />
<FileOpener key={key} className="runnable-err-file-path" fileDetails={stackLine} />
)
return makeLine(key, [whitespace, `at ${fn} (`, link, ')'])
-24
View File
@@ -222,27 +222,3 @@
}
}
}
.editor-picker-modal {
max-width: 40em;
.editor-picker {
margin-bottom: 1em;
}
.controls {
> span:first-child {
order: 1;
}
button.is-disabled,
button.is-disabled:hover,
button.is-disabled:focus {
background: $pass !important;
cursor: default !important;
opacity: 0.5;
}
padding: 1em 1em 1em;
}
}
+2 -2
View File
@@ -62,12 +62,12 @@ const TestError = observer((props: TestErrorProps) => {
<div className='runnable-err'>
<div className='runnable-err-header'>
<div className='runnable-err-name'>
<i className='fas fa-exclamation-circle'></i>
<i className='fas fa-exclamation-circle' />
{err.name}
</div>
</div>
<div className='runnable-err-message'>
<span dangerouslySetInnerHTML={{ __html: formattedMessage(err.message) }}></span>
<span dangerouslySetInnerHTML={{ __html: formattedMessage(err.message) }} />
<DocsUrl url={err.docsUrl} />
</div>
{codeFrame && <ErrorCodeFrame codeFrame={codeFrame} />}
@@ -0,0 +1,7 @@
export interface FileDetails {
absoluteFile: string
column: number
line: number
originalFile: string
relativeFile: string
}
@@ -2,13 +2,9 @@ import _ from 'lodash'
import { action } from 'mobx'
import { observer, useLocalStore } from 'mobx-react'
import React, { MouseEvent } from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
// @ts-ignore
import { EditorPicker } from '@packages/ui-components'
import EditorPickerModal, { Editor } from './editor-picker-modal'
import { FileDetails } from './err-model'
import { FileDetails } from './file-model'
import events from '../lib/events'
interface GetUserEditorResult {
@@ -17,7 +13,8 @@ interface GetUserEditorResult {
}
interface Props {
fileDetails: FileDetails
fileDetails: FileDetails,
className?: string
}
const openFile = (where: Editor, { absoluteFile: file, line, column }: FileDetails) => {
@@ -29,7 +26,7 @@ const openFile = (where: Editor, { absoluteFile: file, line, column }: FileDetai
})
}
const ErrorFilePath = observer(({ fileDetails }: Props) => {
const FileOpener = observer(({ fileDetails, className }: Props) => {
const state = useLocalStore(() => ({
editors: [] as Editor[],
chosenEditor: {} as Editor,
@@ -80,8 +77,8 @@ const ErrorFilePath = observer(({ fileDetails }: Props) => {
const { originalFile, line, column } = fileDetails
return (
<a className='runnable-err-file-path' onClick={attemptOpenFile} href='#'>
{originalFile}:{line}:{column}
<a className={className} onClick={attemptOpenFile} href='#'>
{originalFile}{!!line && `:${line}`}{!!column && `:${column}`}
<EditorPickerModal
chosenEditor={state.chosenEditor}
editors={state.editors}
@@ -94,4 +91,4 @@ const ErrorFilePath = observer(({ fileDetails }: Props) => {
)
})
export default ErrorFilePath
export default FileOpener
+23
View File
@@ -0,0 +1,23 @@
.editor-picker-modal {
max-width: 40em;
.editor-picker {
margin-bottom: 1em;
}
.controls {
> span:first-child {
order: 1;
}
button.is-disabled,
button.is-disabled:hover,
button.is-disabled:focus {
background: $pass !important;
cursor: default !important;
opacity: 0.5;
}
padding: 1em 1em 1em;
}
}
@@ -0,0 +1,29 @@
import React, { Component } from 'react'
import FileOpener from '../opener/file-opener'
interface RunnableHeaderProps {
specPath: string
}
class RunnableHeader extends Component<RunnableHeaderProps> {
render () {
const { specPath } = this.props
const relativeSpecPath = window.Cypress?.spec.relative
const fileDetails = {
absoluteFile: specPath,
column: 0,
line: 0,
originalFile: relativeSpecPath,
relativeFile: relativeSpecPath,
}
return (
<div className="runnable-header">
{ relativeSpecPath === '__all' ? <span>All Specs</span> : <FileOpener fileDetails={fileDetails} /> }
</div>
)
}
}
export default RunnableHeader
@@ -239,4 +239,28 @@
display: none;
}
}
.runnable-header {
background: #f2f2f2;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.25);
display: block;
font-size: 13px;
font-weight: 600;
line-height: 24px;
overflow-wrap: break-word;
padding: 5px 10px;
position: sticky;
top: 0;
width: 100%;
z-index: 1;
a:before,
span:before {
@extend .#{$fa-css-prefix};
@extend .#{$fa-css-prefix}-file;
color: #bdbdbd;
display: inline;
margin-right: 5px;
}
}
}
@@ -5,6 +5,7 @@ import React, { Component } from 'react'
import AnError, { Error } from '../errors/an-error'
import Runnable from './runnable-and-suite'
import RunnableHeader from './runnable-header'
import { RunnablesStore, RunnableArray } from './runnables-store'
import { Scroller } from '../lib/scroller'
import { AppState } from '../lib/app-state'
@@ -55,6 +56,7 @@ class Runnables extends Component<RunnablesProps> {
return (
<div ref='container' className='container'>
<RunnableHeader specPath={specPath} />
{content(runnablesStore, specPath, error)}
</div>
)
+2 -2
View File
@@ -78,7 +78,7 @@ class Test extends Component<Props> {
style={{ paddingLeft: indent(model.level) }}
>
<div className='runnable-content-region'>
<i aria-hidden="true" className='runnable-state fas'></i>
<i aria-hidden="true" className='runnable-state fas' />
<div
aria-expanded={this._shouldBeOpen() === true}
className='runnable-title'
@@ -91,7 +91,7 @@ class Test extends Component<Props> {
</div>
<div className='runnable-controls'>
<Tooltip placement='top' title='One or more commands failed' className='cy-tooltip'>
<i className='fas fa-exclamation-triangle'></i>
<i className='fas fa-exclamation-triangle' />
</Tooltip>
</div>
</div>
-4
View File
@@ -5,10 +5,6 @@ export default {
return !!location.hash
},
specFile () {
return this.specPath().replace(/(.*)\//, '')
},
specPath () {
if (location.hash) {
const match = location.hash.match(/tests\/(.*)$/)