Skip to content

Commit

Permalink
feat: IATR-M0 Debug Page (#25067)
Browse files Browse the repository at this point in the history
* adding debug page

* Fixing sidebar e2e tests

* Adding more content to the Debug page and header updates

* Starting to wire up child components

* created util function for debug specs mapping and wrote tests

* update to debug page graphql extension parsing and tests for utility function

* Fix header test

* Type fixes

* Fix test

* Setting hash to same as feature branch

* New test for DebugContainer

* Test Ids needed for test

* Updates to fix type linting

* Update to cloud schema and types

* Fixing tests

* Minor test fix

* Updates from review comments

* Make use of existing computed value

Co-authored-by: Zachary Williams <ZachJW34@gmail.com>

* Improve test

* Adding descriptions to cloud fields

* Encapsulate duration format in composable

* Making changes to align with latest cloud update

Co-authored-by: Ankit <ankit@cypress.io>
Co-authored-by: Zachary Williams <ZachJW34@gmail.com>
  • Loading branch information
3 people authored Dec 13, 2022
1 parent c89cc92 commit ee859f6
Show file tree
Hide file tree
Showing 24 changed files with 1,389 additions and 145 deletions.
13 changes: 13 additions & 0 deletions packages/app/cypress/e2e/sidebar_navigation.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
.tab().should('have.attr', 'data-cy', 'sidebar-header').should('have.attr', 'role', 'button')
.tab().should('have.attr', 'href', '#/specs').should('have.prop', 'tagName', 'A')
.tab().should('have.attr', 'href', '#/runs').should('have.prop', 'tagName', 'A')
.tab().should('have.attr', 'href', '#/debug').should('have.prop', 'tagName', 'A')
.tab().should('have.attr', 'href', '#/settings').should('have.prop', 'tagName', 'A')
.tab().should('have.attr', 'data-cy', 'keyboard-modal-trigger').should('have.prop', 'tagName', 'BUTTON')
})
Expand Down Expand Up @@ -146,6 +147,10 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
cy.contains('.v-popper--some-open--tooltip', 'Specs')
cy.findByTestId('sidebar-link-specs-page').trigger('mouseout')

cy.findByTestId('sidebar-link-debug-page').trigger('mouseenter')
cy.contains('.v-popper--some-open--tooltip', 'Debug')
cy.findByTestId('sidebar-link-debug-page').trigger('mouseout')

cy.findByTestId('sidebar-link-settings-page').trigger('mouseenter')
cy.contains('.v-popper--some-open--tooltip', 'Settings')
cy.findByTestId('sidebar-link-settings-page').trigger('mouseout')
Expand Down Expand Up @@ -237,6 +242,14 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
cy.get('.router-link-active').findByText('Specs').should('be.visible')
})

it('has a menu item labeled "Debug" which takes you to the Debug page', () => {
cy.get('[data-cy="app-header-bar"]').findByText('Debug').should('not.exist')

cy.findByTestId('sidebar-link-debug-page').should('have.text', 'Debug').should('be.visible').click()
cy.get('[data-cy="app-header-bar"]').findByText('Debug').should('be.visible')
cy.get('.router-link-active').findByText('Debug').should('be.visible')
})

