Commit 3ba01aa2 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '215140-graph-the-average-code-coverage-of-all-of-a-group-s-projects' into 'master'

Resolve "Graph the average code coverage of all of a group's projects"

See merge request gitlab-org/gitlab!53319
parents a617e2f1 78d2f78e
...@@ -854,17 +854,7 @@ With [GitLab Issue Analytics](issues_analytics/index.md), you can see a bar char ...@@ -854,17 +854,7 @@ With [GitLab Issue Analytics](issues_analytics/index.md), you can see a bar char
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/263478) in GitLab 13.6. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/263478) in GitLab 13.6.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/276003) in GitLab 13.7. > - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/276003) in GitLab 13.7.
With [GitLab Repositories Analytics](repositories_analytics/index.md), you can download a CSV of the latest coverage data for all the projects in your group. With [GitLab Repositories Analytics](repositories_analytics/index.md), you can view overall activity of all projects with code coverage.
### Check code coverage for all projects
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/263478) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7.
See the overall activity of all projects with code coverage with [GitLab Repositories Analytics](repositories_analytics/index.md).
It displays the current code coverage data available for your projects:
![Group repositories analytics](img/group_code_coverage_analytics_v13_7.png)
## Dependency Proxy ## Dependency Proxy
......
...@@ -12,6 +12,25 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -12,6 +12,25 @@ info: To determine the technical writer assigned to the Stage/Group associated w
WARNING: WARNING:
This feature might not be available to you. Check the **version history** note above for details. This feature might not be available to you. Check the **version history** note above for details.
![Group repositories analytics](../img/group_code_coverage_analytics_v13_9.png)
## Current group code coverage
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/263478) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.7.
The **Analytics > Repositories** group page displays the overall test coverage of all your projects in your group.
In the **Overall activity** section, you can see:
- The number of projects with coverage reports.
- The average percentage of coverage across all your projects.
- The total number of pipeline jobs that produce coverage reports.
## Average group test coverage from the last 30 days
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/215140) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.9.
The **Analytics > Repositories** group page displays the average test coverage of all your projects in your group in a graph for the last 30 days.
## Latest project test coverage list ## Latest project test coverage list
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/267624) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/267624) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6.
......
...@@ -24,7 +24,7 @@ export default { ...@@ -24,7 +24,7 @@ export default {
<h4 data-testid="test-coverage-header"> <h4 data-testid="test-coverage-header">
{{ $options.text.codeCoverageHeader }} {{ $options.text.codeCoverageHeader }}
</h4> </h4>
<test-coverage-summary /> <test-coverage-summary class="gl-mb-5" />
<test-coverage-table class="gl-mb-5" /> <test-coverage-table class="gl-mb-5" />
<download-test-coverage /> <download-test-coverage />
</div> </div>
......
<script> <script>
import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { GlCard, GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import MetricCard from '~/analytics/shared/components/metric_card.vue'; import MetricCard from '~/analytics/shared/components/metric_card.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import getGroupTestCoverage from '../graphql/queries/get_group_test_coverage.query.graphql'; import getGroupTestCoverage from '../graphql/queries/get_group_test_coverage.query.graphql';
const formatPercent = getFormatter(SUPPORTED_FORMATS.percentHundred);
export default { export default {
name: 'TestCoverageSummary', name: 'TestCoverageSummary',
components: { components: {
ChartSkeletonLoader,
GlAreaChart,
GlCard,
GlSprintf,
MetricCard, MetricCard,
}, },
directives: {
SafeHtml,
},
inject: { inject: {
groupFullPath: { groupFullPath: {
default: '', default: '',
...@@ -17,26 +32,34 @@ export default { ...@@ -17,26 +32,34 @@ export default {
group: { group: {
query: getGroupTestCoverage, query: getGroupTestCoverage,
variables() { variables() {
const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // milliseconds const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; // milliseconds
return { return {
groupFullPath: this.groupFullPath, groupFullPath: this.groupFullPath,
startDate: new Date(Date.now() - ONE_WEEK), startDate: formatDate(new Date(Date.now() - THIRTY_DAYS), 'yyyy-mm-dd'),
}; };
}, },
result(res) { result({ data }) {
const groupCoverage = data.group.codeCoverageActivities.nodes;
const { projectCount, averageCoverage, coverageCount } = const { projectCount, averageCoverage, coverageCount } =
res.data?.group?.codeCoverageActivities?.nodes?.[0] || {}; groupCoverage?.[groupCoverage.length - 1] || {};
this.projectCount = projectCount; this.projectCount = projectCount;
this.averageCoverage = averageCoverage; this.averageCoverage = averageCoverage;
this.coverageCount = coverageCount; this.coverageCount = coverageCount;
this.groupCoverageChartData = [
{
name: this.$options.i18n.graphName,
data: groupCoverage.map((coverage) => [coverage.date, coverage.averageCoverage]),
},
];
}, },
error() { error() {
this.hasError = true; this.hasError = true;
this.projectCount = null; this.projectCount = null;
this.averageCoverage = null; this.averageCoverage = null;
this.coverageCount = null; this.coverageCount = null;
this.groupCoverageChartData = [];
}, },
watchLoading(isLoading) { watchLoading(isLoading) {
this.isLoading = isLoading; this.isLoading = isLoading;
...@@ -48,37 +71,135 @@ export default { ...@@ -48,37 +71,135 @@ export default {
projectCount: null, projectCount: null,
averageCoverage: null, averageCoverage: null,
coverageCount: null, coverageCount: null,
groupCoverageChartData: [],
coveragePercentage: null,
tooltipTitle: null,
hasError: false, hasError: false,
isLoading: false, isLoading: false,
}; };
}, },
i18n: {
cardTitle: __('Overall Activity'),
},
computed: { computed: {
isChartEmpty() {
return !this.groupCoverageChartData?.[0]?.data?.length;
},
metrics() { metrics() {
return [ return [
{ {
key: 'projectCount', key: 'projectCount',
value: this.projectCount, value: this.projectCount,
label: s__('RepositoriesAnalytics|Projects with Coverage'), label: this.$options.i18n.metrics.projectCountLabel,
}, },
{ {
key: 'averageCoverage', key: 'averageCoverage',
value: this.averageCoverage, value: this.averageCoverage,
unit: '%', unit: '%',
label: s__('RepositoriesAnalytics|Average Coverage by Job'), label: this.$options.i18n.metrics.averageCoverageLabel,
}, },
{ {
key: 'coverageCount', key: 'coverageCount',
value: this.coverageCount, value: this.coverageCount,
label: s__('RepositoriesAnalytics|Jobs with Coverage'), label: this.$options.i18n.metrics.coverageCountLabel,
}, },
]; ];
}, },
chartOptions() {
return {
xAxis: {
name: this.$options.i18n.xAxisName,
type: 'time',
axisLabel: {
formatter: (value) => formatDate(value, 'mmm dd'),
},
},
yAxis: {
name: this.$options.i18n.yAxisName,
type: 'value',
min: 0,
max: 100,
axisLabel: {
/**
* We can't do `formatter: formatPercent` because
* formatter passes in a second argument of index, which
* formatPercent takes in as the number of decimal points
* we should include after. This formats 100 as 100.00000%
* instead of 100%.
*/
formatter: (value) => formatPercent(value),
},
},
};
},
}, },
methods: {
formatTooltipText(params) {
this.tooltipTitle = formatDate(params.value, 'mmm dd');
this.coveragePercentage = formatPercent(params.seriesData?.[0]?.data?.[1], 2);
},
},
i18n: {
emptyChart: s__('RepositoriesAnalytics|No test coverage to display'),
graphCardHeader: s__('RepositoriesAnalytics|Average test coverage last 30 days'),
yAxisName: __('Coverage'),
xAxisName: __('Date'),
graphName: s__('RepositoriesAnalytics|Average coverage'),
graphTooltipMessage: __('Code Coverage: %{coveragePercentage}'),
metrics: {
cardTitle: s__('RepositoriesAnalytics|Overall activity'),
projectCountLabel: s__('RepositoriesAnalytics|Projects with Coverage'),
averageCoverageLabel: s__('RepositoriesAnalytics|Average Coverage by Job'),
coverageCountLabel: s__('RepositoriesAnalytics|Jobs with Coverage'),
},
},
chartEmptyStateIllustration,
}; };
</script> </script>
<template> <template>
<metric-card :title="$options.i18n.cardTitle" :metrics="metrics" :is-loading="isLoading" /> <div>
<metric-card
:title="$options.i18n.metrics.cardTitle"
:metrics="metrics"
:is-loading="isLoading"
/>
<gl-card>
<template #header>
<h5>{{ $options.i18n.graphCardHeader }}</h5>
</template>
<chart-skeleton-loader v-if="isLoading" data-testid="group-coverage-chart-loading" />
<div
v-else-if="isChartEmpty"
class="d-flex flex-column justify-content-center gl-my-7"
data-testid="group-coverage-chart-empty"
>
<div
v-safe-html="$options.chartEmptyStateIllustration"
class="gl-my-5 svg-w-100 d-flex align-items-center"
data-testid="chart-empty-state-illustration"
></div>
<h5 class="text-center">{{ $options.i18n.emptyChart }}</h5>
</div>
<gl-area-chart
v-else
:data="groupCoverageChartData"
:option="chartOptions"
:include-legend-avg-max="false"
:format-tooltip-text="formatTooltipText"
data-testid="group-coverage-chart"
>
<template #tooltip-title>
{{ tooltipTitle }}
</template>
<template #tooltip-content>
<gl-sprintf :message="$options.i18n.graphTooltipMessage">
<template #coveragePercentage>
{{ coveragePercentage }}
</template>
</gl-sprintf>
</template>
</gl-area-chart>
</gl-card>
</div>
</template> </template>
query getGroupTestCoverage($groupFullPath: ID!, $startDate: Date!, $last: Int = 1) { query getGroupTestCoverage($groupFullPath: ID!, $startDate: Date!) {
group(fullPath: $groupFullPath) { group(fullPath: $groupFullPath) {
codeCoverageActivities(startDate: $startDate, last: $last) { codeCoverageActivities(startDate: $startDate) {
nodes { nodes {
projectCount projectCount
averageCoverage averageCoverage
......
---
title: Adds a historical coverage graph to the group repositories analytics page
merge_request: 53319
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Test coverage table component when group code coverage is available renders area chart with correct data 1`] = `
Array [
Object {
"data": Array [
Array [
"2020-01-10",
77.9,
],
Array [
"2020-01-11",
78.7,
],
Array [
"2020-01-12",
79.6,
],
],
"name": "test",
},
]
`;
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import TestCoverageSummary from 'ee/analytics/repository_analytics/components/test_coverage_summary.vue'; import TestCoverageSummary from 'ee/analytics/repository_analytics/components/test_coverage_summary.vue';
import getGroupTestCoverage from 'ee/analytics/repository_analytics/graphql/queries/get_group_test_coverage.query.graphql'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import MetricCard from '~/analytics/shared/components/metric_card.vue';
import waitForPromises from 'helpers/wait_for_promises';
const localVue = createLocalVue(); const localVue = createLocalVue();
describe('Test coverage table component', () => { describe('Test coverage table component', () => {
let wrapper; let wrapper;
let fakeApollo;
const findProjectsWithTests = () => wrapper.find('.js-metric-card-item:nth-child(1) h3'); const findProjectsWithTests = () => wrapper.find('.js-metric-card-item:nth-child(1) h3');
const findAverageCoverage = () => wrapper.find('.js-metric-card-item:nth-child(2) h3'); const findAverageCoverage = () => wrapper.find('.js-metric-card-item:nth-child(2) h3');
const findTotalCoverages = () => wrapper.find('.js-metric-card-item:nth-child(3) h3'); const findTotalCoverages = () => wrapper.find('.js-metric-card-item:nth-child(3) h3');
const findGroupCoverageChart = () => wrapper.findByTestId('group-coverage-chart');
const findChartLoadingState = () => wrapper.findByTestId('group-coverage-chart-loading');
const findChartEmptyState = () => wrapper.findByTestId('group-coverage-chart-empty');
const findLoadingState = () => wrapper.find(GlSkeletonLoading); const findLoadingState = () => wrapper.find(GlSkeletonLoading);
const createComponent = ({ data = {} } = {}, withApollo = false) => { const createComponent = ({ data = {} } = {}) => {
fakeApollo = createMockApollo([[getGroupTestCoverage, jest.fn().mockResolvedValue()]]); wrapper = extendedWrapper(
shallowMount(TestCoverageSummary, {
const props = { localVue,
localVue, data() {
data() { return {
return { projectCount: null,
projectCount: null, averageCoverage: null,
averageCoverage: null, coverageCount: null,
coverageCount: null, hasError: false,
hasError: false, isLoading: false,
isLoading: false, ...data,
...data, };
}; },
}, mocks: {
}; $apollo: {
if (withApollo) { queries: {
localVue.use(VueApollo); group: {
props.apolloProvider = fakeApollo; query: jest.fn().mockResolvedValue(),
} else { },
props.mocks = {
$apollo: {
queries: {
group: {
query: jest.fn().mockResolvedValue(),
}, },
}, },
}, },
}; stubs: {
} MetricCard,
},
wrapper = mount(TestCoverageSummary, props); }),
);
}; };
afterEach(() => { afterEach(() => {
...@@ -64,6 +60,13 @@ describe('Test coverage table component', () => { ...@@ -64,6 +60,13 @@ describe('Test coverage table component', () => {
expect(findAverageCoverage().text()).toBe('-'); expect(findAverageCoverage().text()).toBe('-');
expect(findTotalCoverages().text()).toBe('-'); expect(findTotalCoverages().text()).toBe('-');
}); });
it('renders empty chart state', () => {
createComponent();
expect(findChartEmptyState().exists()).toBe(true);
expect(findGroupCoverageChart().exists()).toBe(false);
});
}); });
describe('when query is loading', () => { describe('when query is loading', () => {
...@@ -71,6 +74,7 @@ describe('Test coverage table component', () => { ...@@ -71,6 +74,7 @@ describe('Test coverage table component', () => {
createComponent({ data: { isLoading: true } }); createComponent({ data: { isLoading: true } });
expect(findLoadingState().exists()).toBe(true); expect(findLoadingState().exists()).toBe(true);
expect(findChartLoadingState().exists()).toBe(true);
}); });
}); });
...@@ -92,29 +96,47 @@ describe('Test coverage table component', () => { ...@@ -92,29 +96,47 @@ describe('Test coverage table component', () => {
expect(findAverageCoverage().text()).toBe(`${averageCoverage} %`); expect(findAverageCoverage().text()).toBe(`${averageCoverage} %`);
expect(findTotalCoverages().text()).toBe(coverageCount); expect(findTotalCoverages().text()).toBe(coverageCount);
}); });
});
describe('when group has no coverage', () => { it('renders area chart with correct data', () => {
it('renders empty metrics', async () => {
createComponent({ createComponent({
withApollo: true, data: {
data: {}, groupCoverageChartData: [
queryData: { {
data: { name: 'test',
group: { data: [
codeCoverageActivities: { ['2020-01-10', 77.9],
nodes: [], ['2020-01-11', 78.7],
}, ['2020-01-12', 79.6],
],
}, },
}, ],
}, },
}); });
jest.runOnlyPendingTimers();
await waitForPromises();
expect(findProjectsWithTests().text()).toBe('-'); expect(findGroupCoverageChart().exists()).toBe(true);
expect(findAverageCoverage().text()).toBe('-'); expect(findGroupCoverageChart().props('data')).toMatchSnapshot();
expect(findTotalCoverages().text()).toBe('-'); });
it('formats the area chart labels correctly', () => {
createComponent({
data: {
groupCoverageChartData: [
{
name: 'test',
data: [
['2020-01-10', 77.9],
['2020-01-11', 78.7],
['2020-01-12', 79.6],
],
},
],
},
});
expect(findGroupCoverageChart().props('option').xAxis.axisLabel.formatter('2020-01-10')).toBe(
'Jan 10',
);
expect(findGroupCoverageChart().props('option').yAxis.axisLabel.formatter(80)).toBe('80%');
}); });
}); });
}); });
...@@ -7199,6 +7199,9 @@ msgstr "" ...@@ -7199,6 +7199,9 @@ msgstr ""
msgid "Code" msgid "Code"
msgstr "" msgstr ""
msgid "Code Coverage: %{coveragePercentage}"
msgstr ""
msgid "Code Coverage: %{coveragePercentage}%{percentSymbol}" msgid "Code Coverage: %{coveragePercentage}%{percentSymbol}"
msgstr "" msgstr ""
...@@ -20931,9 +20934,6 @@ msgstr "" ...@@ -20931,9 +20934,6 @@ msgstr ""
msgid "Outdent" msgid "Outdent"
msgstr "" msgstr ""
msgid "Overall Activity"
msgstr ""
msgid "Overridden" msgid "Overridden"
msgstr "" msgstr ""
...@@ -24834,6 +24834,12 @@ msgstr "" ...@@ -24834,6 +24834,12 @@ msgstr ""
msgid "RepositoriesAnalytics|Average Coverage by Job" msgid "RepositoriesAnalytics|Average Coverage by Job"
msgstr "" msgstr ""
msgid "RepositoriesAnalytics|Average coverage"
msgstr ""
msgid "RepositoriesAnalytics|Average test coverage last 30 days"
msgstr ""
msgid "RepositoriesAnalytics|Coverage" msgid "RepositoriesAnalytics|Coverage"
msgstr "" msgstr ""
...@@ -24861,6 +24867,12 @@ msgstr "" ...@@ -24861,6 +24867,12 @@ msgstr ""
msgid "RepositoriesAnalytics|Latest test coverage results" msgid "RepositoriesAnalytics|Latest test coverage results"
msgstr "" msgstr ""
msgid "RepositoriesAnalytics|No test coverage to display"
msgstr ""
msgid "RepositoriesAnalytics|Overall activity"
msgstr ""
msgid "RepositoriesAnalytics|Please select a project or multiple projects to display their most recent test coverage data." msgid "RepositoriesAnalytics|Please select a project or multiple projects to display their most recent test coverage data."
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment