feat: add runs duration, relative created at and style tweaks (#21410)

Co-authored-by: Tim Griesser <tgriesser10@gmail.com>
Co-authored-by: Mark Noonan <mark@cypress.io>
This commit is contained in:
Zachary Williams
2022-05-17 12:58:38 -05:00
committed by GitHub
parent 5613556476
commit 8d79472b7d
15 changed files with 348 additions and 68 deletions
+45 -13
View File
@@ -267,11 +267,11 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
})
it('if project Id is specified in config file that does not exist, shows call to action', () => {
cy.findByText(defaultMessages.runs.errors.notfound.button).should('be.visible')
cy.findByText(defaultMessages.runs.errors.notFound.button).should('be.visible')
})
it('opens Connect Project modal after clicking Reconnect Project button', () => {
cy.findByText(defaultMessages.runs.errors.notfound.button).click()
cy.findByText(defaultMessages.runs.errors.notFound.button).click()
cy.get('[aria-modal="true"]').should('exist')
cy.get('[data-cy="selectProject"] button').click()
cy.findByText('Mock Project').click()
@@ -497,18 +497,18 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.get('[href="http://dummy.cypress.io/runs/0"]').first().as('firstRun')
cy.get('@firstRun').get('[data-cy="run-card-author"]').contains('John Appleseed')
cy.get('@firstRun').get('[data-cy="run-card-avatar')
cy.get('@firstRun').get('[data-cy="run-card-branch"]').contains('main')
cy.get('@firstRun').within(() => {
cy.get('[data-cy="run-card-author"]').contains('John Appleseed')
cy.get('[data-cy="run-card-avatar"]')
cy.get('[data-cy="run-card-branch"]').contains('main')
cy.get('[data-cy="run-card-created-at"]').contains('an hour ago')
cy.get('[data-cy="run-card-duration"]').contains('01:00')
// the exact timestamp string will depend on the user's browser's locale settings
const localeTimeString = (new Date('2022-02-02T08:17:00.005Z')).toLocaleTimeString()
cy.get('@firstRun').contains(localeTimeString)
cy.get('@firstRun').contains('span', 'skipped')
cy.get('@firstRun').get('span').contains('pending')
cy.get('@firstRun').get('span').contains('passed')
cy.get('@firstRun').get('span').contains('failed')
cy.contains('span', 'skipped')
cy.get('span').contains('pending')
cy.get('span').contains('passed')
cy.get('span').contains('failed')
})
})
it('opens the run page if a run is clicked', () => {
@@ -522,6 +522,38 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
expect((ctx.actions.electron.openExternal as SinonStub).lastCall.lastArg).to.eq('http://dummy.cypress.io/runs/0')
})
})
it('shows connection failed error if no cloudProject', () => {
let cloudData: any
cy.loginUser()
cy.visitApp()
cy.remoteGraphQLIntercept((obj) => {
if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') {
cloudData = obj.result
obj.result = {}
return obj.result
}
return obj.result
})
cy.get('[href="#/runs"]').click()
cy.contains('h2', 'Cannot connect to the Cypress Dashboard')
cy.percySnapshot()
cy.remoteGraphQLIntercept((obj) => {
if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') {
return cloudData
}
return obj.result
})
cy.contains('button', 'Try again').click().should('not.exist')
})
})
describe('no internet connection', () => {
+1
View File
@@ -42,6 +42,7 @@
"concurrently": "^6.2.0",
"cross-env": "6.0.3",
"cypress-real-events": "1.6.0",
"dayjs": "^1.9.3",
"disparity": "^3.0.0",
"faker": "5.5.3",
"fuzzysort": "^1.1.4",
+5
View File
@@ -6,6 +6,7 @@
v-else
:gql="query.data.value"
:online="isOnlineRef"
@re-execute-runs-query="reExecuteRunsQuery"
/>
</TransitionQuickFade>
</div>
@@ -42,4 +43,8 @@ watchEffect(() => {
query.executeQuery()
}
})
function reExecuteRunsQuery () {
query.executeQuery()
}
</script>
+125 -1
View File
@@ -2,7 +2,49 @@ import { CloudRunStubs } from '@packages/graphql/test/stubCloudTypes'
import { RunCardFragmentDoc } from '../generated/graphql-test'
import RunCard from './RunCard.vue'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const generateTags = (num): any => new Array(num).fill(null).map((_, i) => ({ id: `${i}`, name: `tag${i}`, __typename: 'CloudRunTag' }))
describe('<RunCard />', { viewportHeight: 400 }, () => {
it('renders with all run information', () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.tags = generateTags(3)
result.totalFlakyTests = 1
},
render: (gqlVal) => {
return (
<div class="h-screen bg-gray-100 p-3">
<RunCard gql={gqlVal} />
</div>
)
},
})
cy.percySnapshot()
})
it('renders with all run information on small viewport', { viewportWidth: 600 }, () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.tags = [1, 2, 3].map((i) => ({ id: `${i}`, name: `tag${i}`, __typename: 'CloudRunTag' }))
result.totalFlakyTests = 1
},
render: (gqlVal) => {
return (
<div class="h-screen bg-gray-100 p-3">
<RunCard gql={gqlVal} />
</div>
)
},
})
cy.percySnapshot()
})
context('when there is full commit info', () => {
it('displays last commit info', () => {
cy.mountFragment(RunCardFragmentDoc, {
@@ -57,7 +99,89 @@ describe('<RunCard />', { viewportHeight: 400 }, () => {
})
// this is the human readable commit time from the stub
cy.contains('3:17:00 AM').should('be.visible')
cy.contains('an hour ago').should('be.visible')
cy.percySnapshot()
})
})
context('run timing', () => {
it('displays HH:mm:ss format for run duration', () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.totalDuration = HOUR + MINUTE + SECOND
},
render: (gqlVal) => {
return (
<div class="h-screen bg-gray-100 p-3">
<RunCard gql={gqlVal} />
</div>
)
},
})
// this is the human readable commit time from the stub
cy.contains('01:01:01').should('be.visible')
cy.percySnapshot()
})
it('displays mm:ss format for run duration if duration is less than an hour', () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.totalDuration = MINUTE + SECOND
},
render: (gqlVal) => {
return (
<div class="h-screen bg-gray-100 p-3">
<RunCard gql={gqlVal} />
</div>
)
},
})
// this is the human readable commit time from the stub
cy.contains('01:01').should('be.visible')
cy.percySnapshot()
})
})
context('tags', () => {
it('renders all tags if >= 2', () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.tags = generateTags(2)
},
render: (gqlVal) => {
return (
<div class="h-screen bg-gray-100 p-3">
<RunCard gql={gqlVal} />
</div>
)
},
})
cy.get('[data-cy="run-tag"]').should('have.length', 2).each(($el, i) => cy.wrap($el).contains(`tag${i}`))
cy.percySnapshot()
})
it('truncates tags if > 2', () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.tags = generateTags(6)
},
render: (gqlVal) => {
return (
<div class="h-screen bg-gray-100 p-3">
<RunCard gql={gqlVal} />
</div>
)
},
})
cy.get('[data-cy="run-tag"]').should('have.length', 3).last().contains('+4')
cy.percySnapshot()
})
+54 -24
View File
@@ -1,7 +1,7 @@
<template>
<ExternalLink
:data-cy="`runCard-${run.id}`"
class="border rounded bg-light-50 border-gray-100 mb-4 w-full
class="border rounded bg-light-50 border-gray-100 w-full
block overflow-hidden hocus-default"
:href="run.url || '#'"
:use-default-hocus="false"
@@ -11,42 +11,49 @@
:data-cy="`run-card-icon-${run.status}`"
>
<template #header>
{{ run.commitInfo?.summary }}
<span class="font-semibold mr-8px whitespace-pre-wrap">{{ run.commitInfo?.summary }}</span>
<span
v-for="tag in tags"
:key="tag"
class="rounded-md font-semibold border-gray-200 border-1px text-xs mr-8px px-4px text-gray-700"
data-cy="run-tag"
>{{ tag }}</span>
</template>
<template #description>
<div class="flex">
<span
<ul class="flex flex-wrap text-sm text-gray-700 gap-8px items-center whitespace-nowrap children:flex children:items-center">
<li
v-if="run.commitInfo?.authorName"
class="flex mr-3 items-center"
data-cy="run-card-author"
>
<i-cy-general-user_x16
class="mr-1 icon-dark-gray-500 icon-light-gray-200 icon-secondary-light-gray-200"
class="mr-1 icon-dark-gray-500 icon-light-gray-100 icon-secondary-light-gray-200"
data-cy="run-card-avatar"
/>
<span class="font-light text-sm text-gray-500">
{{ run.commitInfo.authorName }}
</span>
</span>
<span
<span class="sr-only">Commit Author:</span>{{ run.commitInfo.authorName }}
</li>
<li
v-if="run.commitInfo?.branch"
class="flex mr-3 items-center"
data-cy="run-card-branch"
>
<i-cy-tech-branch-h_x16 class="mr-1 icon-dark-gray-300" />
<span
class="font-light text-sm text-gray-500"
data-cy="run-card-branch"
>
{{ run.commitInfo.branch }}
</span>
</span>
<span
<span class="sr-only">Branch Name:</span>{{ run.commitInfo.branch }}
</li>
<li
v-if="run.createdAt"
class="flex mr-3 items-center"
data-cy="run-card-created-at"
>
{{ new Date(run.createdAt).toLocaleTimeString() }}
</span>
</div>
<span class="sr-only">Run Created At:</span>{{ relativeCreatedAt }}
</li>
<li
v-if="run.totalDuration"
data-cy="run-card-duration"
>
<span class="sr-only">Run Total Duration:</span>{{ totalDuration }}
</li>
</ul>
</template>
<template #right>
<RunResults
@@ -69,13 +76,19 @@ import FailedIcon from '~icons/cy/status-failed-solid_x24.svg'
import ErroredIcon from '~icons/cy/status-errored-solid_x24.svg'
import SkippedIcon from '~icons/cy/status-skipped_x24.svg'
import PendingIcon from '~icons/cy/status-pending_x24.svg'
import { dayjs } from './utils/day.js'
gql`
fragment RunCard on CloudRun {
id
createdAt
status
totalDuration
url
tags {
id
name
}
...RunResults
commitInfo {
authorName
@@ -105,4 +118,21 @@ const icon = computed(() => ICON_MAP[props.gql.status!])
const run = computed(() => props.gql)
const relativeCreatedAt = computed(() => dayjs(new Date(run.value.createdAt!)).fromNow())
const totalDuration = computed(() => dayjs.duration(run.value.totalDuration!).format('HH:mm:ss').replace(/^0+:/, ''))
const tags = computed(() => {
const tags = (props.gql.tags ?? []).map((tag) => tag?.name).filter(Boolean) as string[]
return tags.length <= 2 ? tags : tags.slice(0, 2).concat(`+${tags.length - 2}`)
})
</script>
<style>
li:not(:first-child)::before {
content: '.';
@apply -mt-8px text-lg text-gray-400 pr-8px
}
</style>
+15
View File
@@ -27,4 +27,19 @@ describe('<RunResults />', { viewportHeight: 150, viewportWidth: 250 }, () => {
cy.percySnapshot()
})
it('renders flaky ribbon', () => {
cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
result.totalFlakyTests = 4
},
render (gql) {
return <RunResults gql={gql} />
},
})
cy.contains('4 Flaky')
cy.percySnapshot()
})
})
+22 -15
View File
@@ -1,19 +1,25 @@
<template>
<div class="border rounded flex border-gray-200 h-6 text-gray-500 text-size-14px leading-20px">
<div
v-for="(result, i) in results"
:key="i"
class="flex px-2 items-center hover:bg-indigo-50"
:title="result.name"
:data-cy="`runResults-${result.name}-count`"
>
<component
:is="result.icon"
class="h-12px mr-1 w-12px"
:class="result.class"
/>
<span class="sr-only">{{ result.name }}</span>
{{ result.value }}
<div class="flex gap-8px items-center">
<span
v-if="props.gql.totalFlakyTests"
class="rounded-md font-semibold bg-warning-100 text-sm py-2px px-4px text-warning-600 whitespace-nowrap"
>{{ props.gql.totalFlakyTests }} Flaky</span>
<div class="border rounded flex border-gray-200 h-6 text-gray-700 text-size-14px leading-20px">
<div
v-for="(result, i) in results"
:key="i"
class="flex font-semibold px-2 items-center hover:bg-indigo-50"
:title="result.name"
:data-cy="`runResults-${result.name}-count`"
>
<component
:is="result.icon"
class="mt-px h-12px mr-1 w-12px"
:class="result.class"
/>
<span class="sr-only">{{ result.name }}</span>
{{ result.value }}
</div>
</div>
</div>
</template>
@@ -36,6 +42,7 @@ fragment RunResults on CloudRun {
totalFailed
totalPending
totalSkipped
totalFlakyTests
}
`
@@ -72,4 +72,29 @@ describe('<RunsContainer />', { keystrokeDelay: 0 }, () => {
cy.percySnapshot()
})
})
context('with errors', () => {
it('renders connection failed', () => {
cy.mountFragment(RunsContainerFragmentDoc, {
onResult (result) {
result.cloudViewer = cloudViewer
result.currentProject!.cloudProject = null
},
render (gqlVal) {
return <RunsContainer gql={gqlVal} online onReExecuteRunsQuery={cy.spy().as('reExecuteRunsQuery')}/>
},
})
const { title, description, link, button } = defaultMessages.runs.errors.connectionFailed
cy.contains(title).should('be.visible')
cy.contains(description.replace('{0}', link)).should('be.visible')
cy.contains('a', link).should('have.attr', 'href', 'https://www.cypressstatus.com/')
cy.contains('button', button).should('be.visible').click()
cy.get('@reExecuteRunsQuery').should('have.been.called')
cy.percySnapshot()
})
})
})
+9 -6
View File
@@ -1,8 +1,6 @@
<template>
<div class="h-full">
<NoInternetConnection
v-if="!online && !isCloudProjectReturned"
>
<NoInternetConnection v-if="!online">
Please check your internet connection to resolve this issue. When your internet connection is fixed, we will automatically attempt to fetch your latest runs for this project.
</NoInternetConnection>
<RunsConnectSuccessAlert
@@ -16,8 +14,9 @@
@success="showConnectSuccessAlert = true"
/>
<RunsErrorRenderer
v-else-if="currentProject?.cloudProject?.__typename !== 'CloudProject'"
v-else-if="currentProject?.cloudProject?.__typename !== 'CloudProject' || connectionFailed"
:gql="props.gql"
@re-execute-runs-query="emit('reExecuteRunsQuery')"
/>
<RunsEmpty
v-else-if="!currentProject?.cloudProject?.runs?.nodes.length"
@@ -26,6 +25,7 @@
<div
v-else
data-cy="runs"
class="flex flex-col pb-24px gap-16px"
>
<Warning
v-if="!online"
@@ -58,6 +58,10 @@ import RunsErrorRenderer from './RunsErrorRenderer.vue'
const { t } = useI18n()
const emit = defineEmits<{
(e: 'reExecuteRunsQuery'): void
}>()
gql`
fragment RunsContainer_RunsConnection on CloudRunConnection {
nodes {
@@ -185,9 +189,8 @@ const props = defineProps<{
online: boolean
}>()
const isCloudProjectReturned = computed(() => props.gql.currentProject?.cloudProject?.__typename === 'CloudProject')
const showConnectSuccessAlert = ref(false)
const connectionFailed = computed(() => !props.gql.currentProject?.cloudProject && props.online)
</script>
<style scoped>
+26 -4
View File
@@ -1,15 +1,32 @@
<template>
<RunsError
v-if="currentProject?.cloudProject?.__typename === 'CloudProjectNotFound'"
v-if="!currentProject?.cloudProject"
icon="error"
:button-text="t('runs.errors.notfound.button')"
:button-text="t('runs.errors.connectionFailed.button')"
:button-icon="ConnectIcon"
:message="t('runs.errors.notfound.title')"
:message="t('runs.errors.connectionFailed.title')"
@button-click="emit('reExecuteRunsQuery')"
>
<i18n-t
scope="global"
keypath="runs.errors.connectionFailed.description"
>
<ExternalLink href="https://www.cypressstatus.com/">
{{ t('runs.errors.connectionFailed.link') }}
</ExternalLink>
</i18n-t>
</RunsError>
<RunsError
v-else-if="currentProject?.cloudProject?.__typename === 'CloudProjectNotFound'"
icon="error"
:button-text="t('runs.errors.notFound.button')"
:button-icon="ConnectIcon"
:message="t('runs.errors.notFound.title')"
@button-click="showConnectDialog = true"
>
<i18n-t
scope="global"
keypath="runs.errors.notfound.description"
keypath="runs.errors.notFound.description"
>
<CodeTag
bg
@@ -61,6 +78,7 @@ import SendIcon from '~icons/cy/paper-airplane_x16.svg'
import { useI18n } from '@cy/i18n'
import CodeTag from '../../../frontend-shared/src/components/CodeTag.vue'
import CloudConnectModals from './modals/CloudConnectModals.vue'
import ExternalLink from '@cy/gql-components/ExternalLink.vue'
const { t } = useI18n()
@@ -88,6 +106,10 @@ const props = defineProps<{
gql: RunsErrorRendererFragment
}>()
const emit = defineEmits<{
(e: 'reExecuteRunsQuery'): void
}>()
const currentProject = computed(() => props.gql.currentProject)
const showConnectDialog = ref(false)
+8
View File
@@ -0,0 +1,8 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import duration from 'dayjs/plugin/duration'
dayjs.extend(relativeTime)
dayjs.extend(duration)
export { dayjs }
+3
View File
@@ -14,6 +14,9 @@ export default makeConfig({
'vue-router',
'@urql/devtools',
'@urql/exchange-graphcache',
'dayjs',
'dayjs/plugin/relativeTime',
'dayjs/plugin/duration',
],
},
}, {
@@ -10,8 +10,7 @@
/>
</slot>
</div>
<div class="bg-gray-100 h-40px w-1px" />
<div class="flex-grow h-auto px-16px">
<div class="flex-grow h-auto border-gray-100 border-l-1px px-16px">
<h2
class="text-indigo-500 whitespace-nowrap"
:class="{'text-size-18px leading-24px': bigHeader}"
@@ -488,7 +488,7 @@
"failed": "failed"
},
"errors": {
"notfound": {
"notFound": {
"title": "Couldn't find your project",
"description": "We were unable to find an existing project matching the {0} set in your Cypress config file. You can reconnect with an existing project or create a new project.",
"button": "Reconnect your project"
@@ -502,6 +502,12 @@
"title": "Your access request for this project has been sent.",
"description": "The owner of this project has been notified of your request. We'll notify you via email when your access request has been granted.",
"button": "Request Sent"
},
"connectionFailed": {
"title": "Cannot connect to the Cypress Dashboard",
"description": "The request times out when trying to retrieve the recorded runs from the Cypress Dashboard. Please refresh the page to try again and visit out {0} if this behavior continues.",
"link": "Support Page",
"button": "Try again"
}
}
},
+2 -2
View File
@@ -174,11 +174,11 @@ export function createCloudRun (config: Partial<CloudRun>): Required<CloudRun> {
totalRunning: 0,
totalTests: 10,
totalPassed: 10,
totalDuration: 300,
totalDuration: 1000 * 60,
totalFlakyTests: 0,
tags: [],
url: 'http://dummy.cypress.io/runs/1',
createdAt: new Date('1995-12-17T03:17:00').toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 61).toISOString(),
commitInfo: createCloudRunCommitInfo({
sha: `fake-sha-${getNodeIdx('CloudRun')}`,
summary: `fix: make gql work ${config.status ?? 'PASSED'}`,