diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/runner/components/cells/link_cell.vue new file mode 100644 index 0000000000000000000000000000000000000000..2843ddbacaf6a4d114839751aaed25a983f245cc --- /dev/null +++ b/app/assets/javascripts/runner/components/cells/link_cell.vue @@ -0,0 +1,27 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + props: { + href: { + type: String, + required: false, + default: null, + }, + }, + computed: { + component() { + if (this.href) { + return GlLink; + } + return 'span'; + }, + }, +}; +</script> + +<template> + <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners"> + <slot></slot> + </component> +</template> diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue index ab4b99d41864f61006b07089bf1ac0b2d3e435cf..b6a5ffc7a6420129a2c477ed8310b36374dc603b 100644 --- a/app/assets/javascripts/runner/components/runner_details.vue +++ b/app/assets/javascripts/runner/components/runner_details.vue @@ -1,22 +1,26 @@ <script> -import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui'; +import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui'; import { s__ } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants'; +import { formatJobCount } from '../utils'; import RunnerDetail from './runner_detail.vue'; import RunnerGroups from './runner_groups.vue'; import RunnerProjects from './runner_projects.vue'; +import RunnerJobs from './runner_jobs.vue'; import RunnerTags from './runner_tags.vue'; export default { components: { + GlBadge, GlTabs, GlTab, GlIntersperse, RunnerDetail, RunnerGroups, RunnerProjects, + RunnerJobs, RunnerTags, TimeAgo, }, @@ -53,6 +57,9 @@ export default { isProjectRunner() { return this.runner?.runnerType === PROJECT_TYPE; }, + jobCount() { + return formatJobCount(this.runner?.jobCount); + }, }, ACCESS_LEVEL_REF_PROTECTED, }; @@ -65,7 +72,7 @@ export default { <template v-if="runner"> <div class="gl-pt-4"> - <dl class="gl-mb-0"> + <dl class="gl-mb-0" data-testid="runner-details-list"> <runner-detail :label="s__('Runners|Description')" :value="runner.description" /> <runner-detail :label="s__('Runners|Last contact')" @@ -103,5 +110,15 @@ export default { <runner-projects v-if="isProjectRunner" :runner="runner" /> </template> </gl-tab> + <gl-tab> + <template #title> + {{ s__('Runners|Jobs') }} + <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm"> + {{ jobCount }} + </gl-badge> + </template> + + <runner-jobs v-if="runner" :runner="runner" /> + </gl-tab> </gl-tabs> </template> diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue new file mode 100644 index 0000000000000000000000000000000000000000..c13e7e90168ec6aae28139907548c207d17823b4 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_jobs.vue @@ -0,0 +1,82 @@ +<script> +import { GlSkeletonLoading } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql'; +import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants'; +import { captureException } from '../sentry_utils'; +import { getPaginationVariables } from '../utils'; +import RunnerJobsTable from './runner_jobs_table.vue'; +import RunnerPagination from './runner_pagination.vue'; + +export default { + name: 'RunnerJobs', + components: { + GlSkeletonLoading, + RunnerJobsTable, + RunnerPagination, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + data() { + return { + jobs: { + items: [], + pageInfo: {}, + }, + pagination: { + page: 1, + }, + }; + }, + apollo: { + jobs: { + query: getRunnerJobsQuery, + variables() { + return this.variables; + }, + update({ runner }) { + return { + items: runner?.jobs?.nodes || [], + pageInfo: runner?.jobs?.pageInfo || {}, + }; + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + const { id } = this.runner; + return { + id, + ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE), + }; + }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, + }, + I18N_NO_JOBS_FOUND, +}; +</script> + +<template> + <div class="gl-pt-3"> + <gl-skeleton-loading v-if="loading" class="gl-py-5" /> + <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" /> + <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p> + + <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" /> + </div> +</template> diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/runner/components/runner_jobs_table.vue new file mode 100644 index 0000000000000000000000000000000000000000..7817577bab07f44cb01dc51900c797828b0c1925 --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_jobs_table.vue @@ -0,0 +1,95 @@ +<script> +import { GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RunnerTags from '~/runner/components/runner_tags.vue'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { tableField } from '../utils'; +import LinkCell from './cells/link_cell.vue'; + +export default { + components: { + CiBadge, + GlTableLite, + LinkCell, + RunnerTags, + TimeAgo, + }, + props: { + jobs: { + type: Array, + required: true, + }, + }, + methods: { + trAttr(job) { + if (job?.id) { + return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` }; + } + return {}; + }, + jobId(job) { + return getIdFromGraphQLId(job.id); + }, + jobPath(job) { + return job.detailedStatus?.detailsPath; + }, + projectName(job) { + return job.pipeline?.project?.name; + }, + projectWebUrl(job) { + return job.pipeline?.project?.webUrl; + }, + commitShortSha(job) { + return job.shortSha; + }, + commitPath(job) { + return job.commitPath; + }, + }, + fields: [ + tableField({ key: 'status', label: s__('Job|Status') }), + tableField({ key: 'job', label: __('Job') }), + tableField({ key: 'project', label: __('Project') }), + tableField({ key: 'commit', label: __('Commit') }), + tableField({ key: 'finished_at', label: s__('Job|Finished at') }), + tableField({ key: 'tags', label: s__('Runners|Tags') }), + ], +}; +</script> + +<template> + <gl-table-lite + :items="jobs" + :fields="$options.fields" + :tbody-tr-attr="trAttr" + primary-key="id" + stacked="md" + fixed + > + <template #cell(status)="{ item = {} }"> + <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item = {} }"> + <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell> + </template> + + <template #cell(project)="{ item = {} }"> + <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell> + </template> + + <template #cell(commit)="{ item = {} }"> + <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell> + </template> + + <template #cell(tags)="{ item = {} }"> + <runner-tags :tag-list="item.tags" /> + </template> + + <template #cell(finished_at)="{ item = {} }"> + <time-ago v-if="item.finishedAt" :time="item.finishedAt" /> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 45e61768d1e7fc5e982877ab12263118b3d0e219..1544efaaae2f03163b635c87174d433e23aa3b5b 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -4,6 +4,7 @@ export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5; +export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); @@ -45,6 +46,7 @@ export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_NONE = __('None'); +export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.'); // Styles diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..2b1decd3dddbd3aea802f93e3d0fb53f374e11f8 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) { + runner(id: $id) { + id + projectCount + jobs(before: $before, after: $after, first: $first, last: $last) { + nodes { + id + detailedStatus { + # fields for `<ci-badge>` + id + detailsPath + group + icon + text + } + pipeline { + id + project { + id + name + webUrl + } + } + shortSha + commitPath + tags + finishedAt + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql index ae29fa3a4dfb1241185abf36c18644ecd4c3222a..74760bbaa0754bbfcda90374ffbced989ffacb01 100644 --- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql @@ -8,6 +8,7 @@ fragment RunnerDetailsShared on CiRunner { ipAddress description maximumTimeout + jobCount tagList createdAt status(legacyMode: null) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 76a86981cc8fb67e51d1e559fe6e7b5f4cac8ec3..e19b903fb18b2a3714ae7abac3f09c7262a4ebb5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -20878,6 +20878,9 @@ msgstr "" msgid "Job|Erase job log and artifacts" msgstr "" +msgid "Job|Finished at" +msgstr "" + msgid "Job|Job artifacts" msgstr "" @@ -20902,6 +20905,9 @@ msgstr "" msgid "Job|Show complete raw" msgstr "" +msgid "Job|Status" +msgstr "" + msgid "Job|The artifacts were removed" msgstr "" @@ -31321,6 +31327,9 @@ msgstr "" msgid "Runners|Instance" msgstr "" +msgid "Runners|Jobs" +msgstr "" + msgid "Runners|Last contact" msgstr "" @@ -31561,6 +31570,9 @@ msgstr "" msgid "Runners|stale" msgstr "" +msgid "Runner|This runner has not run any jobs." +msgstr "" + msgid "Running" msgstr "" diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index befb4e23b22b9f5ffea27b8c7304e11067bee79d..cdb4c3fd8ba6183102ae7bc7d5d7978bb86168b1 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -17,6 +17,7 @@ let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') } let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') } let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') } + let_it_be(:build) { create(:ci_build, runner: instance_runner) } query_path = 'runner/graphql/' fixtures_path = 'graphql/runner/' @@ -104,6 +105,22 @@ expect_graphql_errors_to_be_empty end end + + describe GraphQL::Query, type: :request do + get_runner_jobs_query_name = 'get_runner_jobs.query.graphql' + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_runner_jobs_query_name}") + end + + it "#{fixtures_path}#{get_runner_jobs_query_name}.json" do + post_graphql(query, current_user: admin, variables: { + id: instance_runner.to_global_id.to_s + }) + + expect_graphql_errors_to_be_empty + end + end end describe do diff --git a/spec/frontend/runner/components/cells/link_cell_spec.js b/spec/frontend/runner/components/cells/link_cell_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a59a0eaa5d80b008c24ec113acd960041e6c6c75 --- /dev/null +++ b/spec/frontend/runner/components/cells/link_cell_spec.js @@ -0,0 +1,72 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import LinkCell from '~/runner/components/cells/link_cell.vue'; + +describe('LinkCell', () => { + let wrapper; + + const findGlLink = () => wrapper.find(GlLink); + const findSpan = () => wrapper.find('span'); + + const createComponent = ({ props = {}, ...options } = {}) => { + wrapper = shallowMountExtended(LinkCell, { + propsData: { + ...props, + }, + ...options, + }); + }; + + it('when an href is provided, renders a link', () => { + createComponent({ props: { href: '/url' } }); + expect(findGlLink().exists()).toBe(true); + }); + + it('when an href is not provided, renders no link', () => { + createComponent(); + expect(findGlLink().exists()).toBe(false); + }); + + describe.each` + href | findContent + ${null} | ${findSpan} + ${'/url'} | ${findGlLink} + `('When href is $href', ({ href, findContent }) => { + const content = 'My Text'; + const attrs = { foo: 'bar' }; + const listeners = { + click: jest.fn(), + }; + + beforeEach(() => { + createComponent({ + props: { href }, + slots: { + default: content, + }, + attrs, + listeners, + }); + }); + + afterAll(() => { + listeners.click.mockReset(); + }); + + it('Renders content', () => { + expect(findContent().text()).toBe(content); + }); + + it('Passes attributes', () => { + expect(findContent().attributes()).toMatchObject(attrs); + }); + + it('Passes event listeners', () => { + expect(listeners.click).toHaveBeenCalledTimes(0); + + findContent().vm.$emit('click'); + + expect(listeners.click).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js index dbc96a30750eb0ad9190eea8b1b80c32d9783b34..6bf4a52a799e8a9f8565d11d00ba8e797a38978e 100644 --- a/spec/frontend/runner/components/runner_details_spec.js +++ b/spec/frontend/runner/components/runner_details_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf, GlIntersperse } from '@gitlab/ui'; +import { GlSprintf, GlIntersperse, GlTab } from '@gitlab/ui'; import { createWrapper, ErrorWrapper } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -8,6 +8,7 @@ import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner import RunnerDetails from '~/runner/components/runner_details.vue'; import RunnerDetail from '~/runner/components/runner_detail.vue'; import RunnerGroups from '~/runner/components/runner_groups.vue'; +import RunnersJobs from '~/runner/components/runner_jobs.vue'; import RunnerTags from '~/runner/components/runner_tags.vue'; import RunnerTag from '~/runner/components/runner_tag.vue'; @@ -38,6 +39,8 @@ describe('RunnerDetails', () => { }; const findDetailGroups = () => wrapper.findComponent(RunnerGroups); + const findRunnersJobs = () => wrapper.findComponent(RunnersJobs); + const findJobCountBadge = () => wrapper.findByTestId('job-count-badge'); const createComponent = ({ props = {}, mountFn = shallowMountExtended, stubs } = {}) => { wrapper = mountFn(RunnerDetails, { @@ -146,4 +149,41 @@ describe('RunnerDetails', () => { }); }); }); + + describe('Jobs tab', () => { + const stubs = { GlTab }; + + it('without a runner, shows no jobs', () => { + createComponent({ + props: { runner: null }, + stubs, + }); + + expect(findJobCountBadge().exists()).toBe(false); + expect(findRunnersJobs().exists()).toBe(false); + }); + + it('without a job count, shows no jobs count', () => { + createComponent({ + props: { + runner: { ...mockRunner, jobCount: undefined }, + }, + stubs, + }); + + expect(findJobCountBadge().exists()).toBe(false); + }); + + it('with a job count, shows jobs count', () => { + const runner = { ...mockRunner, jobCount: 3 }; + + createComponent({ + props: { runner }, + stubs, + }); + + expect(findJobCountBadge().text()).toBe('3'); + expect(findRunnersJobs().props('runner')).toBe(runner); + }); + }); }); diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..97339056370937b8e4b3bd1ebecc4ab8e7dcdc38 --- /dev/null +++ b/spec/frontend/runner/components/runner_jobs_spec.js @@ -0,0 +1,156 @@ +import { GlSkeletonLoading } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import RunnerJobs from '~/runner/components/runner_jobs.vue'; +import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; +import { captureException } from '~/runner/sentry_utils'; +import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/runner/constants'; + +import getRunnerJobsQuery from '~/runner/graphql/get_runner_jobs.query.graphql'; + +import { runnerData, runnerJobsData } from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + +const mockRunner = runnerData.data.runner; +const mockRunnerWithJobs = runnerJobsData.data.runner; +const mockJobs = mockRunnerWithJobs.jobs.nodes; + +Vue.use(VueApollo); + +describe('RunnerJobs', () => { + let wrapper; + let mockRunnerJobsQuery; + + const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading); + const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable); + const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + + const createComponent = ({ mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(RunnerJobs, { + apolloProvider: createMockApollo([[getRunnerJobsQuery, mockRunnerJobsQuery]]), + propsData: { + runner: mockRunner, + }, + }); + }; + + beforeEach(() => { + mockRunnerJobsQuery = jest.fn(); + }); + + afterEach(() => { + mockRunnerJobsQuery.mockReset(); + wrapper.destroy(); + }); + + it('Requests runner jobs', async () => { + createComponent(); + + await waitForPromises(); + + expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(1); + expect(mockRunnerJobsQuery).toHaveBeenCalledWith({ + id: mockRunner.id, + first: RUNNER_DETAILS_JOBS_PAGE_SIZE, + }); + }); + + describe('When there are jobs assigned', () => { + beforeEach(async () => { + mockRunnerJobsQuery.mockResolvedValueOnce(runnerJobsData); + + createComponent(); + await waitForPromises(); + }); + + it('Shows jobs', () => { + const jobs = findRunnerJobsTable().props('jobs'); + + expect(jobs).toHaveLength(mockJobs.length); + expect(jobs[0]).toMatchObject(mockJobs[0]); + }); + + describe('When "Next" page is clicked', () => { + beforeEach(async () => { + findRunnerPagination().vm.$emit('input', { page: 2, after: 'AFTER_CURSOR' }); + + await waitForPromises(); + }); + + it('A new page is requested', () => { + expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(2); + expect(mockRunnerJobsQuery).toHaveBeenLastCalledWith({ + id: mockRunner.id, + first: RUNNER_DETAILS_JOBS_PAGE_SIZE, + after: 'AFTER_CURSOR', + }); + }); + }); + }); + + describe('When loading', () => { + it('shows loading indicator and no other content', () => { + createComponent(); + + expect(findGlSkeletonLoading().exists()).toBe(true); + expect(findRunnerJobsTable().exists()).toBe(false); + expect(findRunnerPagination().attributes('disabled')).toBe('true'); + }); + }); + + describe('When there are no jobs', () => { + beforeEach(async () => { + mockRunnerJobsQuery.mockResolvedValueOnce({ + data: { + runner: { + id: mockRunner.id, + projectCount: 0, + jobs: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }, + }, + }); + + createComponent(); + await waitForPromises(); + }); + + it('Shows a "None" label', () => { + expect(wrapper.text()).toBe(I18N_NO_JOBS_FOUND); + }); + }); + + describe('When an error occurs', () => { + beforeEach(async () => { + mockRunnerJobsQuery.mockRejectedValue(new Error('Error!')); + + createComponent(); + await waitForPromises(); + }); + + it('shows an error', () => { + expect(createAlert).toHaveBeenCalled(); + }); + + it('reports an error', () => { + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerJobs', + error: expect.any(Error), + }); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_jobs_table_spec.js b/spec/frontend/runner/components/runner_jobs_table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5f4905ad2a86ac291e5f95e0def9530e8e48509e --- /dev/null +++ b/spec/frontend/runner/components/runner_jobs_table_spec.js @@ -0,0 +1,119 @@ +import { GlTableLite } from '@gitlab/ui'; +import { + extendedWrapper, + shallowMountExtended, + mountExtended, +} from 'helpers/vue_test_utils_helper'; +import { __, s__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { runnerJobsData } from '../mock_data'; + +const mockJobs = runnerJobsData.data.runner.jobs.nodes; + +describe('RunnerJobsTable', () => { + let wrapper; + const mockNow = '2021-01-15T12:00:00Z'; + const mockOneHourAgo = '2021-01-15T11:00:00Z'; + + useFakeDate(mockNow); + + const findTable = () => wrapper.findComponent(GlTableLite); + const findHeaders = () => wrapper.findAll('th'); + const findRows = () => wrapper.findAll('[data-testid^="job-row-"]'); + const findCell = ({ field }) => + extendedWrapper(findRows().at(0).find(`[data-testid="td-${field}"]`)); + + const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(RunnerJobsTable, { + propsData: { + jobs: mockJobs, + ...props, + }, + stubs: { + GlTableLite, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('Sets job id as a row key', () => { + createComponent(); + + expect(findTable().attributes('primarykey')).toBe('id'); + }); + + describe('Table data', () => { + beforeEach(() => { + createComponent({}, mountExtended); + }); + + it('Displays headers', () => { + const headerLabels = findHeaders().wrappers.map((w) => w.text()); + + expect(headerLabels).toEqual([ + s__('Job|Status'), + __('Job'), + __('Project'), + __('Commit'), + s__('Job|Finished at'), + s__('Runners|Tags'), + ]); + }); + + it('Displays a list of jobs', () => { + expect(findRows()).toHaveLength(1); + }); + + it('Displays details of a job', () => { + const { id, detailedStatus, pipeline, shortSha, commitPath } = mockJobs[0]; + + expect(findCell({ field: 'status' }).text()).toMatchInterpolatedText(detailedStatus.text); + + expect(findCell({ field: 'job' }).text()).toContain(`#${getIdFromGraphQLId(id)}`); + expect(findCell({ field: 'job' }).find('a').attributes('href')).toBe( + detailedStatus.detailsPath, + ); + + expect(findCell({ field: 'project' }).text()).toBe(pipeline.project.name); + expect(findCell({ field: 'project' }).find('a').attributes('href')).toBe( + pipeline.project.webUrl, + ); + + expect(findCell({ field: 'commit' }).text()).toBe(shortSha); + expect(findCell({ field: 'commit' }).find('a').attributes('href')).toBe(commitPath); + }); + }); + + describe('Table data formatting', () => { + let mockJobsCopy; + + beforeEach(() => { + mockJobsCopy = [ + { + ...mockJobs[0], + }, + ]; + }); + + it('Formats finishedAt time', () => { + mockJobsCopy[0].finishedAt = mockOneHourAgo; + + createComponent({ props: { jobs: mockJobsCopy } }, mountExtended); + + expect(findCell({ field: 'finished_at' }).text()).toBe('1 hour ago'); + }); + + it('Formats tags', () => { + mockJobsCopy[0].tags = ['tag-1', 'tag-2']; + + createComponent({ props: { jobs: mockJobsCopy } }, mountExtended); + + expect(findCell({ field: 'tags' }).text()).toMatchInterpolatedText('tag-1 tag-2'); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 36120a4c7ed9987941e34b580e56b81fee73fe67..8b76be396efa0d64c0d3de31ae54d6ba8ee94614 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -123,6 +123,7 @@ describe('RunnerUpdateForm', () => { // Some read-only fields are not submitted const { + __typename, ipAddress, runnerType, createdAt, @@ -132,7 +133,7 @@ describe('RunnerUpdateForm', () => { userPermissions, version, groups, - __typename, + jobCount, ...submitted } = mockRunner; diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 7260f0fbc9ad3b533423a6da9a6308551e2a2735..d80caa477528fc43b59fa521cdb4a163670ecee4 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -7,6 +7,7 @@ import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json'; import runnerWithGroupData from 'test_fixtures/graphql/runner/get_runner.query.graphql.with_group.json'; import runnerProjectsData from 'test_fixtures/graphql/runner/get_runner_projects.query.graphql.json'; +import runnerJobsData from 'test_fixtures/graphql/runner/get_runner_jobs.query.graphql.json'; // Group queries import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json'; @@ -20,6 +21,7 @@ export { runnerData, runnerWithGroupData, runnerProjectsData, + runnerJobsData, groupRunnersData, groupRunnersCountData, groupRunnersDataPaginated,