feat: Error standardization (#20323)

* refactor: reworking client-side error shape

* feat: add the CodeFrame to baseerror

* consolidate baseError handling, type fixes

* Fix UNIFY-1164 w/ test cleanup to avoid intercepting

* fix types, cleanup based on review / Brian

* fix: imports / types / tests

* cleanup tests, fix TSError location, add reinitializeCypress mutation

* fix: show correct stack trace file name (#20410)

* Improve comments for regexes / TSError capture

* feat: Add codeframe to error, address PR comments

* update snapshot

* change codeframe impl, per Brian's request

* Attempt to fix test flake

Co-authored-by: ElevateBart <ledouxb@gmail.com>
Co-authored-by: Alejandro Estrada <estrada9166@hotmail.com>
This commit is contained in:
Tim Griesser
2022-03-02 18:03:52 -05:00
committed by GitHub
parent 7b14fd08d1
commit 92eac2f67e
110 changed files with 1520 additions and 635 deletions

View File

@@ -28,6 +28,7 @@ system-tests/lib/scaffold/support/commands.js
system-tests/test/support/projects/e2e/cypress/
system-tests/projects/e2e/cypress/e2e/stdout_exit_early_failing.cy.js
system-tests/projects/e2e/cypress/e2e/typescript_syntax_error.cy.ts
system-tests/projects/config-with-ts-syntax-error/**
**/test/fixtures

View File

@@ -261,7 +261,7 @@ describe('App: Index', () => {
})
it('opens config file in ide from SpecPattern', () => {
cy.intercept('mutation-OpenConfigFile', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-OpenFileInIDE_Mutation', { data: { openFileInIDE: true } }).as('OpenIDE')
cy.findByRole('button', { name: 'cypress.config.js' }).click()
@@ -269,7 +269,7 @@ describe('App: Index', () => {
})
it('opens config file in ide from footer button', () => {
cy.intercept('mutation-OpenConfigFile', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-OpenFileInIDE_Mutation', { data: { openFileInIDE: true } }).as('OpenIDE')
cy.contains('button', defaultMessages.createSpec.updateSpecPattern).click()
@@ -697,7 +697,7 @@ describe('App: Index', () => {
})
it('opens config file in ide from SpecPattern', () => {
cy.intercept('mutation-OpenConfigFile', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-OpenFileInIDE_Mutation', { data: { openFileInIDE: true } }).as('OpenIDE')
cy.findByRole('button', { name: 'cypress.config.js' }).click()
@@ -705,7 +705,7 @@ describe('App: Index', () => {
})
it('opens config file in ide from footer button', () => {
cy.intercept('mutation-OpenConfigFile', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-OpenFileInIDE_Mutation', { data: { openFileInIDE: true } }).as('OpenIDE')
cy.contains('button', defaultMessages.createSpec.updateSpecPattern).click()

View File

@@ -49,12 +49,12 @@ describe('hooks', {
cy.get('.hook-open-in-ide').should('have.length', 4)
cy.intercept('mutation-OpenFileInIDE', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-SpecRunnerOpenMode_OpenFileInIDE', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.contains('Open in IDE').invoke('show').click({ force: true })
cy.wait('@OpenIDE').then(({ request }) => {
expect(request.body.variables.input.absolute).to.include('hooks/basic.cy.js')
expect(request.body.variables.input.filePath).to.include('hooks/basic.cy.js')
expect(request.body.variables.input.column).to.eq(Cypress.browser.family === 'firefox' ? 6 : 3)
expect(request.body.variables.input.line).to.eq(2)
})

View File

@@ -102,13 +102,13 @@ describe('src/cypress/runner', () => {
hasPreferredIde: true,
})
cy.intercept('mutation-OpenFileInIDE', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-SpecRunnerOpenMode_OpenFileInIDE', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.contains('a', 'simple-cy-assert.runner')
.click()
cy.wait('@OpenIDE').then(({ request }) => {
expect(request.body.variables.input.absolute).to.include('simple-cy-assert.runner.cy.js')
expect(request.body.variables.input.filePath).to.include('simple-cy-assert.runner.cy.js')
})
cy.get('[data-cy="runnable-header"] [data-cy="spec-duration"]').should('exist')

View File

@@ -5,12 +5,12 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json'
// whether the test has a preferred IDE defined.
const verifyIdeOpen = ({ fileName, action, hasPreferredIde }) => {
if (hasPreferredIde) {
cy.intercept('mutation-OpenFileInIDE', { data: { 'openFileInIDE': true } }).as('OpenIDE')
cy.intercept('mutation-SpecRunnerOpenMode_OpenFileInIDE', { data: { 'openFileInIDE': true } }).as('OpenIDE')
action()
cy.wait('@OpenIDE').then(({ request }) => {
expect(request.body.variables.input.absolute).to.include(fileName)
expect(request.body.variables.input.filePath).to.include(fileName)
})
} else {
action()

View File

@@ -1,3 +1,5 @@
import type { SinonStub } from 'sinon'
describe('App: Settings', () => {
before(() => {
cy.scaffoldProject('todos', { timeout: 50 * 1000 })
@@ -27,11 +29,15 @@ describe('App: Settings', () => {
it('can reconfigure a project', () => {
cy.startAppServer('e2e')
cy.__incorrectlyVisitAppWithIntercept('settings')
cy.visitApp('settings')
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.project, 'reconfigureProject')
})
cy.intercept('mutation-SettingsContainer_ReconfigureProject', { 'data': { 'reconfigureProject': true } }).as('ReconfigureProject')
cy.findByText('Reconfigure Project').click()
cy.wait('@ReconfigureProject')
cy.withRetryableCtx((ctx) => {
expect(ctx.actions.project.reconfigureProject).to.have.been.called
})
})
describe('Cloud Settings', () => {
@@ -68,13 +74,12 @@ describe('App: Settings', () => {
it('opens cloud settings when clicking on "Manage Keys"', () => {
cy.startAppServer('e2e')
cy.loginUser()
cy.intercept('mutation-ExternalLink_OpenExternal', { 'data': { 'openExternal': true } }).as('OpenExternal')
cy.__incorrectlyVisitAppWithIntercept('settings')
cy.visitApp('settings')
cy.findByText('Dashboard Settings').click()
cy.findByText('Manage Keys').click()
cy.wait('@OpenExternal')
.its('request.body.variables.url')
.should('equal', 'http:/test.cloud/cloud-project/settings')
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.electron.openExternal as SinonStub).lastCall.lastArg).to.eq('http:/test.cloud/cloud-project/settings')
})
})
})
@@ -155,15 +160,28 @@ describe('App: Settings', () => {
})
})
// TODO: The Edit button isn't hooked up to do anything when it should trigger the openFileInIDE mutation (https://cypress-io.atlassian.net/browse/UNIFY-1164)
it.skip('opens cypress.config.js file after clicking "Edit" button', () => {
it('opens cypress.config.js file after clicking "Edit" button', () => {
cy.startAppServer('e2e')
cy.withCtx((ctx, o) => {
ctx.coreData.localSettings.preferences.preferredEditorBinary = 'computer'
o.sinon.stub(ctx.actions.file, 'openFile')
})
cy.visitApp('/settings')
cy.findByText('Project Settings').click()
cy.findByRole('button', { name: 'Edit' }).click()
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.file.openFile as SinonStub).lastCall.args[0]).to.eq(ctx.lifecycleManager.configFilePath)
})
})
})
describe('external editor', () => {
beforeEach(() => {
cy.startAppServer('e2e')
cy.withCtx(async (ctx) => {
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.localSettings, 'setPreferences')
o.sinon.stub(ctx.actions.file, 'openFile')
ctx.coreData.localSettings.availableEditors = [
...ctx.coreData.localSettings.availableEditors,
// don't rely on CI machines to have specific editors installed
@@ -183,15 +201,15 @@ describe('App: Settings', () => {
})
it('selects well known editor', () => {
cy.intercept('POST', 'mutation-ExternalEditorSettings_SetPreferredEditorBinary').as('SetPreferred')
cy.contains('Choose your editor...').click()
cy.contains('Well known editor').click()
cy.wait('@SetPreferred').its('request.body.variables.value').should('include', '/usr/bin/well-known')
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('/usr/bin/well-known')
})
// navigate away and come back
// preferred editor selected from dropdown should have been persisted
cy.__incorrectlyVisitAppWithIntercept()
cy.visitApp()
cy.get('[href="#/settings"]').click()
cy.wait(200)
cy.get('[data-cy="Device Settings"]').click()
@@ -200,8 +218,6 @@ describe('App: Settings', () => {
})
it('allows custom editor', () => {
cy.intercept('POST', 'mutation-ExternalEditorSettings_SetPreferredEditorBinary').as('SetPreferred')
cy.contains('Choose your editor...').click()
cy.contains('Custom').click()
@@ -209,7 +225,9 @@ describe('App: Settings', () => {
// for each keystroke, making it hard to intercept **only** the final request, which I want to
// assert contains `/usr/local/bin/vim'
cy.findByPlaceholderText('/path/to/editor').clear().invoke('val', '/usr/local/bin/vim').trigger('input').trigger('change')
cy.wait('@SetPreferred').its('request.body.variables.value').should('include', '/usr/local/bin/vim')
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('/usr/local/bin/vim')
})
// navigate away and come back
// preferred editor entered from input should have been persisted
@@ -221,12 +239,12 @@ describe('App: Settings', () => {
})
it('lists file browser as available editor', () => {
cy.intercept('POST', 'mutation-ExternalEditorSettings_SetPreferredEditorBinary').as('SetPreferred')
cy.contains('Choose your editor...').click()
cy.get('[data-cy="computer"]').click()
cy.wait('@SetPreferred').its('request.body.variables.value').should('include', 'computer')
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('computer')
})
cy.get('[data-cy="custom-editor"]').should('not.exist')
})

View File

@@ -29,7 +29,7 @@ describe('<SpecPatternModal />', () => {
cy.percySnapshot()
cy.get('[data-cy="open-config-file"').should('be.visible')
cy.get('[data-cy="open-config-file"]').should('be.visible')
cy.contains('button', defaultMessages.createSpec.updateSpecPattern)
cy.contains('button', defaultMessages.components.modal.dismiss).click()

View File

@@ -19,8 +19,15 @@
<StandardModalFooter
class="flex gap-16px items-center"
>
<OpenConfigFileInIDE>
<Button size="lg">
<OpenConfigFileInIDE
v-slot="{onClick}"
:gql="props.gql"
>
<Button
size="lg"
data-cy="open-config-file"
@click="onClick"
>
<template #prefix>
<i-cy-code-editor_x16 class="icon-dark-white" />
</template>
@@ -52,6 +59,7 @@ gql`
fragment SpecPatternModal on CurrentProject {
id
...SpecPatterns
...OpenConfigFileInIDE
}
`

View File

@@ -9,11 +9,17 @@
{{ props.gql.specs.length }}
</i18n-t>
</FileMatchIndicator>
<OpenConfigFileInIDE>
<span class="flex items-center text-indigo-500 outline-transparent gap-8px group">
<i-cy-document-text_x16 class="icon-light-gray-50 icon-dark-gray-300" />
<span class="group-hocus:underline">cypress.config.js</span>
</span>
<OpenConfigFileInIDE
v-slot="{onClick}"
:gql="props.gql"
>
<button
class="flex items-center text-indigo-500 outline-transparent gap-8px group"
@click="onClick"
>
<i-cy-document-text_x16 class="icon-light-gray-100 icon-dark-gray-500" />
<span class="group-hocus:underline">{{ props.gql.configFile }}</span>
</button>
</OpenConfigFileInIDE>
</div>
@@ -42,6 +48,8 @@ fragment SpecPatterns on CurrentProject {
id
config
currentTestingType
...OpenConfigFileInIDE
configFile
specs {
id
}

View File

@@ -104,7 +104,8 @@ import ScreenshotHelperPixels from './screenshot/ScreenshotHelperPixels.vue'
import { useScreenshotStore } from '../store/screenshot-store'
import ChooseExternalEditorModal from '@packages/frontend-shared/src/gql-components/ChooseExternalEditorModal.vue'
import { useMutation, gql } from '@urql/vue'
import { OpenFileInIdeDocument, SpecRunnerFragment } from '../generated/graphql'
import { SpecRunnerOpenMode_OpenFileInIdeDocument } from '../generated/graphql'
import type { SpecRunnerFragment } from '../generated/graphql'
import { usePreferences } from '../composables/usePreferences'
import ScriptError from './ScriptError.vue'
import ResizablePanels from './ResizablePanels.vue'
@@ -140,7 +141,7 @@ fragment SpecRunner on Query {
`
gql`
mutation OpenFileInIDE ($input: FileDetailsInput!) {
mutation SpecRunnerOpenMode_OpenFileInIDE ($input: FileDetailsInput!) {
openFileInIDE (input: $input)
}
`
@@ -188,7 +189,7 @@ preferences.update('specListWidth', specListWidth.value)
let fileToOpen: FileDetails
const openFileInIDE = useMutation(OpenFileInIdeDocument)
const openFileInIDE = useMutation(SpecRunnerOpenMode_OpenFileInIdeDocument)
function openFile () {
runnerUiStore.setShowChooseExternalEditorModal(false)
@@ -200,7 +201,7 @@ function openFile () {
openFileInIDE.executeMutation({
input: {
absolute: fileToOpen.absoluteFile,
filePath: fileToOpen.absoluteFile,
line: fileToOpen.line,
column: fileToOpen.column,
},

View File

@@ -54,7 +54,8 @@
import { computed, ref } from 'vue'
import { gql, useMutation } from '@urql/vue'
import RunsError from './RunsError.vue'
import { RunsErrorRendererFragment, RunsErrorRenderer_RequestAccessDocument } from '../generated/graphql'
import { RunsErrorRenderer_RequestAccessDocument } from '../generated/graphql'
import type { RunsErrorRendererFragment } from '../generated/graphql'
import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import SendIcon from '~icons/cy/paper-airplane_x16.svg'
import { useI18n } from '@cy/i18n'

View File

@@ -157,7 +157,8 @@ import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import CreateIcon from '~icons/cy/add-large_x16.svg'
import FolderIcon from '~icons/cy/folder-outline_x16.svg'
import OrganizationIcon from '~icons/cy/office-building_x16.svg'
import { SelectCloudProjectModalFragment, SelectCloudProjectModal_CreateCloudProjectDocument, SelectCloudProjectModal_SetProjectIdDocument } from '../../generated/graphql'
import { SelectCloudProjectModal_CreateCloudProjectDocument, SelectCloudProjectModal_SetProjectIdDocument } from '../../generated/graphql'
import type { SelectCloudProjectModalFragment } from '../../generated/graphql'
import { useI18n } from '@cy/i18n'
const { t } = useI18n()

View File

@@ -11,8 +11,8 @@
max-height="10000px"
>
<ProjectSettings
v-if="props.gql"
:gql="props.gql"
v-if="props.gql.currentProject"
:gql="props.gql.currentProject"
/>
</SettingsCard>
<SettingsCard
@@ -57,8 +57,8 @@ import Button from '@cy/components/Button.vue'
import ExternalEditorSettings from './device/ExternalEditorSettings.vue'
import ProxySettings from './device/ProxySettings.vue'
import SettingsCard from './SettingsCard.vue'
import CloudSettings from './project/CloudSettings.vue'
import ProjectSettings from './project/ProjectSettings.vue'
import CloudSettings from '../settings/project/CloudSettings.vue'
import TestingPreferences from './device/TestingPreferences.vue'
import type { SettingsContainerFragment } from '../generated/graphql'
import { SettingsContainer_ReconfigureProjectDocument } from '../generated/graphql'
@@ -78,7 +78,10 @@ mutation SettingsContainer_ReconfigureProject {
gql`
fragment SettingsContainer on Query {
...TestingPreferences
...ProjectSettings
currentProject {
id
...ProjectSettings
}
...CloudSettings
...ExternalEditorSettings
...ProxySettings

View File

@@ -20,7 +20,8 @@ import SettingsSection from '../SettingsSection.vue'
import ChooseExternalEditor from '@packages/frontend-shared/src/gql-components/ChooseExternalEditor.vue'
import { useI18n } from '@cy/i18n'
import { gql } from '@urql/core'
import { ExternalEditorSettingsFragment, ExternalEditorSettings_SetPreferredEditorBinaryDocument } from '../../generated/graphql'
import { ExternalEditorSettings_SetPreferredEditorBinaryDocument } from '../../generated/graphql'
import type { ExternalEditorSettingsFragment } from '../../generated/graphql'
import { useMutation } from '@urql/vue'
gql`

View File

@@ -32,7 +32,8 @@ import SettingsSection from '../SettingsSection.vue'
import { useI18n } from '@cy/i18n'
import Switch from '@packages/frontend-shared/src/components/Switch.vue'
import { gql, useMutation } from '@urql/vue'
import { SetTestingPreferencesDocument, TestingPreferencesFragment } from '../../generated/graphql'
import { SetTestingPreferencesDocument } from '../../generated/graphql'
import type { TestingPreferencesFragment } from '../../generated/graphql'
const { t } = useI18n()

View File

@@ -31,7 +31,7 @@ describe('<Config/>', { viewportWidth: 1200, viewportHeight: 1600 }, () => {
const sourceTypes = ['default', 'config', 'env', 'cli', 'plugin']
sourceTypes.forEach((sourceType) => {
result.currentProject?.config?.unshift({
result.config?.unshift({
field: sourceType,
value: `testValue-${sourceType}`,
from: sourceType,

View File

@@ -10,15 +10,16 @@
scope="global"
keypath="settingsPage.config.description"
>
<OpenConfigFileInIDE />
<OpenConfigFileInIDE :gql="props.gql" />
</i18n-t>
</template>
<div class="flex w-full">
<ConfigCode
data-cy="config-code"
:config="configObject"
:gql="props.gql"
/>
<ConfigLegend
:gql="props.gql"
data-cy="config-legend"
class="rounded-tr-md rounded-br-md border-1 border-l-0 min-w-280px py-28px px-22px"
/>
@@ -27,7 +28,6 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { gql } from '@urql/vue'
import SettingsSection from '../SettingsSection.vue'
import { useI18n } from '@cy/i18n'
@@ -39,17 +39,14 @@ import OpenConfigFileInIDE from '@packages/frontend-shared/src/gql-components/Op
const { t } = useI18n()
gql`
fragment Config on Query {
currentProject {
id
config
}
fragment Config on CurrentProject {
id
...OpenConfigFileInIDE
...ConfigCode
}
`
const props = defineProps<{
gql: ConfigFragment
}>()
const configObject = computed(() => props.gql.currentProject?.config)
</script>

View File

@@ -1,5 +1,5 @@
import ConfigCode from './ConfigCode.vue'
import config from '../../../../frontend-shared/cypress/fixtures/config.json'
import config from '@packages/frontend-shared/cypress/fixtures/config.json'
import { defaultMessages } from '@cy/i18n'
const selector = '[data-cy=code]'
@@ -16,11 +16,16 @@ describe('<ConfigCode />', () => {
context('with mock values', () => {
it('shows the arrayTest nicely', () => {
cy.mount(() => (<div class="p-12 overflow-auto">
<ConfigCode data-cy="code" config={[{
field: 'arrayTest',
value: arrayTest,
from: 'plugin',
}]} />
<ConfigCode data-cy="code" gql={{
id: 'project-id',
configFile: 'cypress.config.js',
configFileAbsolutePath: '/path/to/cypress.config.js',
config: [{
field: 'arrayTest',
value: arrayTest,
from: 'plugin',
}],
}} />
</div>))
cy.contains(`arrayTest:`).should('contain.text', `['${arrayTest.join('\', \'')}', ]`)
@@ -28,11 +33,16 @@ describe('<ConfigCode />', () => {
it('shows the objectTest nicely', () => {
cy.mount(() => (<div class="p-12 overflow-auto">
<ConfigCode data-cy="code" config={[{
field: 'objectTest',
value: objectTest,
from: 'env',
}]} />
<ConfigCode data-cy="code" gql={{
id: 'project-id',
configFile: 'cypress.config.js',
configFileAbsolutePath: '/path/to/cypress.config.js',
config: [{
field: 'objectTest',
value: objectTest,
from: 'env',
}],
}} />
</div>))
const expectedText = `{${Object.entries(objectTest).map(([key, value]) => `'${key}': '${value}'`).join(',')},}`
@@ -44,7 +54,12 @@ describe('<ConfigCode />', () => {
context('with real config file', () => {
beforeEach(() => {
cy.mount(() => (<div class="p-12 overflow-auto">
<ConfigCode data-cy="code" config={config as any} />
<ConfigCode data-cy="code" gql={{
id: 'project-id',
configFile: 'cypress.config.js',
configFileAbsolutePath: '/path/to/cypress.config.js',
config,
}} />
</div>))
})

View File

@@ -1,20 +1,26 @@
<template>
<div class="rounded-bl-md rounded-tl-md mx-auto border-1 w-full min-w-100px relative hide-scrollbar overflow-auto grow-1">
<Button
variant="outline"
class="top-4 right-4 absolute"
:prefix-icon="IconCode"
prefix-icon-class="text-gray-500"
<OpenConfigFileInIDE
v-slot="{onClick}"
:gql="props.gql"
>
{{ t('file.edit') }}
</Button>
<Button
variant="outline"
class="top-4 right-4 absolute"
:prefix-icon="IconCode"
prefix-icon-class="text-gray-500"
@click="onClick"
>
{{ t('file.edit') }}
</Button>
</OpenConfigFileInIDE>
<code
class="font-thin p-16px text-gray-600 text-size-14px leading-24px block"
>
{<br>
<div class="pl-24px">
<span
v-for="{ field, value, from } in config"
v-for="{ field, value, from } in props.gql.config"
:key="field"
>
{{ field }}:
@@ -47,12 +53,22 @@ import IconCode from '~icons/mdi/code'
import { useI18n } from '@cy/i18n'
import { CONFIG_LEGEND_COLOR_MAP } from './ConfigSourceColors'
import Browsers from './renderers/Browsers.vue'
import type { CypressResolvedConfig } from './projectSettings'
import RenderObject from './renderers/RenderObject.vue'
import { renderPrimitive } from './renderers/renderPrimitive'
import { gql } from '@urql/core'
import OpenConfigFileInIDE from '@packages/frontend-shared/src/gql-components/OpenConfigFileInIDE.vue'
import type { ConfigCodeFragment } from '../../generated/graphql'
defineProps<{
config: CypressResolvedConfig
gql`
fragment ConfigCode on CurrentProject {
id
config
...OpenConfigFileInIDE
}
`
const props = defineProps<{
gql: ConfigCodeFragment
}>()
// a bug in vite demands that we do this passthrough

View File

@@ -1,12 +1,16 @@
import { defaultMessages } from '@cy/i18n'
import ConfigLegend from './ConfigLegend.vue'
import { each } from 'lodash'
import { OpenConfigFileInIdeFragmentDoc } from '../../generated/graphql-test'
const legend = defaultMessages.settingsPage.config.legend
describe('<ConfigLegend/>', () => {
it('renders', () => {
cy.mount(ConfigLegend)
cy.mountFragment(OpenConfigFileInIdeFragmentDoc, {
render: (gql) => <ConfigLegend gql={gql} />,
})
cy.get('[data-cy="external"]').should('have.attr', 'href').and('eq', 'https://on.cypress.io/setup-node-events')
each(legend, ({ label, description }) => {

View File

@@ -15,7 +15,7 @@
scope="global"
:keypath="legendText.config.descriptionKey"
>
<OpenConfigFileInIDE />
<OpenConfigFileInIDE :gql="props.gql" />
</i18n-t>
</ConfigBadge>
@@ -59,6 +59,11 @@ import { computed } from 'vue'
import { useI18n } from '@cy/i18n'
import { CONFIG_LEGEND_COLOR_MAP } from './ConfigSourceColors'
import OpenConfigFileInIDE from '@packages/frontend-shared/src/gql-components/OpenConfigFileInIDE.vue'
import type { OpenConfigFileInIdeFragment } from '../../generated/graphql'
const props = defineProps<{
gql: OpenConfigFileInIdeFragment
}>()
const { t } = useI18n()
const legendText = computed(() => {

View File

@@ -1,6 +1,6 @@
<template>
<SpecPatterns :gql="props.gql.currentProject" />
<Experiments :gql="props.gql.currentProject" />
<SpecPatterns :gql="props.gql" />
<Experiments :gql="props.gql" />
<Config :gql="props.gql" />
</template>
@@ -12,12 +12,10 @@ import SpecPatterns from './SpecPatterns.vue'
import type { ProjectSettingsFragment } from '../../generated/graphql'
gql`
fragment ProjectSettings on Query {
currentProject {
id
...Experiments
...SpecPatterns_Settings
}
fragment ProjectSettings on CurrentProject {
id
...Experiments
...SpecPatterns_Settings
...Config
}
`

View File

@@ -12,8 +12,6 @@
<script lang="ts" setup>
import { generatorList } from './generators'
// eslint-disable-next-line no-duplicate-imports
import type { GeneratorId } from './generators'
import { computed } from 'vue'
import type { CreateSpecCardsFragment } from '../generated/graphql'

View File

@@ -25,6 +25,8 @@ describe('<CreateSpecModal />', () => {
},
storybook: null,
currentTestingType: 'component',
configFile: 'cypress.config.js',
configFileAbsolutePath: '/path/to/cypress.config.js',
config: {
e2e: {
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
@@ -91,6 +93,8 @@ describe('playground', () => {
},
storybook: null,
currentTestingType: 'component',
configFile: 'cypress.config.js',
configFileAbsolutePath: '/path/to/cypress.config.js',
config: {
e2e: {
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',

View File

@@ -41,11 +41,13 @@
</template>
<script lang ="ts" setup>
import { generators, GeneratorId } from './generators'
import { generators } from './generators'
import type { GeneratorId } from './generators'
import { DialogOverlay } from '@headlessui/vue'
import StandardModal from '@cy/components/StandardModal.vue'
import CreateSpecCards from './CreateSpecCards.vue'
import { ref, computed, Ref } from 'vue'
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import type { CreateSpecModalFragment } from '../generated/graphql'
import { gql } from '@urql/vue'
import { not, whenever } from '@vueuse/core'

View File

@@ -1,14 +1,15 @@
<template>
<!-- TODO: spread on props.gql is needed due to bug in mountFragment. Fix -->
<SpecPatterns
v-if="props.gql.currentProject"
:gql="{...props.gql.currentProject}"
/>
<SpecPatterns :gql="{...props.gql}" />
<div class="flex mt-32px gap-16px justify-center">
<OpenConfigFileInIDE>
<OpenConfigFileInIDE
v-slot="{onClick}"
:gql="props.gql"
>
<Button
size="lg"
@click="onClick"
>
<template #prefix>
<i-cy-code-editor_x16 class="icon-dark-white" />
@@ -41,11 +42,11 @@ import OpenConfigFileInIDE from '@packages/frontend-shared/src/gql-components/Op
const { t } = useI18n()
gql`
fragment CustomPatternNoSpecContent on Query {
currentProject {
id
...SpecPatterns
}
fragment CustomPatternNoSpecContent on CurrentProject {
id
...SpecPatterns
...OpenConfigFileInIDE
configFileAbsolutePath
}
`

View File

@@ -28,7 +28,8 @@ import type { Specs_InlineSpecListFragment } from '../generated/graphql'
import InlineSpecListHeader from './InlineSpecListHeader.vue'
import InlineSpecListTree from './InlineSpecListTree.vue'
import CreateSpecModal from './CreateSpecModal.vue'
import { FuzzyFoundSpec, fuzzySortSpecs, makeFuzzyFoundSpec, useCachedSpecs } from '@packages/frontend-shared/src/utils/spec-utils'
import { fuzzySortSpecs, makeFuzzyFoundSpec, useCachedSpecs } from '@packages/frontend-shared/src/utils/spec-utils'
import type { FuzzyFoundSpec } from '@packages/frontend-shared/src/utils/spec-utils'
import { useDebounce } from '@vueuse/core'
gql`

View File

@@ -59,8 +59,10 @@
</template>
<script setup lang="ts">
import { useCollapsibleTree, UseCollapsibleTreeNode } from '@packages/frontend-shared/src/composables/useCollapsibleTree'
import { buildSpecTree, FuzzyFoundSpec, SpecTreeNode, getDirIndexes } from '@packages/frontend-shared/src/utils/spec-utils'
import { useCollapsibleTree } from '@packages/frontend-shared/src/composables/useCollapsibleTree'
import type { UseCollapsibleTreeNode } from '@packages/frontend-shared/src/composables/useCollapsibleTree'
import { buildSpecTree, getDirIndexes } from '@packages/frontend-shared/src/utils/spec-utils'
import type { SpecTreeNode, FuzzyFoundSpec } from '@packages/frontend-shared/src/utils/spec-utils'
import SpecFileItem from './SpecFileItem.vue'
import { computed, watch, onMounted } from 'vue'
import DirectoryItem from './DirectoryItem.vue'

View File

@@ -34,7 +34,7 @@
/>
<CustomPatternNoSpecContent
v-else
:gql="props.gql"
:gql="props.gql.currentProject"
@showCreateSpecModal="showCreateSpecModal"
/>
</div>
@@ -51,11 +51,11 @@ gql`
fragment NoSpecsPage on Query {
...CreateSpecCards
...ChooseExternalEditor
...CustomPatternNoSpecContent
currentProject {
id
currentTestingType
configFileAbsolutePath
...CustomPatternNoSpecContent
}
}
`

View File

@@ -108,7 +108,8 @@ import { gql } from '@urql/vue'
import { computed, ref, watch } from 'vue'
import type { Specs_SpecsListFragment, SpecListRowFragment } from '../generated/graphql'
import { useI18n } from '@cy/i18n'
import { buildSpecTree, FuzzyFoundSpec, fuzzySortSpecs, getDirIndexes, makeFuzzyFoundSpec, useCachedSpecs } from '@packages/frontend-shared/src/utils/spec-utils'
import { buildSpecTree, fuzzySortSpecs, getDirIndexes, makeFuzzyFoundSpec, useCachedSpecs } from '@packages/frontend-shared/src/utils/spec-utils'
import type { FuzzyFoundSpec } from '@packages/frontend-shared/src/utils/spec-utils'
import { useCollapsibleTree } from '@packages/frontend-shared/src/composables/useCollapsibleTree'
import RowDirectory from './RowDirectory.vue'
import SpecItem from './SpecItem.vue'

View File

@@ -1,18 +1,16 @@
declare module 'virtual:*' {
import { Component } from 'vue'
import type { Component } from 'vue'
const src: Component
export default src
}
declare module 'virtual:icons/*' {
// eslint-disable-next-line no-duplicate-imports
import { FunctionalComponent, SVGAttributes } from 'vue'
import type { FunctionalComponent, SVGAttributes } from 'vue'
const component: FunctionalComponent<SVGAttributes>
export default component
}
declare module '~icons/*' {
// eslint-disable-next-line no-duplicate-imports
import { FunctionalComponent, SVGAttributes } from 'vue'
import type { FunctionalComponent, SVGAttributes } from 'vue'
const component: FunctionalComponent<SVGAttributes>
export default component
}

View File

@@ -14,6 +14,7 @@
"test-unit": "mocha -r @packages/ts/register --config ./test/.mocharc.js"
},
"dependencies": {
"@babel/code-frame": "7.8.3",
"@babel/parser": "7.13.0",
"@storybook/csf-tools": "^6.4.0-alpha.38",
"@urql/core": "2.3.1",
@@ -57,6 +58,7 @@
"@packages/ts": "0.0.0-development",
"@packages/types": "0.0.0-development",
"@tooling/system-tests": "0.0.0-development",
"@types/babel__code-frame": "^7.0.3",
"@types/dedent": "^0.7.0",
"@types/ejs": "^3.1.0",
"@types/fs-extra": "^8.0.1",

View File

@@ -4,7 +4,7 @@ import path from 'path'
import util from 'util'
import chalk from 'chalk'
import assert from 'assert'
import s from 'underscore.string'
import str from 'underscore.string'
import 'server-destroy'
@@ -40,6 +40,7 @@ import type { Socket, SocketIOServer } from '@packages/socket'
import { globalPubSub } from '.'
import { InjectedConfigApi, ProjectLifecycleManager } from './data/ProjectLifecycleManager'
import type { CypressError } from '@packages/errors'
import { ErrorDataSource } from './sources/ErrorDataSource'
const IS_DEV_ENV = process.env.CYPRESS_INTERNAL_ENV !== 'production'
@@ -125,16 +126,7 @@ export class DataContext {
}
get baseError () {
if (!this.coreData.baseError) {
return null
}
// TODO: Standardize approach to serializing errors
return {
title: this.coreData.baseError.title,
message: this.coreData.baseError.message,
stack: this.coreData.baseError.stack,
}
return this.coreData.baseError
}
@cached
@@ -216,6 +208,11 @@ export class DataContext {
return new HtmlDataSource(this)
}
@cached
get error () {
return new ErrorDataSource(this)
}
@cached
get util () {
return new UtilDataSource(this)
@@ -347,15 +344,19 @@ export class DataContext {
console.error(e)
}
onError = (err: Error) => {
onError = (cypressError: CypressError, title?: string) => {
if (this.isRunMode) {
if (this.lifecycleManager?.runModeExitEarly) {
this.lifecycleManager.runModeExitEarly(err)
this.lifecycleManager.runModeExitEarly(cypressError)
} else {
throw err
throw cypressError
}
} else {
this.coreData.baseError = err
this.update((coreData) => {
coreData.baseError = { title, cypressError }
})
this.emitter.toLaunchpad()
}
}
@@ -365,9 +366,8 @@ export class DataContext {
console.log(chalk.yellow(err.message))
} else {
this.coreData.warnings.push({
title: `Warning: ${s.titleize(s.humanize(err.type ?? ''))}`,
message: err.messageMarkdown || err.message,
details: err.details,
title: `Warning: ${str.titleize(str.humanize(err.type ?? ''))}`,
cypressError: err,
})
this.emitter.toLaunchpad()
@@ -401,12 +401,7 @@ export class DataContext {
* Resets all of the state for the data context,
* so we can initialize fresh for each E2E test
*/
async resetForTest (modeOptions: Partial<AllModeOptions> = {}) {
this.debug('DataContext resetForTest')
if (!process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) {
throw new Error(`DataContext.reset is only meant to be called in E2E testing mode, there's no good use for it outside of that`)
}
async reinitializeCypress (modeOptions: Partial<AllModeOptions> = {}) {
await this._reset()
this._modeOptions = modeOptions
@@ -418,12 +413,6 @@ export class DataContext {
}
private _reset () {
// this._gqlServer?.close()
// this.emitter.destroy()
// this._loadingManager.destroy()
// this._loadingManager = new LoadingManager(this)
// this.coreData.currentProject?.watcher
// this._coreData = makeCoreData({}, this._loadingManager)
this.setAppSocketServer(undefined)
this.setGqlSocketServer(undefined)

View File

@@ -3,6 +3,7 @@ import path from 'path'
// @ts-ignore - no types available
import launchEditor from 'launch-editor'
import type { DataContext } from '..'
import assert from 'assert'
export class FileActions {
constructor (private ctx: DataContext) {}
@@ -66,9 +67,12 @@ export class FileActions {
}
}
openFile (absolute: string, line: number = 1, column: number = 1) {
openFile (filePath: string, line: number = 1, column: number = 1) {
assert(this.ctx.currentProject)
const binary = this.ctx.coreData.localSettings.preferences.preferredEditorBinary
const absolute = path.resolve(this.ctx.currentProject, filePath)
if (!binary || !absolute) {
this.ctx.debug('cannot open file without binary')

View File

@@ -61,12 +61,8 @@ export class MigrationActions {
const projectRoot = this.ctx.path.join(this.ctx.currentProject)
try {
await moveSpecFiles(projectRoot, specsToMove)
await cleanUpIntegrationFolder(this.ctx.currentProject)
} catch (err: any) {
this.ctx.coreData.baseError = err
}
await moveSpecFiles(projectRoot, specsToMove)
await cleanUpIntegrationFolder(this.ctx.currentProject)
}
async renameSupportFile () {

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-dupe-class-members */
import type { CypressError, SerializedError } from '@packages/errors'
import type { CypressError } from '@packages/errors'
import type { TestingType } from '@packages/types'
import type { ChildProcess } from 'child_process'
import EventEmitter from 'events'
@@ -74,13 +74,13 @@ export class ProjectConfigIpc extends EventEmitter {
*/
once(evt: 'ready', listener: () => void): this
once(evt: 'loadConfig:reply', listener: (payload: SerializedLoadConfigReply) => void): this
once(evt: 'loadConfig:error', listener: (err: SerializedError) => void): this
once(evt: 'loadConfig:error', listener: (err: CypressError) => void): this
/**
* When
*/
once(evt: 'setupTestingType:reply', listener: (payload: SetupNodeEventsReply) => void): this
once(evt: 'setupTestingType:error', listener: (error: SerializedError) => void): this
once(evt: 'setupTestingType:error', listener: (error: CypressError) => void): this
once (evt: string, listener: (...args: any[]) => void) {
return super.once(evt, listener)
}

View File

@@ -16,12 +16,11 @@ import debugLib from 'debug'
import pDefer from 'p-defer'
import fs from 'fs'
import { getError, CypressError, ConfigValidationError } from '@packages/errors'
import { getError, CypressError, ConfigValidationFailureInfo } from '@packages/errors'
import type { DataContext } from '..'
import { LoadConfigReply, SetupNodeEventsReply, ProjectConfigIpc, IpcHandler } from './ProjectConfigIpc'
import assert from 'assert'
import type { AllModeOptions, BreakingErrResult, BreakingOption, FoundBrowser, FullConfig, TestingType } from '@packages/types'
import type { BaseErrorDataShape } from '.'
import { autoBindDebug } from '../util/autoBindDebug'
const debug = debugLib(`cypress:lifecycle:ProjectLifecycleManager`)
@@ -48,7 +47,7 @@ type BreakingValidationFn<T> = (type: BreakingOption, val: BreakingErrResult) =>
export interface InjectedConfigApi {
cypressVersion: string
getServerPluginHandlers: () => IpcHandler[]
validateConfig<T extends Cypress.ConfigOptions>(config: Partial<T>, onErr: (errMsg: ConfigValidationError | string) => never): T
validateConfig<T extends Cypress.ConfigOptions>(config: Partial<T>, onErr: (errMsg: ConfigValidationFailureInfo | string) => never): T
allowedConfig(config: Cypress.ConfigOptions): Cypress.ConfigOptions
updateWithPluginValues(config: FullConfig, modifiedConfig: Partial<Cypress.ConfigOptions>): FullConfig
setupFullConfigWithDefaults(config: SetupFullConfigOptions): Promise<FullConfig>
@@ -184,30 +183,6 @@ export class ProjectLifecycleManager {
return null
}
get errorLoadingConfigFile (): BaseErrorDataShape | null {
if (this._configResult.state === 'errored') {
return {
title: 'Error Loading Config',
message: this._configResult.value?.messageMarkdown || '',
stack: this._configResult.value?.stack,
}
}
return null
}
get errorLoadingNodeEvents (): BaseErrorDataShape | null {
if (this._eventsIpcResult.state === 'errored') {
return {
title: 'Error Loading Config',
message: this._eventsIpcResult.value?.messageMarkdown || '',
stack: this._eventsIpcResult.value?.stack,
}
}
return null
}
get isLoadingConfigFile () {
return this._configResult.state === 'loading'
}
@@ -636,18 +611,26 @@ export class ProjectLifecycleManager {
}
if (shouldReloadConfig) {
this.ctx.coreData.baseError = null
this.ctx.update((coreData) => {
coreData.baseError = null
})
this.reloadConfig().catch(this.onLoadError)
}
}).on('error', (err) => {
})
legacyFileWatcher.on('error', (err) => {
debug('error watching config files %O', err)
this.ctx.coreData.baseError = err
this.ctx.onWarning(getError('UNEXPECTED_INTERNAL_ERROR', err))
})
const cypressEnvFileWatcher = this.addWatcher(this.envFilePath)
cypressEnvFileWatcher.on('all', () => {
this.ctx.coreData.baseError = null
this.ctx.update((coreData) => {
coreData.baseError = null
})
this.reloadCypressEnvFile().catch(this.onLoadError)
})
}
@@ -939,7 +922,7 @@ export class ProjectLifecycleManager {
this._registeredEvents[event] = callback
}
resetForTest () {
reinitializeCypress () {
this.resetInternalState()
this._registeredEvents = {}
this._handlers = []
@@ -1328,19 +1311,19 @@ export class ProjectLifecycleManager {
private configFileWarningCheck () {
// Only if they've explicitly specified a config file path do we error, otherwise they'll go through onboarding
if (!this.metaState.hasValidConfigFile && this.metaState.hasSpecifiedConfigViaCLI !== false && this.ctx.isRunMode) {
this.ctx.onError(getError('CONFIG_FILE_NOT_FOUND', path.basename(this.metaState.hasSpecifiedConfigViaCLI), path.dirname(this.metaState.hasSpecifiedConfigViaCLI)))
this.onLoadError(getError('CONFIG_FILE_NOT_FOUND', path.basename(this.metaState.hasSpecifiedConfigViaCLI), path.dirname(this.metaState.hasSpecifiedConfigViaCLI)))
}
if (this.metaState.hasLegacyCypressJson && !this.metaState.hasValidConfigFile && this.ctx.isRunMode) {
this.ctx.onError(getError('CONFIG_FILE_MIGRATION_NEEDED', this.projectRoot))
this.onLoadError(getError('CONFIG_FILE_MIGRATION_NEEDED', this.projectRoot))
}
if (this.metaState.hasMultipleConfigPaths) {
this.ctx.onError(getError('CONFIG_FILES_LANGUAGE_CONFLICT', this.projectRoot, 'cypress.config.js', 'cypress.config.ts'))
this.onLoadError(getError('CONFIG_FILES_LANGUAGE_CONFLICT', this.projectRoot, 'cypress.config.js', 'cypress.config.ts'))
}
if (this.metaState.hasValidConfigFile && this.metaState.hasLegacyCypressJson) {
this.ctx.onError(getError('LEGACY_CONFIG_FILE', path.basename(this.configFilePath), this.projectRoot))
this.onLoadError(getError('LEGACY_CONFIG_FILE', path.basename(this.configFilePath), this.projectRoot))
}
}
@@ -1351,7 +1334,11 @@ export class ProjectLifecycleManager {
* for run mode
*/
private onLoadError = (err: any) => {
this._pendingInitialize?.reject(err)
if (this.ctx.isRunMode && this._pendingInitialize) {
this._pendingInitialize.reject(err)
} else {
this.ctx.onError(err, 'Error Loading Config')
}
}
}

View File

@@ -1,10 +1,11 @@
import type { FoundBrowser, Editor, Warning, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName } from '@packages/types'
import type { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName } from '@packages/types'
import type { Bundler, FRONTEND_FRAMEWORKS } from '@packages/scaffold-config'
import type { NexusGenEnums, NexusGenObjects } from '@packages/graphql/src/gen/nxs.gen'
import type { App, BrowserWindow } from 'electron'
import type { ChildProcess } from 'child_process'
import type { SocketIOServer } from '@packages/socket'
import type { Server } from 'http'
import type { ErrorWrapperSource } from '@packages/errors'
export type Maybe<T> = T | null | undefined
@@ -82,12 +83,6 @@ export interface ElectronShape {
browserWindow: BrowserWindow | null
}
export interface BaseErrorDataShape {
title?: string
message: string
stack?: string
}
export interface AuthStateShape {
name?: AuthStateName
message?: string
@@ -113,7 +108,8 @@ export interface CoreDataShape {
gqlSocketServer?: Maybe<SocketIOServer>
}
hasInitializedMode: 'run' | 'open' | null
baseError: BaseErrorDataShape | null
baseError: ErrorWrapperSource | null
dashboardGraphQLError: ErrorWrapperSource | null
dev: DevStateShape
localSettings: LocalSettingsDataShape
app: AppDataShape
@@ -125,7 +121,7 @@ export interface CoreDataShape {
electron: ElectronShape
authState: AuthStateShape
scaffoldedFiles: NexusGenObjects['ScaffoldedFile'][] | null
warnings: Warning[]
warnings: ErrorWrapperSource[]
packageManager: typeof PACKAGE_MANAGERS[number]
forceReconfigureProject: ForceReconfigureProjectDataShape | null
}
@@ -141,6 +137,7 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
machineBrowsers: null,
hasInitializedMode: null,
baseError: null,
dashboardGraphQLError: null,
dev: {
refreshState: null,
},

View File

@@ -42,8 +42,11 @@ export class BrowserDataSource {
this.ctx.coreData.machineBrowsers = browsers
}
}).catch((e) => {
this.ctx.coreData.machineBrowsers = null
this.ctx.coreData.baseError = e
this.ctx.update((coreData) => {
coreData.machineBrowsers = null
coreData.baseError = e
})
throw e
})
}

View File

@@ -17,6 +17,7 @@ import {
RequestPolicy,
} from '@urql/core'
import _ from 'lodash'
import { getError } from '@packages/errors'
const cloudEnv = getenv('CYPRESS_INTERNAL_CLOUD_ENV', process.env.CYPRESS_INTERNAL_ENV || 'development') as keyof typeof REMOTE_SCHEMA_URLS
@@ -104,13 +105,11 @@ export class CloudDataSource {
}
} else if ((!_.isEqual(resolvedData.data, res.data) || !_.isEqual(resolvedData.error, res.error)) && !res.error?.networkError) {
if (res.error) {
this.ctx.coreData.baseError = {
title: res.error.graphQLErrors?.[0]?.originalError?.name,
message: res.error.message,
stack: res.error.stack,
this.ctx.coreData.dashboardGraphQLError = {
cypressError: getError('DASHBOARD_GRAPHQL_ERROR', res.error),
}
} else {
this.ctx.coreData.baseError = null
this.ctx.coreData.dashboardGraphQLError = null
}
// TODO(tim): send a signal to the frontend so when it refetches it does 'cache-only' request,

View File

@@ -7,6 +7,10 @@ import type { DataContext } from '../DataContext'
export class EnvDataSource {
constructor (private ctx: DataContext) {}
get isProduction () {
return process.env.CYPRESS_INTERNAL_ENV === 'production'
}
get HTTP_PROXY () {
return process.env.HTTPS_PROXY || process.env.HTTP_PROXY
}

View File

@@ -0,0 +1,69 @@
import { ErrorWrapperSource, stackUtils } from '@packages/errors'
import path from 'path'
import _ from 'lodash'
import { codeFrameColumns } from '@babel/code-frame'
import type { DataContext } from '..'
export interface CodeFrameShape {
line: number
column: number
absolute: string
codeBlock: string
}
export class ErrorDataSource {
constructor (private ctx: DataContext) {}
isUserCodeError (source: ErrorWrapperSource) {
return Boolean(source.cypressError.originalError && !source.cypressError.originalError?.isCypressErr)
}
async codeFrame (source: ErrorWrapperSource): Promise<CodeFrameShape | null> {
if (!this.ctx.currentProject || !this.isUserCodeError(source)) {
return null
}
// If we saw a TSError, we will extract the error location from the message
const tsErrorLocation = source.cypressError.originalError?.tsErrorLocation
let line: number | null | undefined
let column: number | null | undefined
let absolute: string | null | undefined
if (tsErrorLocation) {
line = tsErrorLocation.line
column = tsErrorLocation.column
absolute = path.join(this.ctx.currentProject, tsErrorLocation.filePath)
} else {
// Skip any stack trace lines which come from node:internal code
const stackLines = stackUtils.getStackLines(source.cypressError.stack ?? '')
const filteredStackLines = stackLines.filter((stackLine) => !stackLine.includes('node:internal'))
const parsedLine = stackUtils.parseStackLine(filteredStackLines[0] ?? '')
if (parsedLine) {
absolute = parsedLine.absolute
line = parsedLine.line
column = parsedLine.column
}
}
if (!absolute || !_.isNumber(line) || !_.isNumber(column)) {
return null
}
const codeBlock = codeFrameColumns(await this.ctx.file.readFile(absolute), {
start: { line, column },
}, {
linesAbove: 2,
linesBelow: 4,
})
return {
absolute,
line,
column,
codeBlock,
}
}
}

View File

@@ -4,6 +4,7 @@
export * from './BrowserDataSource'
export * from './CloudDataSource'
export * from './EnvDataSource'
export * from './ErrorDataSource'
export * from './FileDataSource'
export * from './GitDataSource'
export * from './GraphQLDataSource'

View File

@@ -329,7 +329,7 @@ export async function cleanUpIntegrationFolder (projectRoot: string) {
} catch (e: any) {
// only throw if the folder exists
if (e.code !== 'ENOENT') {
throw Error(`Failed to remove ${integrationPath}`)
throw e
}
}
}

View File

@@ -4,5 +4,6 @@
export * from './autoRename'
export * from './codegen'
export * from './format'
export * from './parserUtils'
export * from './regexps'
export * from './shouldShowSteps'

View File

@@ -21,12 +21,12 @@ export const urqlCacheKeys: Partial<UrqlCacheKeys> = {
DevState: (data) => data.__typename,
Wizard: (data) => data.__typename,
Migration: (data) => data.__typename,
Warning: () => null,
CloudRunCommitInfo: () => null,
GitInfo: () => null,
MigrationFile: () => null,
MigrationFilePart: () => null,
BaseError: () => null,
ErrorWrapper: () => null,
CodeFrame: () => null,
ProjectPreferences: (data) => data.__typename,
VersionData: () => null,
ScaffoldedFile: () => null,

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap" rel="stylesheet">
<style>
body {
font-family: "Courier Prime", Courier, monospace;
padding: 0 1em;
line-height: 1.4;
color: #eee;
background-color: #111;
}
pre {
padding: 0 0;
margin: 0 0;
font-family: "Courier Prime", Courier, monospace;
}
body {
margin: 5px;
padding: 0;
overflow: hidden;
}
pre {
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body><pre><span style="color:#e05561">We received an unexpected error response from the request to the Cypress dashboard:<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#de73ff">&quot;fail whale&quot;<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span>
</pre></body></html>

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap" rel="stylesheet">
<style>
body {
font-family: "Courier Prime", Courier, monospace;
padding: 0 1em;
line-height: 1.4;
color: #eee;
background-color: #111;
}
pre {
padding: 0 0;
margin: 0 0;
font-family: "Courier Prime", Courier, monospace;
}
body {
margin: 5px;
padding: 0;
overflow: hidden;
}
pre {
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body><pre><span style="color:#e05561">We encountered an unexpected internal error. Please check GitHub or open a new issue <span style="color:#e6e6e6">
<span style="color:#e05561">if you don&#39;t see one already with the details below:<span style="color:#e6e6e6">
<span style="color:#c062de"><span style="color:#e6e6e6">
<span style="color:#c062de">Error: fail whale<span style="color:#e6e6e6">
<span style="color:#c062de"> at makeErr (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6">
<span style="color:#c062de"> at UNEXPECTED_INTERNAL_ERROR (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span></span></span>
</pre></body></html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap" rel="stylesheet">
<style>
body {
font-family: "Courier Prime", Courier, monospace;
padding: 0 1em;
line-height: 1.4;
color: #eee;
background-color: #111;
}
pre {
padding: 0 0;
margin: 0 0;
font-family: "Courier Prime", Courier, monospace;
}
body {
margin: 5px;
padding: 0;
overflow: hidden;
}
pre {
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body><pre><span style="color:#e05561">An unexpected internal error occurred while executing the <span style="color:#e5e510">wizardUpdate<span style="color:#e05561"> operation with payload:<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#de73ff">{}<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#c062de"><span style="color:#e6e6e6">
<span style="color:#c062de">Error: fail whale<span style="color:#e6e6e6">
<span style="color:#c062de"> at makeErr (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6">
<span style="color:#c062de"> at UNEXPECTED_MUTATION_ERROR (cypress/packages/errors/test/unit/visualSnapshotErrors_spec.ts)<span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span>
</pre></body></html>

View File

@@ -1 +1,12 @@
// The errors package is always compiled in a production build, but when we're developing locally,
// there'a chance we can run into a situation where we're requriring the
// @packages/errors from the child process in a non-ts project, and we need to build this JIT.
// Otherwise the error will incorrectly be shown as "cannot find module ./src" instead of
// the actual error. Double check that we can require './src', and if not install ts-node
try {
require.resolve('./src')
} catch (e) {
require('@packages/ts/register')
}
module.exports = require('./src')

View File

@@ -3,7 +3,7 @@ import type { AllCypressErrors } from './errors'
/**
* A config validation result
*/
export interface ConfigValidationError {
export interface ConfigValidationFailureInfo {
key: string
type: string
value: any
@@ -25,13 +25,13 @@ export interface ErrorLike {
/**
* An error originating from the @cypress/errors package,
* includes the `type` of the error, the `originalError`
* if one exists, and an isCypressError for duck-type checking
* if one exists, and an isCypressErr for duck-type checking
*/
export interface CypressError extends ErrorLike {
messageMarkdown: string
type: keyof typeof AllCypressErrors
isCypressErr: boolean
originalError?: CypressError | ErrorLike
originalError?: SerializedError
details?: string
code?: string | number
errno?: string | number
@@ -56,7 +56,7 @@ export interface ClonedError {
[key: string]: any
}
export interface SerializedError {
export interface SerializedError extends Omit<CypressError, 'messageMarkdown' | 'type' | 'isCypressErr'> {
code?: string | number
type?: string | number
errorType?: string
@@ -65,4 +65,19 @@ export interface SerializedError {
message: string
name: string
isCypressErr?: boolean
// If there's a parse error from TSNode, we strip out the first error separately from
// the message body and provide here, since this is is the error we actually want to fix
tsErrorLocation?: {
line: number
column: number
filePath: string
} | null
}
/**
* Used in the GraphQL Error / Warning objects
*/
export interface ErrorWrapperSource {
title?: string | null
cypressError: CypressError
}

View File

@@ -10,7 +10,7 @@ import { humanTime, logError, parseResolvedPattern, pluralize } from './errorUti
import { errPartial, errTemplate, fmt, theme, PartialErr } from './errTemplate'
import { stackWithoutMessage } from './stackUtils'
import type { ClonedError, ConfigValidationError, CypressError, ErrTemplateResult, ErrorLike } from './errorTypes'
import type { ClonedError, ConfigValidationFailureInfo, CypressError, ErrTemplateResult, ErrorLike } from './errorTypes'
const ansi_up = new AU()
@@ -695,7 +695,7 @@ export const AllCypressErrors = {
${fmt.highlight(validationMsg)}`
},
// TODO: make this relative path, not absolute
CONFIG_VALIDATION_ERROR: (fileType: 'configFile' | 'pluginsFile' | null, filePath: string | null, validationResult: ConfigValidationError) => {
CONFIG_VALIDATION_ERROR: (fileType: 'configFile' | 'pluginsFile' | null, filePath: string | null, validationResult: ConfigValidationFailureInfo) => {
const { key, type, value, list } = validationResult
if (!fileType) {
@@ -1282,6 +1282,33 @@ export const AllCypressErrors = {
`
},
UNEXPECTED_MUTATION_ERROR: (mutationField: string, args: any, err: Error) => {
return errTemplate`
An unexpected internal error occurred while executing the ${fmt.highlight(mutationField)} operation with payload:
${fmt.stringify(args)}
${fmt.stackTrace(err)}
`
},
DASHBOARD_GRAPHQL_ERROR: (err: Error) => {
return errTemplate`
We received an unexpected error response from the request to the Cypress dashboard:
${fmt.stringify(err.message)}
`
},
UNEXPECTED_INTERNAL_ERROR: (err: Error) => {
return errTemplate`
We encountered an unexpected internal error. Please check GitHub or open a new issue
if you don't see one already with the details below:
${fmt.stackTrace(err)}
`
},
} as const
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@@ -31,6 +31,20 @@ export const getStackLines = (stack: string) => {
return stackLines
}
/**
* Captures & returns the absolute path, line, and column from a stack trace line
*/
export const parseStackLine = (line: string): null | { absolute: string, line: number, column: number } => {
const stackLineCapture = /^\s*(?:at )?.*@?\((.*?)\:(\d+)\:(\d+)\)?$/
const result = stackLineCapture.exec(line)
if (!result?.[1]) {
return null
}
return { absolute: result[1], line: Number(result[2]), column: Number(result[3]) }
}
/**
* Takes the stack and returns only the lines that contain stack-frame like entries,
* matching the `stackLineRegex` above

View File

@@ -1081,5 +1081,20 @@ describe('visual error templates', () => {
default: ['/path/to/config.ts', {}],
}
},
UNEXPECTED_INTERNAL_ERROR: () => {
return {
default: [makeErr()],
}
},
UNEXPECTED_MUTATION_ERROR: () => {
return {
default: ['wizardUpdate', {}, makeErr()],
}
},
DASHBOARD_GRAPHQL_ERROR: () => {
return {
default: [makeErr()],
}
},
})
})

View File

@@ -143,7 +143,7 @@ async function makeE2ETasks () {
await globalPubSub.emitThen('test:cleanup')
await ctx.actions.app.removeAppDataDir()
await ctx.actions.app.ensureAppDataDirExists()
await ctx.resetForTest()
await ctx.reinitializeCypress()
sinon.reset()
sinon.restore()
remoteGraphQLIntercept = undefined
@@ -239,7 +239,7 @@ async function makeE2ETasks () {
const modeOptions = argUtils.toObject(processedArgv)
// Reset the state of the context
await ctx.resetForTest(modeOptions)
await ctx.reinitializeCypress(modeOptions)
// Handle any pre-loading that should occur based on the launch arg settings
await ctx.initializeMode()
@@ -263,7 +263,7 @@ async function makeE2ETasks () {
const modeOptions = argUtils.toObject(processedArgv)
// Reset the state of the context
await ctx.resetForTest(modeOptions)
await ctx.reinitializeCypress(modeOptions)
// Handle any pre-loading that should occur based on the launch arg settings
await ctx.initializeMode()

View File

@@ -7,11 +7,13 @@ export const e2eProjectDirs = [
'component-tests',
'config-with-custom-file-js',
'config-with-custom-file-ts',
'config-with-import-error',
'config-with-invalid-browser',
'config-with-invalid-viewport',
'config-with-js',
'config-with-short-timeout',
'config-with-ts',
'config-with-ts-syntax-error',
'cookies',
'create-react-app-configured',
'create-react-app-unconfigured',

View File

@@ -345,7 +345,7 @@ function withCtx<T extends Partial<WithCtxOptions>, R> (fn: (ctx: DataContext, o
const { log, timeout, ...rest } = opts
const _log = log === false ? { end () {}, set (key: string, val: any) {} } : Cypress.log({
name: opts.retry ? 'withCtx' : 'withRetryableCtx',
name: opts.retry ? 'withRetryableCtx' : 'withCtx',
message: '(view in console)',
consoleProps () {
return {

View File

@@ -1,9 +1,22 @@
import { defaultMessages } from '@cy/i18n'
import { registerMountFn, addVueCommand } from './common'
import '../../src/styles/shared.scss'
import 'virtual:windi.css'
import 'cypress-real-events/support'
import { installCustomPercyCommand } from '@packages/ui-components/cypress/support/customPercyCommand'
import { addNetworkCommands } from './onlineNetwork'
import { GQLStubRegistry } from './mock-graphql/stubgql-Registry'
declare global {
namespace Cypress {
interface Chainable {
gqlStub: typeof GQLStubRegistry
}
}
}
cy.i18n = defaultMessages
cy.gqlStub = GQLStubRegistry
Cypress.on('uncaught:exception', (err) => !err.message.includes('ResizeObserver loop limit exceeded'))

View File

@@ -6,12 +6,12 @@ import { executeExchange } from '@urql/exchange-execute'
import { makeCacheExchange } from '@packages/frontend-shared/src/graphql/urqlClient'
import { clientTestSchema } from './clientTestSchema'
import type { ClientTestContext } from './clientTestContext'
import type { FieldNode, GraphQLFieldResolver, GraphQLResolveInfo, GraphQLTypeResolver } from 'graphql'
import type { GraphQLFieldResolver, GraphQLResolveInfo, GraphQLTypeResolver, FieldNode } from 'graphql'
import { defaultTypeResolver, introspectionFromSchema, isNonNullType } from 'graphql'
import type { CodegenTypeMap } from '../generated/test-graphql-types.gen'
import { GQLStubRegistry } from './stubgql-Registry'
import { pathToArray } from 'graphql/jsutils/Path'
import dedent from 'dedent'
import { GQLStubRegistry } from './stubgql-Registry'
export function testUrqlClient (context: ClientTestContext, onResult?: (result: any, context: ClientTestContext) => any): Client {
return createClient({

View File

@@ -143,8 +143,16 @@ type ResultType<T> = T extends TypedDocumentNode<infer U, any> ? U : never
type MountFragmentConfig<T extends TypedDocumentNode> = {
variables?: T['__variablesType']
render: (frag: Exclude<T['__resultType'], undefined>) => JSX.Element
/**
* When we are mounting a GraphQL Fragment, we can use `onResult`
* to intercept the result and modify the contents on the fragment
* before rendering the component
*/
onResult?: (result: ResultType<T>, ctx: ClientTestContext) => ResultType<T> | void
/**
* Render is passed the result of the "frag" and mounts the component under test
*/
render: (frag: Exclude<T['__resultType'], undefined>) => JSX.Element
expectError?: boolean
} & CyMountOptions<unknown>

View File

@@ -0,0 +1,49 @@
import dedent from 'dedent'
import type { ErrorWrapper } from '../generated/test-graphql-types.gen'
import type { MaybeResolver } from './clientTestUtils'
export const StubErrorWrapper = {
__typename: 'ErrorWrapper',
title: 'Error Loading Config',
errorMessage: dedent`
Your \`supportFile\` is set to \`foo.bar.js\`, but either the file is missing or it's invalid. The \`supportFile\` must be a \`.js\`, \`.ts\`, \`.coffee\` file or be supported by your preprocessor plugin (if configured).
Correct your \`foo.bar.js\`, create the appropriate file, or set \`supportFile\` to \`false\` if a support file is not necessary for your project.
Or you might have renamed the extension of your \`supportFile\` to \`.ts\`. If that's the case, restart the test runner.
Learn more at https://on.cypress.io/support-file-missing-or-invalid
`,
isUserCodeError: true,
errorType: 'SUPPORT_FILE_NOT_FOUND',
errorStack: dedent`
OriginalError: foobar
at module.exports (/Users/bmann/Dev/cypress-playground/v9.0.0/cypress/plugins/index.js:22:9)
at /Users/bmann/Library/Caches/Cypress/9.1.1/Cypress.app/Contents/Resources/app/packages/server/lib/plugins/child/run_plugins.js:90:12
at tryCatcher (/Users/bmann/Library/Caches/Cypress/9.1.1/Cypress.app/Contents/Resources/app/packages/server/node_modules/bluebird/js/release/util.js:16:23)
at Function.Promise.attempt.Promise.try (/Users/bmann/Library/Caches/Cypress/9.1.1/Cypress.app/Contents/Resources/app/packages/server/node_modules/bluebird/js/release/method.js:39:29)
at load (/Users/bmann/Library/Caches/Cypress/9.1.1/Cypress.app/Contents/Resources/app/packages/server/lib/plugins/child/run_plugins.js:87:7)
at EventEmitter.<anonymous> (/Users/bmann/Library/Caches/Cypress/9.1.1/Cypress.app/Contents/Resources/app/packages/server/lib/plugins/child/run_plugins.js:198:5)
at EventEmitter.emit (events.js:314:20)
at process.<anonymous> (/Users/bmann/Library/Caches/Cypress/9.1.1/Cypress.app/Contents/Resources/app/packages/server/lib/plugins/util.js:19:22)
at process.emit (events.js:314:20)
at emit (internal/child_process.js:877:12)
at processTicksAndRejections (internal/process/task_queues.js:85:21)
`,
errorName: 'OriginalError',
fileToOpen: {
__typename: 'FileParts',
id: `FilePath:pathtoFoobarjs`,
absolute: '/path/to/foo.bar.js',
relative: './foo.bar.js',
fileName: 'foo',
contents: 'const x = 1',
name: 'foo.bar.js',
fileExtension: '.js',
baseName: 'foo.bar.js',
},
} as const
// For type checking
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _typeCheck: MaybeResolver<ErrorWrapper> = StubErrorWrapper

View File

@@ -53,6 +53,7 @@ export const createTestCurrentProject = (title: string, currentProject: Partial<
isDefaultSpecPattern: true,
browserStatus: 'closed',
packageManager: 'yarn',
configFileAbsolutePath: '/path/to/cypress.config.js',
...currentProject,
}
}

View File

@@ -6,6 +6,7 @@ import { stubGlobalProject, stubProject } from './stubgql-Project'
import { CloudOrganizationStubs, CloudProjectStubs, CloudRecordKeyStubs, CloudRunStubs, CloudUserStubs } from './stubgql-CloudTypes'
import { stubMigration } from './stubgql-Migration'
import type { CodegenTypeMap } from '../generated/test-graphql-types.gen'
import { StubErrorWrapper } from './stubgql-ErrorWrapper'
type MaybeResolveMap = {[K in keyof CodegenTypeMap]: MaybeResolver<CodegenTypeMap[K]>}
@@ -22,8 +23,9 @@ export const GQLStubRegistry = {
CloudRun: CloudRunStubs.allPassing,
CloudRecordKey: CloudRecordKeyStubs.componentProject,
CloudUser: CloudUserStubs.me,
ErrorWrapper: StubErrorWrapper,
} as const
// Line below added so we can refer to the above as a const value, but ensure it fits the type contract
// For Type checking
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _typeCheck: Partial<MaybeResolveMap> = GQLStubRegistry
const _x: Partial<MaybeResolveMap> = GQLStubRegistry

View File

@@ -79,4 +79,10 @@ describe('<ShikiHighlight/>', { viewportWidth: 800, viewportHeight: 500 }, () =>
cy.get('.shiki').should('be.visible')
cy.percySnapshot()
})
it('show line numbers with initial line when the prop is passed', () => {
cy.mount(() => <div class="p-12"><ShikiHighlight code={code} lang="ts" lineNumbers initialLine={10} /></div>)
cy.get('.shiki').should('be.visible')
cy.percySnapshot()
})
})

View File

@@ -46,7 +46,8 @@ shikiWrapperClasses computed property.
'inline': props.inline,
'wrap': props.wrap,
'line-numbers': props.lineNumbers,
'p-8px': !props.lineNumbers && !props.inline,
'p-8px': !props.lineNumbers && !props.inline && !props.codeframe,
'p-2px': props.codeframe,
},
props.class,
@@ -118,12 +119,14 @@ onBeforeMount(async () => {
const props = withDefaults(defineProps<{
code: string
initialLine?: number
lang: CyLangType | undefined
lineNumbers?: boolean
inline?: boolean
wrap?: boolean
copyOnClick?: boolean
copyButton?: boolean
codeframe?: boolean
skipTrim?: boolean
class?: string | string[] | Record<string, any>
}>(), {
@@ -131,6 +134,8 @@ const props = withDefaults(defineProps<{
inline: false,
wrap: false,
copyOnClick: false,
codeframe: false,
initialLine: 1,
copyButton: false,
skipTrim: false,
class: undefined,
@@ -196,7 +201,7 @@ $offset: 1.1em;
@apply py-8px;
code {
counter-reset: step;
counter-increment: step 0;
counter-increment: step calc(v-bind('props.initialLine') - 1);
// Keep bg-gray-50 synced with the box-shadows.
.line::before, .line:first-child::before {

View File

@@ -4,10 +4,11 @@
* We could eventually use Shiki as a Markdown plugin, but I don't want to get into it right now.
*/
import type { Ref } from 'vue'
import { computed } from 'vue'
import { computed, unref } from 'vue'
import MarkdownIt from 'markdown-it'
import MarkdownItClass from '@toycode/markdown-it-class'
import { useEventListener, whenever } from '@vueuse/core'
import type { MaybeRef } from '@vueuse/core'
import { useExternalLink } from '../gql-components/useExternalLink'
import { mapValues, isArray, flatten } from 'lodash'
@@ -38,9 +39,9 @@ const defaultClasses = {
h4: ['font-medium', 'text-1xl', 'mb-3'],
h5: ['font-medium', 'text-sm', 'mb-3'],
h6: ['font-medium', 'text-xs', 'mb-3'],
p: ['my-3 first:mt-0 text-sm'],
pre: ['rounded p-3'],
code: [`font-medium rounded text-sm px-4px py-2px`],
p: ['my-3 first:mt-0 text-sm mb-4'],
pre: ['rounded p-3 bg-white mb-2'],
code: [`font-medium rounded text-sm px-4px py-2px bg-red-100`],
a: ['text-blue-500', 'hover:underline text-sm'],
ul: ['list-disc pl-6 my-3 text-sm'],
ol: ['list-decimal pl-6 my-3 text-sm'],
@@ -81,7 +82,7 @@ const buildClasses = (options) => {
return _classes
}
export const useMarkdown = (target: Ref<HTMLElement>, text: string, options: UseMarkdownOptions = {}) => {
export const useMarkdown = (target: Ref<HTMLElement>, text: MaybeRef<string>, options: UseMarkdownOptions = {}) => {
options.openExternal = options.openExternal || true
const classes = buildClasses(options)
@@ -112,6 +113,6 @@ export const useMarkdown = (target: Ref<HTMLElement>, text: string, options: Use
}
return {
markdown: computed(() => md.render(text, { sanitize: true })),
markdown: computed(() => md.render(unref(text), { sanitize: true })),
}
}

View File

@@ -64,9 +64,11 @@ import {
Auth_LoginDocument,
Auth_LogoutDocument,
Auth_ResetAuthStateDocument,
AuthFragment,
Auth_BrowserOpenedDocument,
} from '../generated/graphql'
import type {
AuthFragment,
} from '../generated/graphql'
import Button from '@cy/components/Button.vue'
import { useI18n } from '@cy/i18n'

View File

@@ -1,90 +1,39 @@
<template>
<template v-if="query?.data?.value">
<button
data-cy="open-config-file"
class="hocus-link-default underline-purple-500"
@click="showCypressConfigInIDE()"
>
<slot>
<OpenFileInIDE
v-if="props.gql.configFileAbsolutePath"
v-slot="{onClick}"
:file-path="props.gql.configFileAbsolutePath"
>
<slot :on-click="onClick">
<button
data-testid="open-config-file"
class="hocus-link-default underline-purple-500"
@click="onClick"
>
<span
class="cursor-pointer text-purple-500"
class="text-purple-500 cursor-pointer"
>
{{ configFile }}
{{ props.gql.configFile ?? 'cypress.config.js' }}
</span>
</slot>
</button>
<ChooseExternalEditorModal
v-if="isChooseEditorOpen"
:open="isChooseEditorOpen"
:gql="query.data?.value"
@close="isChooseEditorOpen = false"
@selected="openFile"
/>
</template>
<div v-else />
</button>
</slot>
</OpenFileInIDE>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { gql, useMutation, useQuery } from '@urql/vue'
import { OpenConfigFileDocument, OpenConfigFileInIdeDocument } from '../generated/graphql'
import ChooseExternalEditorModal from './ChooseExternalEditorModal.vue'
import { gql } from '@urql/core'
import OpenFileInIDE from './OpenFileInIDE.vue'
import type { OpenConfigFileInIdeFragment } from '../generated/graphql'
gql`
query OpenConfigFileInIDE {
currentProject {
id
configFile
configFileAbsolutePath
}
localSettings {
preferences {
preferredEditorBinary
}
}
...ChooseExternalEditorModal
fragment OpenConfigFileInIDE on CurrentProject {
id
configFile
configFileAbsolutePath
}
`
gql`
mutation OpenConfigFile ($input: FileDetailsInput!) {
openFileInIDE (input: $input)
}
`
const query = useQuery({ query: OpenConfigFileInIdeDocument, requestPolicy: 'network-only' })
const configFile = computed(() => query.data?.value?.currentProject?.configFile ?? 'cypress.config.js')
const OpenConfigFileInIDE = useMutation(OpenConfigFileDocument)
const openConfigFileInIDE = (absolute: string) => {
OpenConfigFileInIDE.executeMutation({
input: {
absolute,
line: 1,
column: 1,
},
})
}
const openFile = () => {
isChooseEditorOpen.value = false
if (query.data?.value?.currentProject?.configFileAbsolutePath) {
openConfigFileInIDE(query.data?.value?.currentProject.configFileAbsolutePath)
}
}
const showCypressConfigInIDE = () => {
if (query.data?.value?.localSettings.preferences.preferredEditorBinary && query.data?.value?.currentProject?.configFileAbsolutePath) {
openConfigFileInIDE(query.data?.value?.currentProject.configFileAbsolutePath)
} else {
isChooseEditorOpen.value = true
}
}
const isChooseEditorOpen = ref(false)
const props = defineProps<{
gql: OpenConfigFileInIdeFragment
}>()
</script>

View File

@@ -0,0 +1,81 @@
<template>
<slot :on-click="maybeShowFileInIDE" />
<ChooseExternalEditorModal
v-if="isChooseEditorOpen && query.data.value"
:open="isChooseEditorOpen"
:gql="query.data.value"
@close="isChooseEditorOpen = false"
@selected="openFile"
/>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { gql, useMutation, useQuery } from '@urql/vue'
import { OpenFileInIdeDocument, OpenFileInIde_MutationDocument } from '../generated/graphql'
import ChooseExternalEditorModal from './ChooseExternalEditorModal.vue'
gql`
query OpenFileInIDE {
localSettings {
preferences {
preferredEditorBinary
}
}
...ChooseExternalEditorModal
}
`
gql`
mutation OpenFileInIDE_Mutation ($input: FileDetailsInput!) {
openFileInIDE (input: $input)
}
`
const props = defineProps<{
line?: number
column?: number
filePath: string
}>()
const query = useQuery({ query: OpenFileInIdeDocument, requestPolicy: 'network-only' })
const OpenFileInIDE = useMutation(OpenFileInIde_MutationDocument)
const openFileInIDE = () => {
OpenFileInIDE.executeMutation({
input: {
filePath: props.filePath,
line: props.line ?? 1,
column: props.column ?? 1,
},
})
}
const openFile = () => {
isChooseEditorOpen.value = false
openFileInIDE()
}
const maybeShowFileInIDE = () => {
// If we haven't fetched the data yet checking for the local binary,
// wait until we have it before possibly prompting
if (query.fetching.value) {
query.then(() => {
maybeShowFileInIDE()
})
return
}
if (query.data?.value?.localSettings.preferences.preferredEditorBinary) {
openFileInIDE()
} else {
isChooseEditorOpen.value = true
}
}
const isChooseEditorOpen = ref(false)
</script>

View File

@@ -54,7 +54,8 @@
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import { useI18n } from '@cy/i18n'
import { gql, useMutation } from '@urql/vue'
import { TestingTypeSelectionAndReconfigureDocument, TestingTypeEnum } from '../generated/graphql'
import { TestingTypeSelectionAndReconfigureDocument } from '../generated/graphql'
import type { TestingTypeEnum } from '../generated/graphql'
import { computed } from 'vue'
const props = defineProps<{

View File

@@ -196,9 +196,11 @@ import TopNavList from './TopNavList.vue'
import PromptContent from './PromptContent.vue'
import { allBrowsersIcons } from '@packages/frontend-shared/src/assets/browserLogos'
import { gql, useMutation } from '@urql/vue'
import { TopNavFragment, TopNav_SetPromptShownDocument } from '../../generated/graphql'
import { TopNav_SetPromptShownDocument } from '../../generated/graphql'
import type { TopNavFragment } from '../../generated/graphql'
import { useI18n } from '@cy/i18n'
import { computed, ref, ComponentPublicInstance, watch, watchEffect } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { onClickOutside, onKeyStroke, useTimeAgo } from '@vueuse/core'
import type { DocsMenuVariant } from './DocsMenuContent.vue'
import DocsMenuContent from './DocsMenuContent.vue'

View File

@@ -74,7 +74,8 @@
</template>
<script setup lang="ts">
import { useI18n } from '@cy/i18n'
import { VerticalBrowserListItemsFragment, VerticalBrowserListItems_SetBrowserDocument } from '../../generated/graphql'
import { VerticalBrowserListItems_SetBrowserDocument } from '../../generated/graphql'
import type { VerticalBrowserListItemsFragment } from '../../generated/graphql'
import { computed } from 'vue'
import { gql, useMutation } from '@urql/vue'
import { allBrowsersIcons } from '@packages/frontend-shared/src/assets/browserLogos'

View File

@@ -20,13 +20,6 @@ enum AuthStateNameEnum {
AUTH_ERROR_DURING_LOGIN
}
"""Base error"""
type BaseError {
message: String
stack: String
title: String
}
"""Container representing a browser"""
type Browser implements Node {
channel: String!
@@ -324,6 +317,21 @@ type CloudUser implements Node {
userIsViewer: Boolean!
}
"""
A code frame to display for a file, used when displaying code related to errors
"""
type CodeFrame {
"""Source of the code frame to display"""
codeBlock: String
"""The column of the error to display"""
column: Int
file: FileParts!
"""The line number of the code snippet to display"""
line: Int
}
"""Glob patterns for detecting files for code gen."""
type CodeGenGlobs implements Node {
component: String!
@@ -380,14 +388,6 @@ type CurrentProject implements Node & ProjectLike {
"""The mode the interactive runner was launched in"""
currentTestingType: TestingTypeEnum
"""If there is an error loading the config file, it is represented here"""
errorLoadingConfigFile: BaseError
"""
If there is an error related to the node events, it is represented here
"""
errorLoadingNodeEvents: BaseError
"""Whether the project has Typescript"""
hasTypescript: Boolean
@@ -470,9 +470,158 @@ type Editor {
name: String!
}
enum ErrorTypeEnum {
AUTOMATION_SERVER_DISCONNECTED
BAD_POLICY_WARNING
BAD_POLICY_WARNING_TOOLTIP
BROWSER_NOT_FOUND_BY_NAME
BROWSER_NOT_FOUND_BY_PATH
BUNDLE_ERROR
CANNOT_CONNECT_BASE_URL
CANNOT_CONNECT_BASE_URL_RETRYING
CANNOT_CONNECT_BASE_URL_WARNING
CANNOT_CREATE_PROJECT_TOKEN
CANNOT_FETCH_PROJECT_TOKEN
CANNOT_RECORD_NO_PROJECT_ID
CANNOT_REMOVE_OLD_BROWSER_PROFILES
CANNOT_TRASH_ASSETS
CDP_COULD_NOT_CONNECT
CDP_COULD_NOT_RECONNECT
CDP_RETRYING_CONNECTION
CDP_VERSION_TOO_OLD
CHROME_WEB_SECURITY_NOT_SUPPORTED
CONFIG_FILES_LANGUAGE_CONFLICT
CONFIG_FILE_DEV_SERVER_IS_NOT_A_FUNCTION
CONFIG_FILE_INVALID_DEV_START_EVENT
CONFIG_FILE_INVALID_ROOT_CONFIG
CONFIG_FILE_INVALID_ROOT_CONFIG_E2E
CONFIG_FILE_INVALID_TESTING_TYPE_CONFIG_COMPONENT
CONFIG_FILE_MIGRATION_NEEDED
CONFIG_FILE_NOT_FOUND
CONFIG_FILE_REQUIRE_ERROR
CONFIG_FILE_SETUP_NODE_EVENTS_ERROR
CONFIG_FILE_UNEXPECTED_ERROR
CONFIG_VALIDATION_ERROR
CONFIG_VALIDATION_MSG_ERROR
COULD_NOT_FIND_SYSTEM_NODE
COULD_NOT_PARSE_ARGUMENTS
DASHBOARD_ALREADY_COMPLETE
DASHBOARD_API_RESPONSE_FAILED_RETRYING
DASHBOARD_CANCEL_SKIPPED_SPEC
DASHBOARD_CANNOT_CREATE_RUN_OR_INSTANCE
DASHBOARD_CANNOT_PROCEED_IN_PARALLEL
DASHBOARD_CANNOT_PROCEED_IN_SERIAL
DASHBOARD_CANNOT_UPLOAD_RESULTS
DASHBOARD_GRAPHQL_ERROR
DASHBOARD_INVALID_RUN_REQUEST
DASHBOARD_PARALLEL_DISALLOWED
DASHBOARD_PARALLEL_GROUP_PARAMS_MISMATCH
DASHBOARD_PARALLEL_REQUIRED
DASHBOARD_PROJECT_NOT_FOUND
DASHBOARD_RECORD_KEY_NOT_VALID
DASHBOARD_RUN_GROUP_NAME_NOT_UNIQUE
DASHBOARD_STALE_RUN
DASHBOARD_UNKNOWN_CREATE_RUN_WARNING
DASHBOARD_UNKNOWN_INVALID_REQUEST
DEPRECATED_BEFORE_BROWSER_LAUNCH_ARGS
DUPLICATE_TASK_KEY
ERROR_READING_FILE
ERROR_WRITING_FILE
EXPERIMENTAL_COMPONENT_TESTING_REMOVED
EXPERIMENTAL_NETWORK_STUBBING_REMOVED
EXPERIMENTAL_RUN_EVENTS_REMOVED
EXPERIMENTAL_SAMESITE_REMOVED
EXPERIMENTAL_SHADOW_DOM_REMOVED
EXTENSION_NOT_LOADED
FIREFOX_COULD_NOT_CONNECT
FIREFOX_GC_INTERVAL_REMOVED
FIREFOX_MARIONETTE_FAILURE
FIXTURE_NOT_FOUND
FOLDER_NOT_WRITABLE
FREE_PLAN_EXCEEDS_MONTHLY_PRIVATE_TESTS
FREE_PLAN_EXCEEDS_MONTHLY_TESTS
FREE_PLAN_IN_GRACE_PERIOD_EXCEEDS_MONTHLY_PRIVATE_TESTS
FREE_PLAN_IN_GRACE_PERIOD_EXCEEDS_MONTHLY_TESTS
FREE_PLAN_IN_GRACE_PERIOD_PARALLEL_FEATURE
INCOMPATIBLE_PLUGIN_RETRIES
INCORRECT_CI_BUILD_ID_USAGE
INDETERMINATE_CI_BUILD_ID
INVALID_CONFIG_OPTION
INVALID_CYPRESS_INTERNAL_ENV
INVALID_REPORTER_NAME
INVOKED_BINARY_OUTSIDE_NPM_MODULE
LEGACY_CONFIG_FILE
MULTIPLE_SUPPORT_FILES_FOUND
NODE_VERSION_DEPRECATION_BUNDLED
NODE_VERSION_DEPRECATION_SYSTEM
NOT_LOGGED_IN
NO_DEFAULT_CONFIG_FILE_FOUND
NO_PROJECT_FOUND_AT_PROJECT_ROOT
NO_PROJECT_ID
NO_SPECS_FOUND
PAID_PLAN_EXCEEDS_MONTHLY_PRIVATE_TESTS
PARALLEL_FEATURE_NOT_AVAILABLE_IN_PLAN
PLAN_EXCEEDS_MONTHLY_TESTS
PLAN_IN_GRACE_PERIOD_RUN_GROUPING_FEATURE_USED
PLUGINS_INVALID_EVENT_NAME_ERROR
PLUGINS_RUN_EVENT_ERROR
PORT_IN_USE_LONG
PORT_IN_USE_SHORT
PROJECT_ID_AND_KEY_BUT_MISSING_RECORD_OPTION
RECORDING_FROM_FORK_PR
RECORD_KEY_MISSING
RECORD_PARAMS_WITHOUT_RECORDING
RENAMED_CONFIG_OPTION
RENDERER_CRASHED
RUN_GROUPING_FEATURE_NOT_AVAILABLE_IN_PLAN
SETUP_NODE_EVENTS_DO_NOT_SUPPORT_DEV_SERVER
SETUP_NODE_EVENTS_IS_NOT_FUNCTION
SUPPORT_FILE_NOT_FOUND
TESTS_DID_NOT_START_FAILED
TESTS_DID_NOT_START_RETRYING
UNEXPECTED_BEFORE_BROWSER_LAUNCH_PROPERTIES
UNEXPECTED_INTERNAL_ERROR
UNEXPECTED_MUTATION_ERROR
UNSUPPORTED_BROWSER_VERSION
VIDEO_POST_PROCESSING_FAILED
VIDEO_RECORDING_FAILED
}
"""Base error"""
type ErrorWrapper {
"""The code frame to display in relation to the error"""
codeFrame: CodeFrame
"""The markdown formatted content associated with the ErrorTypeEnum"""
errorMessage: String!
"""Name of the error class"""
errorName: String!
"""
The error stack of either the original error from the user or from where the internal Cypress error was created
"""
errorStack: String!
errorType: ErrorTypeEnum!
"""
Whether the error came from user code, can be used to determine whether to open a stack trace by default
"""
isUserCodeError: Boolean!
"""
Optional title of the error. Used to optionally display a title above the error
"""
title: String
}
input FileDetailsInput {
absolute: String!
column: Int
"""
When we open a file we take a filePath, either relative to the project root, or absolute on disk
"""
filePath: String!
line: Int
}
@@ -486,6 +635,11 @@ type FileParts implements Node {
"""Full name of the file (e.g. MySpec.test.tsx)"""
baseName: String!
"""
If provided, used to specify the column of the file to open in openFileInIDE
"""
column: Int
"""The contents of the file"""
contents: String!
@@ -498,6 +652,11 @@ type FileParts implements Node {
"""Relay style Node ID field for the FileParts field"""
id: ID!
"""
If provided, used to specify the line of the file to open in openFileInIDE
"""
line: Int
"""Full name of spec file (e.g. MySpec.test.tsx)"""
name: String!
@@ -750,6 +909,9 @@ type Mutation {
"""
devRelaunch(action: DevRelaunchAction!): Boolean
"""Dismisses a warning displayed by the frontend"""
dismissWarning: Query
"""user has finished migration component specs - move to next step"""
finishedRenamingComponentSpecs: Query
@@ -827,6 +989,9 @@ type Mutation {
"""show the launchpad windows"""
reconfigureProject: Boolean!
"""Re-initializes Cypress from the initial CLI options"""
reinitializeCypress: Query
"""Remove project from projects array and cache"""
removeProject(path: String!): Query
@@ -938,7 +1103,7 @@ type ProjectPreferences {
type Query {
"""The latest state of the auth process"""
authState: AuthState!
baseError: BaseError
baseError: ErrorWrapper
"""Returns an object conforming to the Relay spec"""
cloudNode(
@@ -986,7 +1151,7 @@ type Query {
versions: VersionData
"""A list of warnings"""
warnings: [Warning!]!
warnings: [ErrorWrapper!]!
"""Metadata about the wizard"""
wizard: Wizard!
@@ -1103,13 +1268,6 @@ type VersionData {
latest: Version!
}
"""A generic warning"""
type Warning {
details: String
message: String!
title: String!
}
"""
The Wizard is a container for any state associated with initial onboarding to Cypress
"""

View File

@@ -3,5 +3,3 @@ export { graphqlSchema } from './schema'
export { execute, parse, print } from 'graphql'
export { remoteSchemaWrapped } from './stitching/remoteSchemaWrapped'
//

View File

@@ -2,5 +2,6 @@
// created by autobarrel, do not modify directly
export * from './nexusDebugFieldPlugin'
export * from './nexusMutationErrorPlugin'
export * from './nexusNodePlugin'
export * from './nexusSlowGuardPlugin'

View File

@@ -0,0 +1,35 @@
import { plugin } from 'nexus'
import { getError } from '@packages/errors'
import type { DataContext } from '@packages/data-context'
import { getNamedType, isObjectType } from 'graphql'
export const mutationErrorPlugin = plugin({
name: 'MutationErrorPlugin',
description: 'Wraps any mutation fields and handles any uncaught errors',
onCreateFieldResolver: (def) => {
if (def.parentTypeConfig.name !== 'Mutation') {
return
}
return (source, args, ctx: DataContext, info, next) => {
return plugin.completeValue(next(source, args, ctx, info), (v) => v, (err) => {
ctx.update((d) => {
d.baseError = {
cypressError: err.isCypressErr
? err
: getError('UNEXPECTED_MUTATION_ERROR', def.fieldConfig.name, args, err),
}
})
const returnType = getNamedType(info.returnType)
// If we're returning a query, we're getting the "baseError" here anyway
if (isObjectType(returnType) && returnType.name === 'Query') {
return {}
}
throw err
})
}
},
})

View File

@@ -4,7 +4,7 @@ import { makeSchema, connectionPlugin } from 'nexus'
import * as schemaTypes from './schemaTypes/'
import { nodePlugin } from './plugins/nexusNodePlugin'
import { remoteSchemaWrapped } from './stitching/remoteSchemaWrapped'
import { nexusDebugLogPlugin, nexusSlowGuardPlugin } from './plugins'
import { mutationErrorPlugin, nexusDebugLogPlugin, nexusSlowGuardPlugin } from './plugins'
const isCodegen = Boolean(process.env.CYPRESS_INTERNAL_NEXUS_CODEGEN)
@@ -32,6 +32,7 @@ export const graphqlSchema = makeSchema({
plugins: [
nexusSlowGuardPlugin,
nexusDebugLogPlugin,
mutationErrorPlugin,
connectionPlugin({
nonNullDefaults: {
output: true,

View File

@@ -0,0 +1,7 @@
import { enumType } from 'nexus'
import { AllCypressErrors } from '@packages/errors'
export const ErrorTypeEnum = enumType({
name: 'ErrorTypeEnum',
members: Object.keys(AllCypressErrors),
})

View File

@@ -4,6 +4,7 @@
export * from './gql-BrowserFamilyEnum'
export * from './gql-BrowserStatus'
export * from './gql-CodeGenTypeEnum'
export * from './gql-ErrorTypeEnum'
export * from './gql-ProjectEnums'
export * from './gql-SpecEnum'
export * from './gql-WizardEnums'

View File

@@ -3,7 +3,10 @@ import { inputObjectType } from 'nexus'
export const FileDetailsInput = inputObjectType({
name: 'FileDetailsInput',
definition (t) {
t.nonNull.string('absolute')
t.nonNull.string('filePath', {
description: 'When we open a file we take a filePath, either relative to the project root, or absolute on disk',
})
t.int('column')
t.int('line')
},

View File

@@ -1,21 +0,0 @@
import { objectType } from 'nexus'
export interface BaseErrorSource {
title?: string
message: string
stack?: string
}
export const BaseError = objectType({
name: 'BaseError',
description: 'Base error',
definition (t) {
t.string('title')
t.string('message')
t.string('stack')
},
sourceType: {
module: __filename,
export: 'BaseErrorSource',
},
})

View File

@@ -0,0 +1,31 @@
import { objectType } from 'nexus'
import { FileParts } from './gql-FileParts'
export const CodeFrame = objectType({
name: 'CodeFrame',
description: 'A code frame to display for a file, used when displaying code related to errors',
definition (t) {
t.int('line', {
description: 'The line number of the code snippet to display',
})
t.int('column', {
description: 'The column of the error to display',
})
t.string('codeBlock', {
description: 'Source of the code frame to display',
})
t.nonNull.field('file', {
type: FileParts,
resolve (source, args, ctx) {
return { absolute: source.absolute }
},
})
},
sourceType: {
module: '@packages/data-context/src/sources/ErrorDataSource',
export: 'CodeFrameShape',
},
})

View File

@@ -1,7 +1,6 @@
import { PACKAGE_MANAGERS } from '@packages/types'
import { enumType, nonNull, objectType, stringArg } from 'nexus'
import path from 'path'
import { BaseError } from '.'
import { BrowserStatusEnum } from '..'
import { cloudProjectBySlug } from '../../stitching/remoteGraphQLCalls'
import { TestingTypeEnum } from '../enumTypes/gql-WizardEnums'
@@ -29,16 +28,6 @@ export const CurrentProject = objectType({
resolve: (source, args, ctx) => ctx.coreData.packageManager,
})
t.field('errorLoadingConfigFile', {
type: BaseError,
description: 'If there is an error loading the config file, it is represented here',
})
t.field('errorLoadingNodeEvents', {
type: BaseError,
description: 'If there is an error related to the node events, it is represented here',
})
t.boolean('isLoadingConfigFile', {
description: 'Whether we are currently loading the configFile',
})

View File

@@ -0,0 +1,58 @@
import { stripAnsi } from '@packages/errors'
import { objectType } from 'nexus'
import { ErrorTypeEnum } from '../enumTypes/gql-ErrorTypeEnum'
import { CodeFrame } from './gql-CodeFrame'
export const ErrorWrapper = objectType({
name: 'ErrorWrapper',
description: 'Base error',
definition (t) {
t.string('title', {
description: 'Optional title of the error. Used to optionally display a title above the error',
})
t.nonNull.string('errorName', {
description: 'Name of the error class',
resolve (source) {
return source.cypressError.originalError?.name || source.cypressError.name
},
})
t.nonNull.string('errorStack', {
description: 'The error stack of either the original error from the user or from where the internal Cypress error was created',
resolve (source) {
return stripAnsi(source.cypressError.stack || '')
},
})
t.nonNull.field('errorType', {
type: ErrorTypeEnum,
resolve: (source) => source.cypressError.type ?? 'UNEXPECTED_INTERNAL_ERROR',
})
t.nonNull.string('errorMessage', {
description: 'The markdown formatted content associated with the ErrorTypeEnum',
resolve (source) {
return source.cypressError.messageMarkdown
},
})
t.nonNull.boolean('isUserCodeError', {
description: 'Whether the error came from user code, can be used to determine whether to open a stack trace by default',
resolve (source, args, ctx) {
return ctx.error.isUserCodeError(source)
},
})
t.field('codeFrame', {
type: CodeFrame,
description: 'The code frame to display in relation to the error',
resolve: (source, args, ctx) => ctx.error.codeFrame(source),
})
},
sourceType: {
module: '@packages/errors',
export: 'ErrorWrapperSource',
},
})

View File

@@ -2,8 +2,11 @@ import { objectType } from 'nexus'
import path from 'path'
export interface FilePartsShape {
line?: number
column?: number
absolute: string
// For when we're merging the file
// For when we're merging / creating the file and might not have the file contents on-disk yet
// used in the scaffolding
contents?: string
}
@@ -57,6 +60,14 @@ export const FileParts = objectType({
return root.contents || ctx.file.readFile(root.absolute)
},
})
t.int('line', {
description: 'If provided, used to specify the line of the file to open in openFileInIDE',
})
t.int('column', {
description: 'If provided, used to specify the column of the file to open in openFileInIDE',
})
},
sourceType: {
module: __filename,

View File

@@ -11,6 +11,17 @@ import { ScaffoldedFile } from './gql-ScaffoldedFile'
export const mutation = mutationType({
definition (t) {
t.field('reinitializeCypress', {
type: 'Query',
description: 'Re-initializes Cypress from the initial CLI options',
resolve: async (_, args, ctx) => {
await ctx.reinitializeCypress(ctx.modeOptions)
await ctx.initializeMode()
return {}
},
})
t.field('devRelaunch', {
type: 'Boolean',
description: 'Development only: Triggers or dismisses a prompted refresh by touching the file watched by our development scripts',
@@ -136,11 +147,7 @@ export const mutation = mutationType({
// if necessary init the wizard for configuration
if (ctx.coreData.currentTestingType
&& !ctx.lifecycleManager.isTestingTypeConfigured(ctx.coreData.currentTestingType)) {
try {
await ctx.actions.wizard.initialize()
} catch (e) {
ctx.coreData.baseError = e as Error
}
await ctx.actions.wizard.initialize()
}
return {}
@@ -249,16 +256,13 @@ export const mutation = mutationType({
t.field('launchOpenProject', {
type: CurrentProject,
slowLogThreshold: false,
description: 'Launches project from open_project global singleton',
args: {
specPath: stringArg(),
},
resolve: async (_, args, ctx) => {
try {
await ctx.actions.project.launchProject(ctx.coreData.currentTestingType, {}, args.specPath)
} catch (e) {
ctx.coreData.baseError = e as Error
}
await ctx.actions.project.launchProject(ctx.coreData.currentTestingType, {}, args.specPath)
return ctx.lifecycleManager
},
@@ -310,18 +314,7 @@ export const mutation = mutationType({
path: nonNull(stringArg()),
},
resolve: async (_, args, ctx) => {
try {
await ctx.actions.project.setCurrentProject(args.path)
ctx.coreData.baseError = null
} catch (error) {
const e = error as Error
ctx.coreData.baseError = {
title: 'Cypress Configuration Error',
message: e.message,
stack: e.stack,
}
}
await ctx.actions.project.setCurrentProject(args.path)
return {}
},
@@ -456,7 +449,7 @@ export const mutation = mutationType({
},
resolve: (_, args, ctx) => {
ctx.actions.file.openFile(
args.input.absolute,
args.input.filePath,
args.input.line || 1,
args.input.column || 1,
)
@@ -489,17 +482,7 @@ export const mutation = mutationType({
},
resolve: async (_, { skip, before, after }, ctx) => {
if (!skip && before && after) {
try {
await ctx.actions.migration.renameSpecFiles(before, after)
} catch (error) {
const e = error as Error
ctx.coreData.baseError = {
title: 'Spec Files Migration Error',
message: e.message,
stack: e.stack,
}
}
await ctx.actions.migration.renameSpecFiles(before, after)
}
await ctx.actions.migration.nextStep()
@@ -512,20 +495,7 @@ export const mutation = mutationType({
description: 'When the user decides to skip specs rename',
type: Query,
resolve: async (_, args, ctx) => {
try {
await ctx.actions.migration.renameSpecsFolder()
} catch (error) {
const e = error as Error
const message = e.message === 'dest already exists.' ? 'e2e folder already exists.' : e.message
ctx.coreData.baseError = {
title: 'Spec Folder Migration Error',
message,
stack: e.stack,
}
}
await ctx.actions.migration.renameSpecsFolder()
await ctx.actions.migration.nextStep()
return {}
@@ -566,17 +536,7 @@ export const mutation = mutationType({
description: 'While migrating to 10+ launch renaming of support file',
type: Query,
resolve: async (_, args, ctx) => {
try {
await ctx.actions.migration.renameSupportFile()
} catch (error) {
const e = error as Error
ctx.coreData.baseError = {
title: 'Support File Migration Error',
message: e.message,
stack: e.stack,
}
}
await ctx.actions.migration.renameSupportFile()
await ctx.actions.migration.nextStep()
return {}
@@ -588,18 +548,7 @@ export const mutation = mutationType({
type: Query,
slowLogThreshold: 5000, // This mutation takes a little time
resolve: async (_, args, ctx) => {
try {
await ctx.actions.migration.createConfigFile()
} catch (error) {
const e = error as Error
ctx.coreData.baseError = {
title: 'Config File Migration Error',
message: e.message,
stack: e.stack,
}
}
await ctx.actions.migration.createConfigFile()
await ctx.actions.migration.nextStep()
return {}
@@ -679,5 +628,13 @@ export const mutation = mutationType({
return true
},
})
t.field('dismissWarning', {
type: Query,
description: `Dismisses a warning displayed by the frontend`,
resolve: (source) => {
return {}
},
})
},
})

View File

@@ -1,5 +1,4 @@
import { objectType } from 'nexus'
import { BaseError } from '.'
import { ProjectLike, ScaffoldedFile, TestingTypeEnum } from '..'
import { CurrentProject } from './gql-CurrentProject'
import { DevState } from './gql-DevState'
@@ -8,19 +7,19 @@ import { LocalSettings } from './gql-LocalSettings'
import { Migration } from './gql-Migration'
import { VersionData } from './gql-VersionData'
import { Wizard } from './gql-Wizard'
import { Warning } from './gql-Warning'
import { ErrorWrapper } from './gql-ErrorWrapper'
export const Query = objectType({
name: 'Query',
description: 'The root "Query" type containing all entry fields for our querying',
definition (t) {
t.field('baseError', {
type: BaseError,
type: ErrorWrapper,
resolve: (root, args, ctx) => ctx.baseError,
})
t.nonNull.list.nonNull.field('warnings', {
type: Warning,
type: ErrorWrapper,
description: 'A list of warnings',
resolve: (source, args, ctx) => {
return ctx.coreData.warnings

View File

@@ -1,15 +0,0 @@
import { objectType } from 'nexus'
export const Warning = objectType({
name: 'Warning',
description: 'A generic warning',
definition (t) {
t.nonNull.string('title')
t.nonNull.string('message')
t.string('details')
},
sourceType: {
module: '@packages/types',
export: 'Warning',
},
})

View File

@@ -2,12 +2,13 @@
// created by autobarrel, do not modify directly
export * from './gql-AuthState'
export * from './gql-BaseError'
export * from './gql-Browser'
export * from './gql-CodeFrame'
export * from './gql-CodeGenGlobs'
export * from './gql-CurrentProject'
export * from './gql-DevState'
export * from './gql-Editor'
export * from './gql-ErrorWrapper'
export * from './gql-FileParts'
export * from './gql-GenerateSpecResponse'
export * from './gql-GeneratedSpecError'
@@ -24,7 +25,6 @@ export * from './gql-Storybook'
export * from './gql-TestingTypeInfo'
export * from './gql-Version'
export * from './gql-VersionData'
export * from './gql-Warning'
export * from './gql-Wizard'
export * from './gql-WizardBundler'
export * from './gql-WizardCodeLanguage'

View File

@@ -1,6 +1,9 @@
import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json'
// TODO: add when we land the lifecycle management
const expectStackToBe = (mode: 'open' | 'closed') => {
cy.get(`[data-cy="stack-open-${mode === 'open' ? 'true' : 'false'}"]`)
}
describe('Config files error handling', () => {
beforeEach(() => {
cy.scaffoldProject('pristine')
@@ -18,13 +21,13 @@ describe('Config files error handling', () => {
cy.openProject('pristine-with-e2e-testing')
cy.visitLaunchpad()
cy.get('body').should('contain.text', 'Something went wrong')
cy.get('body').should('contain.text', 'Please remove one of the two and try again')
expectStackToBe('closed')
cy.withCtx(async (ctx) => {
await ctx.actions.file.removeFileInProject('cypress.config.js')
})
cy.get('body')
.should('not.contain.text', 'Something went wrong')
cy.get('h1').should('contain', 'Welcome to Cypress')
})
it('shows the upgrade screen if there is a legacy config file', () => {
@@ -52,43 +55,98 @@ describe('Config files error handling', () => {
cy.visitLaunchpad()
cy.get('body').should('contain.text', 'Cypress no longer supports')
expectStackToBe('closed')
cy.withCtx(async (ctx) => {
await ctx.actions.file.removeFileInProject('cypress.json')
})
cy.get('body').should('not.contain.text', 'Cypress no longer supports')
cy.get('h1').should('contain', 'Welcome to Cypress')
})
it('handles deprecated config fields', () => {
cy.openProject('pristine')
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = { experimentalComponentTesting: true, e2e: {} }')
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = { e2e: { supportFile: false, experimentalComponentTesting: true } }')
})
cy.openProject('pristine')
cy.visitLaunchpad()
cy.get('[data-cy-testingType=e2e]').click()
cy.get('body').should('contain.text', 'Something went wrong')
cy.get('body').should('contain.text', 'It looks like there\'s some issues that need to be resolved before we continue.')
cy.findByText('Error Loading Config')
})
it('handles deprecated fields on root config', () => {
cy.openProject('pristine')
cy.get('body', { timeout: 10000 }).should('contain.text', 'was removed in Cypress version')
expectStackToBe('closed')
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', `module.exports = { supportFile: 'cypress/support.ts', e2e: {} }`)
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = { e2e: { supportFile: false } }')
})
cy.openProject('pristine')
cy.visitLaunchpad()
cy.get('[data-cy-testingType=e2e]').click()
cy.get('body').should('contain.text', 'Something went wrong')
cy.get('body').should('contain.text', 'It looks like there\'s some issues that need to be resolved before we continue.')
cy.findByText('Error Loading Config')
cy.get('h1').should('contain', 'Choose a Browser')
})
})
describe('Launchpad: Error System Tests', () => {
it('Handles an error thrown from the root of the config file', () => {
cy.scaffoldProject('plugins-root-sync-error')
cy.openProject('plugins-root-sync-error', ['--e2e'])
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Error')
expectStackToBe('open')
})
it('Handles an error thrown asynchronously in the root of the config', () => {
cy.scaffoldProject('plugins-root-async-error')
cy.openProject('plugins-root-async-error', ['--e2e'])
cy.visitLaunchpad()
cy.get('h1').should('not.exist') // No title set on unhandled error
expectStackToBe('open')
})
it('Handles an synchronously in setupNodeEvents', () => {
cy.scaffoldProject('plugins-function-sync-error')
cy.openProject('plugins-function-sync-error', ['--e2e'])
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Error Loading Config')
expectStackToBe('open')
})
it('Handles an error thrown while validating config', () => {
cy.scaffoldProject('config-with-invalid-browser')
cy.openProject('config-with-invalid-browser', ['--e2e'])
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Error Loading Config')
expectStackToBe('closed')
})
it('Handles an error thrown from the tasks', () => {
cy.scaffoldProject('plugins-function-sync-error')
cy.openProject('plugins-function-sync-error', ['--e2e'])
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Error Loading Config')
expectStackToBe('open')
})
it('Handles a TS syntax error when loading the config', () => {
cy.scaffoldProject('config-with-ts-syntax-error')
cy.openProject('config-with-ts-syntax-error')
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Error Loading Config')
cy.percySnapshot()
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.ts', 'module.exports = { e2e: { supportFile: false } }')
})
cy.findByRole('button', { name: 'Try again' }).click()
cy.get('h1').should('contain', 'Welcome to Cypress')
})
it('shows correct user file instead of node file', () => {
cy.scaffoldProject('config-with-import-error')
cy.openProject('config-with-import-error')
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Error Loading Config')
cy.percySnapshot()
cy.get('[data-testid="error-code-frame"]').should('contain', 'cypress.config.js:4:23')
})
})

View File

@@ -9,7 +9,6 @@ describe('Error handling', () => {
cy.get('[data-cy-testingType=e2e]').click()
cy.get('body')
.should('contain.text', 'Please confirm that everything looks correct in your cypress.config.js file.')
.and('contain.text', 'threw an error from')
cy.get('[data-cy="collapsible-header"]')
@@ -42,7 +41,6 @@ describe('Error handling', () => {
cy.visitLaunchpad()
cy.get('body')
.should('contain.text', 'Please confirm that everything looks correct in your cypress.config.js file.')
.should('contain.text', 'Error Loading Config')
.and('contain.text', 'Error thrown from Config')

View File

@@ -187,14 +187,14 @@ describe('Launchpad: Setup Project', () => {
// project has a cypress.configuration file with component testing configured
describe('project that has not been configured for e2e', () => {
// FIXME: ProjectLifecycleManager is skipping straight to browser pages when it should show setup page.
it.skip('shows the configuration setup page when selecting e2e tests', () => {
it('shows the configuration setup page when selecting e2e tests', () => {
scaffoldAndOpenProject('pristine-with-ct-testing')
cy.visitLaunchpad()
verifyWelcomePage({ e2eIsConfigured: false, ctIsConfigured: true })
cy.get('[data-cy-testingtype="e2e"]').click()
cy.findByRole('button', { name: 'Next Step' }).click()
cy.contains('h1', 'Configuration Files')
cy.findByText('We added the following files to your project.')
@@ -217,23 +217,20 @@ describe('Launchpad: Setup Project', () => {
])
})
// FIXME: ProjectLifecycleManager is skipping straight to browser pages when it should show setup page.
it.skip('moves to "Choose a Browser" page after clicking "Continue" button in first step in configuration page', () => {
scaffoldAndOpenProject('pristine-with-ct-testing')
it('moves to "Choose a Browser" page after clicking "Continue" button in first step in configuration page', () => {
scaffoldAndOpenProject('pristine')
cy.visitLaunchpad()
verifyWelcomePage({ e2eIsConfigured: false, ctIsConfigured: true })
verifyWelcomePage({ e2eIsConfigured: false, ctIsConfigured: false })
cy.get('[data-cy-testingtype="e2e"]').click()
cy.findByRole('button', { name: 'Next Step' }).click()
cy.contains('h1', 'Configuration Files')
cy.findByText('We added the following files to your project.')
cy.get('[data-cy=changes]').within(() => {
cy.contains('cypress.config.js')
})
cy.get('[data-cy=valid]').within(() => {
cy.contains('cypress.config.js')
cy.containsPath('cypress/support/e2e.js')
cy.containsPath('cypress/support/commands.js')
cy.containsPath('cypress/fixtures/example.json')
@@ -912,7 +909,7 @@ describe('Launchpad: Setup Project', () => {
cy.get('[data-cy-testingtype="component"]').click()
cy.get('[data-testid="select-framework"]').click()
cy.findByText('Nuxt.js (v2)').click()
cy.findByText('Next Step').click()
cy.findByRole('button', { name: 'Next Step' }).should('not.be.disabled').click()
fakeInstalledDeps()
cy.findByRole('button', { name: 'Continue' }).click()
cy.intercept('POST', 'mutation-ExternalLink_OpenExternal', { 'data': { 'openExternal': true } }).as('OpenExternal')

View File

@@ -7,6 +7,7 @@
<BaseError
v-if="query.data.value.baseError"
:gql="query.data.value.baseError"
:retry="reinitializeCypress"
/>
<GlobalPage
v-else-if="query.data.value.isInGlobalMode || !query.data.value?.currentProject"
@@ -20,14 +21,6 @@
v-if="query.data.value.scaffoldedFiles"
:gql="query.data.value"
/>
<BaseError
v-else-if="currentProject?.errorLoadingConfigFile"
:gql="currentProject.errorLoadingConfigFile"
/>
<BaseError
v-else-if="currentProject?.errorLoadingNodeEvents"
:gql="currentProject.errorLoadingNodeEvents"
/>
<Spinner v-else-if="currentProject?.isLoadingConfigFile" />
<template v-else-if="currentProject?.isLoadingNodeEvents">
<LaunchpadHeader
@@ -79,8 +72,8 @@
</template>
<script lang="ts" setup>
import { gql, useQuery } from '@urql/vue'
import { MainLaunchpadQueryDocument } from './generated/graphql'
import { gql, useMutation, useQuery } from '@urql/vue'
import { MainLaunchpadQueryDocument, Main_ReinitializeCypressDocument } from './generated/graphql'
import TestingTypeCards from './setup/TestingTypeCards.vue'
import Wizard from './setup/Wizard.vue'
import ScaffoldLanguageSelect from './setup/ScaffoldLanguageSelect.vue'
@@ -107,7 +100,7 @@ fragment MainLaunchpadQueryData on Query {
...Wizard
...ScaffoldLanguageSelect
baseError {
...BaseError_Data
...BaseError
}
currentTestingType
currentProject {
@@ -118,12 +111,6 @@ fragment MainLaunchpadQueryData on Query {
isLoadingNodeEvents
needsLegacyConfigMigration
currentTestingType
errorLoadingConfigFile {
...BaseError_Data
}
errorLoadingNodeEvents {
...BaseError_Data
}
}
isInGlobalMode
...GlobalPage
@@ -137,6 +124,22 @@ query MainLaunchpadQuery {
}
`
gql`
mutation Main_ReinitializeCypress {
reinitializeCypress {
...MainLaunchpadQueryData
}
}
`
const mutation = useMutation(Main_ReinitializeCypressDocument)
const reinitializeCypress = () => {
if (!mutation.fetching.value) {
mutation.executeMutation({})
}
}
const query = useQuery({ query: MainLaunchpadQueryDocument })
const currentProject = computed(() => query.data.value?.currentProject)
</script>

View File

@@ -80,7 +80,8 @@ import Button from '@cy/components/Button.vue'
// eslint-disable-next-line no-duplicate-imports
import Badge from '@cy/components/Badge.vue'
import { useI18n } from '@cy/i18n'
import ShikiHighlight, { CyLangType, langsSupported } from '@cy/components/ShikiHighlight.vue'
import ShikiHighlight, { langsSupported } from '@cy/components/ShikiHighlight.vue'
import type { CyLangType } from '@cy/components/ShikiHighlight.vue'
import ListRowHeader from '@cy/components/ListRowHeader.vue'
import Collapsible from '@cy/components/Collapsible.vue'
import AddedIcon from '~icons/cy/file-changes-added_x24.svg'

View File

@@ -1,17 +1,17 @@
import { defaultMessages } from '@cy/i18n'
import { codeFrameColumns } from '@babel/code-frame'
import BaseError from './BaseError.vue'
import Button from '@cy/components/Button.vue'
import { BaseError_DataFragmentDoc } from '../generated/graphql-test'
import { BaseErrorFragmentDoc } from '../generated/graphql-test'
import dedent from 'dedent'
// Selectors
const headerSelector = '[data-testid=error-header]'
const messageSelector = '[data-testid=error-message]'
const retryButtonSelector = '[data-testid=error-retry-button]'
const docsButtonSelector = '[data-testid=error-read-the-docs-button]'
const customFooterSelector = '[data-testid=custom-error-footer]'
// Constants
const messages = defaultMessages.launchpadErrors.generic
const messages = cy.i18n.launchpadErrors.generic
const customHeaderMessage = 'Well, this was unexpected!'
const customMessage = `Don't worry, just click the "It's fixed now" button to try again.`
const customFooterText = `Yikes, try again!`
@@ -23,30 +23,43 @@ describe('<BaseError />', () => {
})
it('renders the default error the correct messages', () => {
cy.mountFragment(BaseError_DataFragmentDoc, {
onResult: (result) => {
result.title = messages.header
},
render: (gqlVal) => <BaseError gql={gqlVal} retry={() => {}} />,
cy.mountFragment(BaseErrorFragmentDoc, {
render: (gqlVal) => <BaseError gql={gqlVal} />,
})
.get(headerSelector)
.should('contain.text', messages.header)
.should('contain.text', cy.gqlStub.ErrorWrapper.title)
.get(messageSelector)
.should('contain.text', `It looks like there's some issues that need to be resolved before we continue`)
.should('contain.text', cy.gqlStub.ErrorWrapper.errorMessage.replace(/\`/g, '').slice(0, 10))
.get(retryButtonSelector)
.should('not.exist')
})
it('renders the retry button if retry is passed', () => {
cy.mountFragment(BaseErrorFragmentDoc, {
render: (gqlVal) => <BaseError gql={gqlVal} retry={() => {}} />,
})
.get(retryButtonSelector)
.should('contain.text', messages.retryButton)
.get(docsButtonSelector)
.should('contain.text', messages.readTheDocsButton)
})
it('does not open the stack by default if it is not a user error', () => {
cy.mountFragment(BaseErrorFragmentDoc, {
onResult (result) {
result.isUserCodeError = false
},
render: (gqlVal) => <BaseError gql={gqlVal} />,
}).then(() => {
cy.get('[data-cy=stack-open-true]').should('not.exist')
cy.contains('Stack Trace').click()
cy.contains('Error: foobar').should('be.visible')
cy.get('[data-cy=stack-open-true]')
})
})
it('calls the retry function passed in', () => {
const retrySpy = cy.spy().as('retry')
cy.mountFragment(BaseError_DataFragmentDoc, {
onResult: (result) => {
result.title = messages.header
result.message = null
},
cy.mountFragment(BaseErrorFragmentDoc, {
render: (gqlVal) => (<div class="p-16px">
<BaseError gql={gqlVal} retry={retrySpy} />,
</div>),
@@ -59,11 +72,11 @@ describe('<BaseError />', () => {
})
it('renders custom error messages and headers with props', () => {
cy.mountFragment(BaseError_DataFragmentDoc, {
cy.mountFragment(BaseErrorFragmentDoc, {
onResult: (result) => {
result.title = customHeaderMessage
result.message = customMessage
result.stack = customStack
result.errorMessage = customMessage
result.errorStack = customStack
},
render: (gqlVal) => (<div class="p-16px">
<BaseError gql={gqlVal} />
@@ -76,10 +89,10 @@ describe('<BaseError />', () => {
})
it('renders the header, message, and footer slots', () => {
cy.mountFragment(BaseError_DataFragmentDoc, {
cy.mountFragment(BaseErrorFragmentDoc, {
onResult: (result) => {
result.title = messages.header
result.message = messages.message
result.errorMessage = messages.message
},
render: (gqlVal) => (
<BaseError
@@ -96,4 +109,42 @@ describe('<BaseError />', () => {
.get(customFooterSelector)
.should('contain.text', customFooterText)
})
it('renders the header, message, and footer slots', () => {
cy.mountFragment(BaseErrorFragmentDoc, {
onResult: (result) => {
result.title = messages.header
result.codeFrame = {
__typename: 'CodeFrame',
line: 12,
column: 25,
codeBlock: codeFrameColumns(dedent`
const x = 1;
throw new Error('Some Error');
const y = 2;
`, {
start: {
line: 3,
column: 5,
},
}, {
linesAbove: 2,
linesBelow: 4,
}),
file: {
id: `FileParts:/absolute/full/path/cypress/e2e/file.cy.js`,
__typename: 'FileParts',
relative: 'cypress/e2e/file.cy.js',
absolute: '/absolute/full/path/cypress/e2e/file.cy.js',
},
}
},
render: (gqlVal) => (
<BaseError gql={gqlVal} />),
})
cy.findByText('cypress/e2e/file.cy.js:12:25').should('be.visible')
})
})

View File

@@ -1,29 +1,23 @@
<template>
<div class="mx-auto space-y-32px text-center min-w-476px max-w-848px py-16px children:text-center">
<div
v-if="baseError"
class="mx-auto space-y-32px text-center min-w-476px max-w-848px pt-16px children:text-center"
>
<div>
<h1
class="font-medium leading-snug text-32px text-gray-900"
v-if="baseError.title"
class="font-medium leading-snug pb-24px text-32px text-gray-900"
data-testid="error-header"
>
<slot name="header">
{{ headerText }}
{{ baseError.title }}
</slot>
</h1>
<!-- eslint-disable vue/multiline-html-element-content-newline -->
<slot name="message">
<!-- Can't pull this out because of the i18n-t component -->
<i18n-t
scope="global"
keypath="launchpadErrors.generic.message"
tag="p"
class="font-light pb-24px"
data-testid="error-message"
>
<OpenConfigFileInIDE />
</i18n-t>
<Alert
:title="props.gql.title ?? ''"
:title="baseError.errorName"
status="error"
body-class="px-0px bg-red-50"
alert-class="bg-red-50"
@@ -32,25 +26,31 @@
icon-classes="icon-dark-red-400"
max-height="none"
>
<p
v-if="errorMessage"
class="border-b-1 border-b-red-100 p-16px pt-0 text-red-500"
>
{{ errorMessage }}
</p>
<p
v-if="stack"
<div class="border-b-1 border-b-red-100 p-16px pt-0">
<div
ref="markdownTarget"
class="text-red-500"
data-testid="error-message"
v-html="markdown"
/>
<ErrorCodeFrame
v-if="baseError.codeFrame"
:gql="baseError.codeFrame"
/>
</div>
<div
class="m-16px mb-0 overflow-hidden"
>
<Collapsible
disable
max-height="none"
initially-open
:initially-open="baseError.isUserCodeError"
>
<template #target="{open, toggle}">
<p
class="gap-8px inline-flex items-center justify-center"
:class="{'pb-8px': open}"
:data-cy="`stack-open-${open}`"
>
<i-cy-chevron-right-small_x16
class="min-w-8px min-h-8px transform duration-150 icon-dark-red-400"
@@ -65,12 +65,12 @@
</p>
</template>
<pre
v-if="stack"
data-testid="error-header"
class="bg-white rounded font-light border-1 border-red-200 p-16px overflow-auto"
v-html="stack"
v-html="baseError.errorStack"
/>
</Collapsible>
</p>
</div>
</Alert>
</slot>
<!-- eslint-enable vue/multiline-html-element-content-newline -->
@@ -80,7 +80,7 @@
<div class="w-full gap-16px inline-flex">
<slot name="footer">
<Button
v-if="retry"
v-if="props.retry"
size="lg"
variant="primary"
data-testid="error-retry-button"
@@ -90,52 +90,47 @@
>
{{ t('launchpadErrors.generic.retryButton') }}
</Button>
<Button
size="lg"
variant="outline"
data-testid="error-read-the-docs-button"
@click="openDocs"
>
{{ t('launchpadErrors.generic.readTheDocsButton') }}
</Button>
</slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { gql } from '@urql/vue'
import Button from '@cy/components/Button.vue'
import { computed } from 'vue'
import { useI18n } from '@cy/i18n'
import type { BaseError_DataFragment } from '../generated/graphql'
import type { BaseErrorFragment } from '../generated/graphql'
import Alert from '@cy/components/Alert.vue'
import OpenConfigFileInIDE from '@packages/frontend-shared/src/gql-components/OpenConfigFileInIDE.vue'
import Collapsible from '@cy/components/Collapsible.vue'
import { useMarkdown } from '@packages/frontend-shared/src/composables/useMarkdown'
import RestartIcon from '~icons/cy/restart_x16.svg'
import { useExternalLink } from '@packages/frontend-shared/src/gql-components/useExternalLink'
import ErrorOutlineIcon from '~icons/cy/status-errored-outline_x16.svg'
import ErrorCodeFrame from './ErrorCodeFrame.vue'
gql`
fragment BaseError_Data on BaseError {
fragment BaseError on ErrorWrapper {
title
message
stack
errorName
errorStack
errorType
errorMessage
isUserCodeError
codeFrame {
...ErrorCodeFrame
}
}
`
const openDocs = useExternalLink('https://on.cypress.io/')
const { t } = useI18n()
const props = defineProps<{
gql: BaseError_DataFragment
gql: BaseErrorFragment
retry?: () => void
onReadDocs?: () => void
}>()
const headerText = computed(() => t('launchpadErrors.generic.header'))
const errorMessage = computed(() => props.gql.message ?? null)
const stack = computed(() => props.gql.stack ?? null)
const markdownTarget = ref()
const baseError = computed(() => props.gql)
const { markdown } = useMarkdown(markdownTarget, computed(() => props.gql.errorMessage), { classes: { code: ['bg-error-200'] } })
</script>

View File

@@ -0,0 +1,55 @@
<template>
<OpenFileInIDE
v-slot="{onClick}"
:file-path="props.gql.file.absolute"
:line="props.gql.line ?? 0"
:column="props.gql.column ?? 0"
>
<div
class="border rounded cursor-pointer flex flex-row bg-gray-50 border-red-100 mt-16px text-indigo-500 text-14px leading-24px items-center"
tab-index="1"
data-testid="error-code-frame"
@click="onClick"
>
<i-cy-document-text_x16 class="h-16px m-12px mr-8px w-16px icon-dark-indigo-500 icon-light-indigo-100" />
<code>{{ fileText }}</code>
</div>
</OpenFileInIDE>
<ShikiHighlight
v-if="props.gql.codeBlock"
:code="props.gql.codeBlock"
lang="js"
skip-trim
codeframe
/>
</template>
<script lang="ts" setup>
import { gql } from '@urql/vue'
import { computed } from 'vue'
import ShikiHighlight from '@packages/frontend-shared/src/components/ShikiHighlight.vue'
import OpenFileInIDE from '@packages/frontend-shared/src/gql-components/OpenFileInIDE.vue'
import type { ErrorCodeFrameFragment } from '../generated/graphql'
gql`
fragment ErrorCodeFrame on CodeFrame {
line
column
codeBlock
file {
id
absolute
relative
}
}`
const props = defineProps<{
gql: ErrorCodeFrameFragment
}>()
const fileText = computed(() => {
const { file, line, column } = props.gql
return `${file.relative}${(line && column) ? `:${line}:${column}` : ''}`
})
</script>

View File

@@ -171,7 +171,7 @@ const { t } = useI18n()
gql`
fragment MigrationBaseError on Query {
baseError {
...BaseError_Data
...BaseError
}
}
`

View File

@@ -80,7 +80,8 @@ import MigrationTitle from './fragments/MigrationTitle.vue'
import MigrationList from './fragments/MigrationList.vue'
import MigrationListItem from './fragments/MigrationListItem.vue'
import { gql, useMutation } from '@urql/vue'
import { RenameSpecsManualFragment, RenameSpecsManual_CloseWatcherDocument } from '../generated/graphql'
import { RenameSpecsManual_CloseWatcherDocument } from '../generated/graphql'
import type { RenameSpecsManualFragment } from '../generated/graphql'
gql`
fragment RenameSpecsManual on Migration {

View File

@@ -4,19 +4,14 @@ import faker from 'faker'
import { defaultMessages } from '@cy/i18n'
const warningSelector = '[data-cy=warning-alert]'
const message = faker.hacker.phrase()
const title = faker.hacker.ingverb()
const createWarning = (props = {}) => ({
__typename: 'Warning' as const,
title,
message,
details: null,
...cy.gqlStub.ErrorWrapper,
...props,
})
const firstWarning = createWarning({ title: faker.hacker.ingverb(), message: faker.hacker.phrase(), setupStep: null })
const secondWarning = createWarning({ title: faker.hacker.ingverb(), message: faker.hacker.phrase(), setupStep: null })
const firstWarning = createWarning({ title: faker.hacker.ingverb(), errorMessage: faker.hacker.phrase() })
const secondWarning = createWarning({ title: faker.hacker.ingverb(), errorMessage: faker.hacker.phrase() })
describe('<WarningList />', () => {
it('does not render warning if there are none', () => {
@@ -33,7 +28,10 @@ describe('<WarningList />', () => {
it('renders multiple warnings', () => {
cy.mountFragment(WarningListFragmentDoc, {
onResult (result) {
result.warnings = [firstWarning, secondWarning]
result.warnings = [
firstWarning,
secondWarning,
]
},
render: (gqlVal) => <div class="p-4"><WarningList gql={gqlVal} /></div>,
})
@@ -50,10 +48,10 @@ describe('<WarningList />', () => {
})
cy.get(warningSelector).should('have.length', 2)
cy.contains(firstWarning.message)
cy.contains(firstWarning.errorMessage)
cy.findAllByLabelText(defaultMessages.components.modal.dismiss).first().click()
cy.get(warningSelector).should('have.length', 1)
cy.contains(firstWarning.message).should('not.exist')
cy.contains(firstWarning.errorMessage).should('not.exist')
})
})

Some files were not shown because too many files have changed in this diff Show More