Skip to content
Merged
3 changes: 2 additions & 1 deletion packages/driver/src/cy/commands/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,13 @@ export default (Commands, Cypress, cy, state) => {
if (err.type === 'existence') {
// file exists but it shouldn't - or - file doesn't exist but it should
const errPath = fileResult.contents ? 'files.existent' : 'files.nonexistent'
const { message, docsUrl } = $errUtils.cypressErrByPath(errPath, {
const { message, docsUrl, docsUrlTitle } = $errUtils.cypressErrByPath(errPath, {
args: { cmd: 'readFile', file, filePath: fileResult.filePath },
})

err.message = message
err.docsUrl = docsUrl
err.docsUrlTitle = docsUrlTitle
}

createFilePromise()
Expand Down
6 changes: 4 additions & 2 deletions packages/driver/src/cy/commands/querying/querying.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,11 @@ export default (Commands, Cypress, cy, state) => {
switch (err.type) {
case 'length':
if (err.expected > 1) {
const { message, docsUrl } = $errUtils.cypressErrByPath('contains.length_option')
const { message, docsUrl, docsUrlTitle } = $errUtils.cypressErrByPath('contains.length_option')

err.message = message
err.docsUrl = docsUrl
err.docsUrlTitle = docsUrlTitle
err.retry = false
}

Expand Down Expand Up @@ -381,10 +382,11 @@ export default (Commands, Cypress, cy, state) => {
this.set('onFail', (err) => {
switch (err.type) {
case 'existence': {
const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root')
const { message, docsUrl, docsUrlTitle } = $errUtils.cypressErrByPath('shadow.no_shadow_root')

err.message = message
err.docsUrl = docsUrl
err.docsUrlTitle = docsUrlTitle
break
}
default:
Expand Down
25 changes: 24 additions & 1 deletion packages/driver/src/cypress/error_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import $stackUtils, { StackAndCodeFrameIndex } from './stack_utils'
import $utils from './utils'
import type { HandlerType } from './runner'

const ERROR_PROPS = ['message', 'type', 'name', 'stack', 'parsedStack', 'fileName', 'lineNumber', 'columnNumber', 'host', 'uncaught', 'actual', 'expected', 'showDiff', 'isPending', 'isRecovered', 'docsUrl', 'codeFrame'] as const
const ERROR_PROPS = ['message', 'type', 'name', 'stack', 'parsedStack', 'fileName', 'lineNumber', 'columnNumber', 'host', 'uncaught', 'actual', 'expected', 'showDiff', 'isPending', 'isRecovered', 'docsUrl', 'codeFrame', 'docsUrlTitle'] as const
const ERR_PREPARED_FOR_SERIALIZATION = Symbol('ERR_PREPARED_FOR_SERIALIZATION')

const crossOriginScriptRe = /^script error/i
Expand Down Expand Up @@ -329,6 +329,7 @@ export class InternalCypressError extends Error {

export class CypressError extends Error {
docsUrl?: string
docsUrlTitle?: string | null
retry?: boolean
userInvocationStack?: any
onFail?: Function
Expand Down Expand Up @@ -407,6 +408,23 @@ const docsUrlByParents = (msgPath) => {
return docsUrlByParents(msgPath)
}

// recursively try for a default docsUrlTitle
const docsUrlTitleByParents = (msgPath) => {
msgPath = msgPath.split('.').slice(0, -1).join('.')

if (!msgPath) {
return // reached root
}

const obj = _.get(allErrorMessages, msgPath)

if (obj.hasOwnProperty('docsUrlTitle')) {
return obj.docsUrlTitle
}

return docsUrlTitleByParents(msgPath)
}

const errByPath = (msgPath, args?) => {
let msgValue = _.get(allErrorMessages, msgPath)

Expand All @@ -427,10 +445,12 @@ const errByPath = (msgPath, args?) => {
}

const docsUrl = (msgObj.hasOwnProperty('docsUrl') && msgObj.docsUrl) || docsUrlByParents(msgPath)
const docsUrlTitle = (msgObj.hasOwnProperty('docsUrlTitle') && msgObj.docsUrlTitle) || docsUrlTitleByParents(msgPath)

return cypressErr({
message: replaceErrMsgTokens(msgObj.message, args),
docsUrl: docsUrl ? replaceErrMsgTokens(docsUrl, args) : undefined,
docsUrlTitle: docsUrlTitle ? replaceErrMsgTokens(docsUrlTitle, args) : undefined,
})
}

Expand Down Expand Up @@ -544,6 +564,7 @@ export interface ErrorFromErrorEvent {
const errorFromErrorEvent = (event): ErrorFromErrorEvent => {
let { message, filename, lineno, colno, error } = event
let docsUrl = error?.docsUrl
let docsUrlTitle = error?.docsUrlTitle

// reset the message on a cross origin script error
// since no details are accessible
Expand All @@ -552,6 +573,7 @@ const errorFromErrorEvent = (event): ErrorFromErrorEvent => {

message = crossOriginErr.message
docsUrl = crossOriginErr.docsUrl
docsUrlTitle = crossOriginErr.docsUrlTitle
}

// it's possible the error was thrown as a string (throw 'some error')
Expand All @@ -561,6 +583,7 @@ const errorFromErrorEvent = (event): ErrorFromErrorEvent => {
})) as CypressError

err.docsUrl = docsUrl
err.docsUrlTitle = docsUrlTitle

// makeErrFromObj clones the error, so the original doesn't get mutated
return {
Expand Down
17 changes: 17 additions & 0 deletions packages/reporter/cypress/e2e/test_errors.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,21 @@ describe('test errors', () => {
.should('have.class', 'language-text')
})
})

describe('docs url', () => {
it('renders docs url with default title', () => {
setError(commandErr)

cy.get('.runnable-err-docs-url').should('have.attr', 'href', commandErr.docsUrl)
cy.get('.runnable-err-docs-url').should('have.text', 'Learn more')
})

it('renders docs url with custom title', () => {
commandErr.docsUrlTitle = 'Custom title'
setError(commandErr)

cy.get('.runnable-err-docs-url').should('have.attr', 'href', commandErr.docsUrl)
cy.get('.runnable-err-docs-url').should('have.text', 'Custom title')
})
})
})
4 changes: 4 additions & 0 deletions packages/reporter/src/errors/err-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface ErrProps {
docsUrl: string | string[]
templateType: string
codeFrame: CodeFrame
docsUrlTitle: string | null
}

export default class Err {
Expand All @@ -43,6 +44,7 @@ export default class Err {
// @ts-ignore
codeFrame: CodeFrame
isRecovered: boolean = false
docsUrlTitle: string | null = null

constructor (props?: Partial<ErrProps>) {
makeObservable(this, {
Expand All @@ -56,6 +58,7 @@ export default class Err {
isRecovered: observable,
displayMessage: computed,
isCommandErr: computed,
docsUrlTitle: observable,
})

this.update(props)
Expand All @@ -79,6 +82,7 @@ export default class Err {
if (props.parsedStack) this.parsedStack = props.parsedStack
if (props.templateType) this.templateType = props.templateType
if (props.codeFrame) this.codeFrame = props.codeFrame
if (props.docsUrlTitle) this.docsUrlTitle = props.docsUrlTitle
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Null Value Blocked in Title Update

The update method's if (props.docsUrlTitle) condition prevents docsUrlTitle from being explicitly set to null. Since docsUrlTitle is typed as string | null and null is a meaningful value, this prevents clearing a previously set title.

Fix in Cursor Fix in Web

this.isRecovered = !!props.isRecovered
}
}
7 changes: 4 additions & 3 deletions packages/reporter/src/errors/test-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import { IconChevronRightMedium } from '@cypress-design/react-icon'

interface DocsUrlProps {
url: string | string[]
title: string | null
}

const DocsUrl = ({ url }: DocsUrlProps) => {
const DocsUrl = ({ url, title }: DocsUrlProps) => {
if (!url) return null

const openUrl = (url: string) => (e: React.MouseEvent) => {
Expand All @@ -35,7 +36,7 @@ const DocsUrl = ({ url }: DocsUrlProps) => {

return _.map(urlArray, (url) => (
<a className='runnable-err-docs-url' href={url} key={url} onClick={openUrl(url)}>
Learn more
{title || 'Learn more'}
</a>
))
}
Expand Down Expand Up @@ -93,7 +94,7 @@ const TestError: React.FC<TestErrorProps> = ({ err, groupLevel = 0, testId, comm
<div className='runnable-err-content'>
<div className='runnable-err-message'>
<span dangerouslySetInnerHTML={{ __html: formattedMessage(err.message) }} />
<DocsUrl url={err.docsUrl} />
<DocsUrl url={err.docsUrl} title={err.docsUrlTitle} />
</div>
{codeFrame && <ErrorCodeFrame codeFrame={codeFrame} />}
{err.stack &&
Expand Down
12 changes: 2 additions & 10 deletions packages/server/lib/socket-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import runEvents from './plugins/run_events'
import type { OTLPTraceExporterCloud } from '@packages/telemetry'
import { telemetry } from '@packages/telemetry'
import type { Automation } from './automation'
import { openExternal } from './gui/links'

import type { Socket } from '@packages/socket'

Expand Down Expand Up @@ -658,17 +659,8 @@ export class SocketBase {

socket.on('external:open', (url: string) => {
debug('received external:open %o', { url })
// using this instead of require('electron').shell.openExternal
// because CT runner does not spawn an electron shell
// if we eventually decide to exclusively launch CT from
// the desktop-gui electron shell, we should update this to use
// electron.shell.openExternal.

// cross platform way to open a new tab in default browser, or a new browser window
// if one does not already exist for the user's default browser.
const start = (process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open')

return require('child_process').exec(`${start} ${url}`)
return openExternal(url)
})

socket.on('get:user:editor', (cb) => {
Expand Down