it('Specs sidebar nav link is not active when a test is running', () => {
cy.location('hash').should('equal', '#/specs')
cy.contains('.router-link-exact-active', 'Specs')
Expand Down
9 changes: 9 additions & 0 deletions packages/app/cypress/e2e/specs_list_latest_runs.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ function simulateRunData () {
status: s,
createdAt: new Date('2022-05-08T03:17:00').toISOString(),
completedAt: new Date('2022-05-08T05:17:00').toISOString(),
basename: idPrefix.substring(idPrefix.lastIndexOf('/') + 1, idPrefix.indexOf('.')),
path: idPrefix,
extension: idPrefix.substring(idPrefix.indexOf('.')),
runNumber: 432,
groupCount: 2,
specDuration: {
Expand Down Expand Up @@ -444,6 +447,9 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW
status: s,
createdAt: new Date('2022-05-08T03:17:00').toISOString(),
completedAt: new Date('2022-05-08T05:17:00').toISOString(),
basename: idPrefix.substring(idPrefix.lastIndexOf('/') + 1, idPrefix.indexOf('.')),
path: idPrefix,
extension: idPrefix.substring(idPrefix.indexOf('.')),
runNumber: 432,
groupCount: 2,
specDuration: {
Expand Down Expand Up @@ -550,6 +556,9 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW
status: s,
createdAt: new Date('2022-05-08T03:17:00').toISOString(),
completedAt: new Date('2022-05-08T05:17:00').toISOString(),
basename: idPrefix.substring(idPrefix.lastIndexOf('/') + 1, idPrefix.indexOf('.')),
path: idPrefix,
extension: idPrefix.substring(idPrefix.indexOf('.')),
runNumber: 432,
groupCount: 2,
specDuration: {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"dependencies": {},
"devDependencies": {
"@cypress-design/vue-icon": "^0.4.2",
"@cypress-design/vue-icon": "^0.12.1",
"@graphql-typed-document-node/core": "^3.1.0",
"@headlessui/vue": "1.4.0",
"@iconify/iconify": "2.1.2",
Expand Down
39 changes: 39 additions & 0 deletions packages/app/src/composables/useDurationFormat.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ref } from 'vue'
import { useDurationFormat } from './useDurationFormat'

describe('useDurationFormat', () => {
it('should format duration', () => {
expect(useDurationFormat(1000).value).to.eq('00:01')
expect(useDurationFormat(60000).value).to.eq('01:00')
expect(useDurationFormat(6000000).value).to.eq('01:40:00')

// expects 24 hours and greater to "roll over" and not include day information
expect(useDurationFormat(86400000).value).to.eq('00:00')
})

it('should render with value', () => {
const duration = 1000
const formatted = useDurationFormat(duration)

cy.mount(() => (<div>
{formatted.value}
</div>))

cy.contains('00:01')
})

it('should render with ref and update if ref changes', () => {
const duration = ref(1000)
const formatted = useDurationFormat(duration)

cy.mount(() => (<div>
{formatted.value}
</div>))

cy.contains('00:01').then(() => {
duration.value = 2000
})

cy.contains('00:02')
})
})
13 changes: 13 additions & 0 deletions packages/app/src/composables/useDurationFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { computed, Ref, unref } from 'vue'
import { dayjs } from '../runs/utils/day.js'

/*
Format duration to in HH:mm:ss format. The `totalDuration` field is milliseconds. Remove the leading "00:" if the value is less
than an hour. Currently, there is no expectation that a run duration will be greater 24 hours or greater, so it is okay that
this format would "roll-over" in that scenario.
Ex: 1 second which is 1000ms = 00:01
Ex: 1 hour and 1 second which is 3601000ms = 01:00:01
*/
export function useDurationFormat (value: number | Ref<number>) {
return computed(() => dayjs.duration(unref(value)).format('HH:mm:ss').replace(/^0+:/, ''))
}
154 changes: 154 additions & 0 deletions packages/app/src/debug/DebugContainer.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { DebugSpecsFragmentDoc } from '../generated/graphql-test'
import DebugContainer from './DebugContainer.vue'
import { defaultMessages } from '@cy/i18n'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { specsList } from './utils/DebugMapping'
import { CloudRunStubs } from '@packages/graphql/test/stubCloudTypes'

describe('<DebugContainer />', () => {
context('empty states', () => {
const validateEmptyState = (expectedMessage: string) => {
cy.mountFragment(DebugSpecsFragmentDoc, {
render: (gqlVal) => {
return (
<DebugContainer
gql={gqlVal}
/>
)
},
})

cy.findByTestId('debug-empty').contains(expectedMessage)
}

it('shows not logged in', () => {
validateEmptyState(defaultMessages.debugPage.notLoggedIn)
})

it('is logged in', () => {
const loginConnectStore = useLoginConnectStore()

loginConnectStore.setUserFlag('isLoggedIn', true)

validateEmptyState(defaultMessages.debugPage.notConnected)
})

it('has no runs', () => {
const loginConnectStore = useLoginConnectStore()

loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
cy.mountFragment(DebugSpecsFragmentDoc, {
render: (gqlVal) => {
return (
<DebugContainer
gql={gqlVal}
/>
)
},
})

cy.findByTestId('debug-empty').contains(defaultMessages.debugPage.noRuns)
})
})

describe('render specs and tests', () => {
it('renders data when logged in and connected', () => {
const loginConnectStore = useLoginConnectStore()

loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
cy.mountFragment(DebugSpecsFragmentDoc, {
onResult: (result) => {
if (result.currentProject?.cloudProject?.__typename === 'CloudProject') {
const test = result.currentProject.cloudProject.runByNumber
const other = CloudRunStubs.failingWithTests as typeof test

result.currentProject.cloudProject.runByNumber = other
}
},
render: (gqlVal) => {
return (
<DebugContainer
gql={gqlVal}
/>
)
},
})

// Only asserting that it is rendering the components for failed specs
cy.findByTestId('debug-header').should('be.visible')
cy.findByTestId('debug-spec-item').should('be.visible')
})
})

describe('testing util function: debugMapping', () => {
const createSpecs = (idArr: string[]) => {
const acc: {id: string}[] = []

idArr.forEach((val) => {
acc.push({ id: val })
})

return acc
}

it('maps correctly for a single spec', () => {
const spec = createSpecs(['a1c'])
const tests = [
{ specId: 'a1c', id: 'random1' },
{ specId: 'a1c', id: 'random2' },
]

const debugMappingArray = specsList(spec, tests)

expect(debugMappingArray).to.have.length(1)
expect(debugMappingArray[0]).to.deep.equal({ spec: { id: 'a1c' }, tests: [{ specId: 'a1c', id: 'random1' }, { specId: 'a1c', id: 'random2' }] })
})

it('maps correctly for multiple specs and test', () => {
const specs = createSpecs(['123', '456', '789'])
const tests = [
{ specId: '123', id: 'random1' },
{ specId: '456', id: 'random2' },
{ specId: '456', id: 'random3' },
{ specId: '789', id: 'random4' },
{ specId: '123', id: 'random6' },
]

const debugMappingArray = specsList(specs, tests)

const expected = [
{ spec: { id: '123' }, tests: [{ specId: '123', id: 'random1' }, { specId: '123', id: 'random6' }] },
{ spec: { id: '456' }, tests: [{ specId: '456', id: 'random2' }, { specId: '456', id: 'random3' }] },
{ spec: { id: '789' }, tests: [{ specId: '789', id: 'random4' }] },
]

expect(debugMappingArray).to.deep.equal(expected)
})

it('maps does not show specs that do not have tests', () => {
const specs = createSpecs(['123', '456', '789'])
const tests = [{ specId: '123', id: 'random1' }]

const debugMappingArray = specsList(specs, tests)

expect(debugMappingArray).to.have.length(1)
expect(debugMappingArray).to.deep.equal([{ spec: { id: '123' }, tests: [{ specId: '123', id: 'random1' }] }])
})

it('throws an error when a test does not map to a spec', () => {
const specs = createSpecs(['123'])
const tests = [
{ specId: '123', id: 'random1' },
{ specId: '456', id: 'random2' },
]

const specsListWrapper = () => {
return specsList(specs, tests)
}

expect(specsListWrapper).to.throw('Could not find spec for id 456')
})
})
})
100 changes: 100 additions & 0 deletions packages/app/src/debug/DebugContainer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<template>
<div>
<div
v-if="loginConnectStore.user.isLoggedIn && loginConnectStore.project.isProjectConnected && run"
>
<DebugPageHeader
:gql="run"
:commits-ahead="0"
/>
<DebugSpecList
:specs="debugSpecsArray"
/>
</div>
<div
v-else
data-cy="debug-empty"
>
<div
v-if="!loginConnectStore.user.isLoggedIn"
>
{{ t('debugPage.notLoggedIn') }}
</div>
<div
v-else-if="!loginConnectStore.project.isProjectConnected"
>
{{ t('debugPage.notConnected' ) }}
</div>
<div
v-else-if="!run"
>
{{ t('debugPage.noRuns') }}
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { gql } from '@urql/vue'
import { computed } from '@vue/reactivity'
import type { DebugSpecsFragment } from '../generated/graphql'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import DebugPageHeader from './DebugPageHeader.vue'
import DebugSpecList from './DebugSpecList.vue'
import { useI18n } from 'vue-i18n'
import { specsList } from './utils/DebugMapping'
const { t } = useI18n()
gql`
fragment DebugSpecs on Query {
currentProject {
id
cloudProject {
__typename
... on CloudProject {
id
runByNumber(runNumber: 2) {
...DebugPage
id
runNumber
status
overLimitActionType
overLimitActionUrl
testsForReview {
id
...DebugSpecListTests
}
specs {
id
...DebugSpecListSpec
}
}
}
}
}
}
`
const props = defineProps<{
gql: DebugSpecsFragment
}>()
const loginConnectStore = useLoginConnectStore()
const run = computed(() => {
return props.gql.currentProject?.cloudProject?.__typename === 'CloudProject' ? props.gql.currentProject.cloudProject.runByNumber : null
})
const debugSpecsArray = computed(() => {
if (run.value) {
const specs = run.value.specs || []
const tests = run.value.testsForReview || []
return specsList(specs, tests)
}
return []
})
</script>
Loading

0 comments on commit ee859f6

Please sign in to comment.