Commit 399182b2 authored by Miguel Rincon's avatar Miguel Rincon Committed by Jose Ivan Vargas

Add basic runner details in admin view runner

This change adds basic details for the read-only admin page for runners
as a first MCV of the page.

This page is behind a feature flag, so no changelog is included.
parent 4f1601c7
import { initAdminRunnerShow } from '~/runner/admin_runner_show';
initAdminRunnerShow();
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
import { I18N_FETCH_ERROR } from '../constants';
import getRunnerQuery from '../graphql/get_runner.query.graphql';
import { captureException } from '../sentry_utils';
export default {
name: 'AdminRunnerShowApp',
components: {
RunnerEditButton,
RunnerHeader,
RunnerDetails,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
runnerId: {
type: String,
required: true,
},
},
data() {
return {
runner: null,
};
},
apollo: {
runner: {
query: getRunnerQuery,
variables() {
return {
id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
};
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
},
},
},
computed: {
canUpdate() {
return this.runner.userPermissions?.updateRunner;
},
},
errorCaptured(error) {
this.reportToSentry(error);
},
methods: {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
};
</script>
<template>
<div>
<runner-header v-if="runner" :runner="runner">
<template #actions>
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
</template>
</runner-header>
<runner-details :runner="runner" />
</div>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import AdminRunnerShowApp from './admin_runner_show_app.vue';
Vue.use(VueApollo);
export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => {
const el = document.querySelector(selector);
if (!el) {
return null;
}
const { runnerId } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
render(h) {
return h(AdminRunnerShowApp, {
props: {
runnerId,
},
});
},
});
};
...@@ -6,9 +6,9 @@ import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphq ...@@ -6,9 +6,9 @@ import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphq
import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils'; import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerEditButton from '../runner_edit_button.vue';
import RunnerDeleteModal from '../runner_delete_modal.vue'; import RunnerDeleteModal from '../runner_delete_modal.vue';
const I18N_EDIT = __('Edit');
const I18N_PAUSE = __('Pause'); const I18N_PAUSE = __('Pause');
const I18N_RESUME = __('Resume'); const I18N_RESUME = __('Resume');
const I18N_DELETE = s__('Runners|Delete runner'); const I18N_DELETE = s__('Runners|Delete runner');
...@@ -19,6 +19,7 @@ export default { ...@@ -19,6 +19,7 @@ export default {
components: { components: {
GlButton, GlButton,
GlButtonGroup, GlButtonGroup,
RunnerEditButton,
RunnerDeleteModal, RunnerDeleteModal,
}, },
directives: { directives: {
...@@ -147,7 +148,6 @@ export default { ...@@ -147,7 +148,6 @@ export default {
captureException({ error, component: this.$options.name }); captureException({ error, component: this.$options.name });
}, },
}, },
I18N_EDIT,
I18N_DELETE, I18N_DELETE,
}; };
</script> </script>
...@@ -161,14 +161,7 @@ export default { ...@@ -161,14 +161,7 @@ export default {
See https://gitlab.com/gitlab-org/gitlab/-/issues/334802 See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
--> -->
<gl-button <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
v-if="canUpdate && runner.editAdminUrl"
v-gl-tooltip.hover.viewport="$options.I18N_EDIT"
:href="runner.editAdminUrl"
:aria-label="$options.I18N_EDIT"
icon="pencil"
data-testid="edit-runner"
/>
<gl-button <gl-button
v-if="canUpdate" v-if="canUpdate"
v-gl-tooltip.hover.viewport="toggleActiveTitle" v-gl-tooltip.hover.viewport="toggleActiveTitle"
......
<script>
import { __ } from '~/locale';
/**
* Usage:
*
* With a `value` prop:
*
* <runner-detail label="Field Name" :value="value" />
*
* Or a `value` slot:
*
* <runner-detail label="Field Name">
* <template #value>
* <strong>{{ value }}</strong>
* </template>
* </runner-detail>
*
*/
export default {
props: {
label: {
type: String,
required: true,
},
value: {
type: String,
default: null,
required: false,
},
emptyValue: {
type: String,
default: __('None'),
required: false,
},
},
};
</script>
<template>
<div class="gl-display-flex gl-pb-4">
<dt class="gl-mr-2">{{ label }}</dt>
<dd class="gl-mb-0">
<template v-if="value || $slots.value">
<slot name="value">{{ value }}</slot>
</template>
<span v-else class="gl-text-gray-500">{{ emptyValue }}</span>
</dd>
</div>
</template>
<script>
import { 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 } from '../constants';
import RunnerDetail from './runner_detail.vue';
import RunnerTags from './runner_tags.vue';
export default {
components: {
GlTabs,
GlTab,
GlIntersperse,
RunnerDetail,
RunnerTags,
TimeAgo,
},
props: {
runner: {
type: Object,
required: false,
default: null,
},
},
computed: {
maximumTimeout() {
const { maximumTimeout } = this.runner;
if (typeof maximumTimeout !== 'number') {
return null;
}
return timeIntervalInWords(maximumTimeout);
},
configTextProtected() {
if (this.runner.accessLevel === ACCESS_LEVEL_REF_PROTECTED) {
return s__('Runners|Protected');
}
return null;
},
configTextUntagged() {
if (this.runner.runUntagged) {
return s__('Runners|Runs untagged jobs');
}
return null;
},
},
ACCESS_LEVEL_REF_PROTECTED,
};
</script>
<template>
<gl-tabs>
<gl-tab>
<template #title>{{ s__('Runners|Details') }}</template>
<div v-if="runner" class="gl-py-4">
<dl>
<runner-detail :label="s__('Runners|Description')" :value="runner.description" />
<runner-detail
:label="s__('Runners|Last contact')"
:empty-value="s__('Runners|Never contacted')"
>
<template #value>
<time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Version')" :value="runner.version" />
<runner-detail :label="s__('Runners|IP Address')" :value="runner.ipAddress" />
<runner-detail :label="s__('Runners|Configuration')">
<template #value>
<gl-intersperse v-if="configTextProtected || configTextUntagged">
<span v-if="configTextProtected">{{ configTextProtected }}</span>
<span v-if="configTextUntagged">{{ configTextUntagged }}</span>
</gl-intersperse>
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
<runner-detail :label="s__('Runners|Tags')">
<template #value>
<runner-tags
v-if="runner.tagList && runner.tagList.length"
class="gl-vertical-align-middle"
:tag-list="runner.tagList"
size="sm"
/>
</template>
</runner-detail>
</dl>
</div>
</gl-tab>
</gl-tabs>
</template>
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
const I18N_EDIT = __('Edit');
export default {
components: {
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
I18N_EDIT,
};
</script>
<template>
<gl-button
v-gl-tooltip="$options.I18N_EDIT"
v-bind="$attrs"
:aria-label="$options.I18N_EDIT"
icon="pencil"
v-on="$listeners"
/>
</template>
<script> <script>
import { GlSprintf } from '@gitlab/ui'; import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { I18N_DETAILS_TITLE } from '../constants'; import { I18N_DETAILS_TITLE, I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
import RunnerTypeBadge from './runner_type_badge.vue'; import RunnerTypeBadge from './runner_type_badge.vue';
import RunnerStatusBadge from './runner_status_badge.vue'; import RunnerStatusBadge from './runner_status_badge.vue';
export default { export default {
components: { components: {
GlIcon,
GlSprintf, GlSprintf,
TimeAgo, TimeAgo,
RunnerTypeBadge, RunnerTypeBadge,
RunnerStatusBadge, RunnerStatusBadge,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
props: { props: {
runner: { runner: {
type: Object, type: Object,
...@@ -29,24 +33,36 @@ export default { ...@@ -29,24 +33,36 @@ export default {
return sprintf(I18N_DETAILS_TITLE, { runner_id: id }); return sprintf(I18N_DETAILS_TITLE, { runner_id: id });
}, },
}, },
I18N_LOCKED_RUNNER_DESCRIPTION,
}; };
</script> </script>
<template> <template>
<div class="gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> <div
<runner-status-badge :runner="runner" /> class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
<runner-type-badge v-if="runner" :type="runner.runnerType" /> >
<template v-if="runner.createdAt"> <div>
<gl-sprintf :message="__('%{runner} created %{timeago}')"> <runner-status-badge :runner="runner" />
<template #runner> <runner-type-badge v-if="runner" :type="runner.runnerType" />
<strong>{{ heading }}</strong> <template v-if="runner.createdAt">
</template> <gl-sprintf :message="__('%{runner} created %{timeago}')">
<template #timeago> <template #runner>
<time-ago :time="runner.createdAt" /> <strong>{{ heading }}</strong>
</template> <gl-icon
</gl-sprintf> v-if="runner.locked"
</template> v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
<template v-else> name="lock"
<strong>{{ heading }}</strong> :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
</template> />
</template>
<template #timeago>
<time-ago :time="runner.createdAt" />
</template>
</gl-sprintf>
</template>
<template v-else>
<strong>{{ heading }}</strong>
</template>
</div>
<div class="gl-ml-auto"><slot name="actions"></slot></div>
</div> </div>
</template> </template>
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
}; };
</script> </script>
<template> <template>
<div> <span>
<runner-tag <runner-tag
v-for="tag in tagList" v-for="tag in tagList"
:key="tag" :key="tag"
...@@ -28,5 +28,5 @@ export default { ...@@ -28,5 +28,5 @@ export default {
:tag="tag" :tag="tag"
:size="size" :size="size"
/> />
</div> </span>
</template> </template>
...@@ -11,4 +11,11 @@ fragment RunnerDetailsShared on CiRunner { ...@@ -11,4 +11,11 @@ fragment RunnerDetailsShared on CiRunner {
tagList tagList
createdAt createdAt
status(legacyMode: null) status(legacyMode: null)
contactedAt
version
editAdminUrl
userPermissions {
updateRunner
deleteRunner
}
} }
...@@ -5,4 +5,4 @@ ...@@ -5,4 +5,4 @@
- page_title title - page_title title
- add_to_breadcrumbs _('Runners'), admin_runners_path - add_to_breadcrumbs _('Runners'), admin_runners_path
-# Empty view in development behind feature flag runner_read_only_admin_view #js-admin-runner-show{ data: {runner_id: @runner.id} }
...@@ -30738,6 +30738,9 @@ msgstr "" ...@@ -30738,6 +30738,9 @@ msgstr ""
msgid "Runners|Command to register runner" msgid "Runners|Command to register runner"
msgstr "" msgstr ""
msgid "Runners|Configuration"
msgstr ""
msgid "Runners|Copy instructions" msgid "Runners|Copy instructions"
msgstr "" msgstr ""
...@@ -30756,6 +30759,9 @@ msgstr "" ...@@ -30756,6 +30759,9 @@ msgstr ""
msgid "Runners|Description" msgid "Runners|Description"
msgstr "" msgstr ""
msgid "Runners|Details"
msgstr ""
msgid "Runners|Download and install binary" msgid "Runners|Download and install binary"
msgstr "" msgstr ""
...@@ -30906,6 +30912,9 @@ msgstr "" ...@@ -30906,6 +30912,9 @@ msgstr ""
msgid "Runners|Runners in this group: %{groupRunnersCount}" msgid "Runners|Runners in this group: %{groupRunnersCount}"
msgstr "" msgstr ""
msgid "Runners|Runs untagged jobs"
msgstr ""
msgid "Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner." msgid "Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner."
msgstr "" msgstr ""
......
...@@ -476,6 +476,42 @@ RSpec.describe "Admin Runners" do ...@@ -476,6 +476,42 @@ RSpec.describe "Admin Runners" do
end end
end end
describe "Runner show page", :js do
let(:runner) do
create(
:ci_runner,
description: 'runner-foo',
version: '14.0',
ip_address: '127.0.0.1',
tag_list: %w(tag1 tag2)
)
end
before do
visit admin_runner_path(runner)
end
describe 'runner show page breadcrumbs' do
it 'contains the current runner id and token' do
page.within '[data-testid="breadcrumb-links"]' do
expect(page.find('h2')).to have_link("##{runner.id} (#{runner.short_sha})")
end
end
end
it 'shows runner details' do
aggregate_failures do
expect(page).to have_content 'Description runner-foo'
expect(page).to have_content 'Last contact Never contacted'
expect(page).to have_content 'Version 14.0'
expect(page).to have_content 'IP Address 127.0.0.1'
expect(page).to have_content 'Configuration Runs untagged jobs'
expect(page).to have_content 'Maximum job timeout None'
expect(page).to have_content 'Tags tag1 tag2'
end
end
end
describe "Runner edit page" do describe "Runner edit page" do
let(:runner) { create(:ci_runner) } let(:runner) { create(:ci_runner) }
...@@ -487,7 +523,7 @@ RSpec.describe "Admin Runners" do ...@@ -487,7 +523,7 @@ RSpec.describe "Admin Runners" do
wait_for_requests wait_for_requests
end end
describe 'runner page breadcrumbs' do describe 'runner edit page breadcrumbs' do
it 'contains the current runner id and token' do it 'contains the current runner id and token' do
page.within '[data-testid="breadcrumb-links"]' do page.within '[data-testid="breadcrumb-links"]' do
expect(page).to have_link("##{runner.id} (#{runner.short_sha})") expect(page).to have_link("##{runner.id} (#{runner.short_sha})")
......
...@@ -55,10 +55,11 @@ describe('AdminRunnerEditApp', () => { ...@@ -55,10 +55,11 @@ describe('AdminRunnerEditApp', () => {
expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId }); expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId });
}); });
it('displays the runner id', async () => { it('displays the runner id and creation date', async () => {
await createComponentWithApollo({ mountFn: mount }); await createComponentWithApollo({ mountFn: mount });
expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId} created`); expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
expect(findRunnerHeader().text()).toContain('created');
}); });
it('displays the runner type and status', async () => { it('displays the runner type and status', async () => {
......
import Vue from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
import RunnerDetails from '~/runner/components/runner_details.vue';
import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql';
import AdminRunnerShowApp from '~/runner/admin_runner_show/admin_runner_show_app.vue';
import { captureException } from '~/runner/sentry_utils';
import { runnerData } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunnerGraphqlId = runnerData.data.runner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
Vue.use(VueApollo);
describe('AdminRunnerShowApp', () => {
let wrapper;
let mockRunnerQuery;
const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
const findRunnerDetails = () => wrapper.findComponent(RunnerDetails);
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(AdminRunnerShowApp, {
apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
...props,
},
});
return waitForPromises();
};
beforeEach(() => {
mockRunnerQuery = jest.fn().mockResolvedValue(runnerData);
});
afterEach(() => {
mockRunnerQuery.mockReset();
wrapper.destroy();
});
describe('When showing runner details', () => {
beforeEach(async () => {
await createComponent({ mountFn: mount });
});
it('expect GraphQL ID to be requested', async () => {
expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId });
});
it('displays the runner header', async () => {
expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
});
it('shows basic runner details', async () => {
const expected = `Details
Description Instance runner
Last contact Never contacted
Version 1.0.0
IP Address 127.0.0.1
Configuration Runs untagged jobs
Maximum job timeout None
Tags None`.replace(/\s+/g, ' ');
expect(findRunnerDetails().text()).toMatchInterpolatedText(expected);
});
});
describe('When there is an error', () => {
beforeEach(async () => {
mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!'));
await createComponent();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Network error: Error!'),
component: 'AdminRunnerShowApp',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalled();
});
});
});
...@@ -9,6 +9,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -9,6 +9,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { captureException } from '~/runner/sentry_utils'; import { captureException } from '~/runner/sentry_utils';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue'; import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
...@@ -33,7 +34,7 @@ describe('RunnerTypeCell', () => { ...@@ -33,7 +34,7 @@ describe('RunnerTypeCell', () => {
const runnerDeleteMutationHandler = jest.fn(); const runnerDeleteMutationHandler = jest.fn();
const runnerActionsUpdateMutationHandler = jest.fn(); const runnerActionsUpdateMutationHandler = jest.fn();
const findEditBtn = () => wrapper.findByTestId('edit-runner'); const findEditBtn = () => wrapper.findComponent(RunnerEditButton);
const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal); const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
......
import { GlSprintf, GlIntersperse } from '@gitlab/ui';
import { createWrapper, ErrorWrapper } from '@vue/test-utils';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { useFakeDate } from 'helpers/fake_date';
import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants';
import RunnerDetails from '~/runner/components/runner_details.vue';
import RunnerDetail from '~/runner/components/runner_detail.vue';
import { runnerData } from '../mock_data';
const mockRunner = runnerData.data.runner;
describe('RunnerDetails', () => {
let wrapper;
const mockNow = '2021-01-15T12:00:00Z';
const mockOneHourAgo = '2021-01-15T11:00:00Z';
useFakeDate(mockNow);
/**
* Find the definition (<dd>) that corresponds to this term (<dt>)
* @param {string} dtLabel - Label for this value
* @returns Wrapper
*/
const findDd = (dtLabel) => {
const dt = wrapper.findByText(dtLabel).element;
const dd = dt.nextElementSibling;
if (dt.tagName === 'DT' && dd.tagName === 'DD') {
return createWrapper(dd, {});
}
return ErrorWrapper(dtLabel);
};
const createComponent = ({ runner = {}, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetails, {
propsData: {
runner: {
...mockRunner,
...runner,
},
},
stubs: {
GlIntersperse,
GlSprintf,
TimeAgo,
RunnerDetail,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe.each`
field | runner | expectedValue
${'Description'} | ${{ description: 'My runner' }} | ${'My runner'}
${'Description'} | ${{ description: null }} | ${'None'}
${'Last contact'} | ${{ contactedAt: mockOneHourAgo }} | ${'1 hour ago'}
${'Last contact'} | ${{ contactedAt: null }} | ${'Never contacted'}
${'Version'} | ${{ version: '12.3' }} | ${'12.3'}
${'Version'} | ${{ version: null }} | ${'None'}
${'IP Address'} | ${{ ipAddress: '127.0.0.1' }} | ${'127.0.0.1'}
${'IP Address'} | ${{ ipAddress: null }} | ${'None'}
${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: true }} | ${'Protected, Runs untagged jobs'}
${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_REF_PROTECTED, runUntagged: false }} | ${'Protected'}
${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED, runUntagged: true }} | ${'Runs untagged jobs'}
${'Configuration'} | ${{ accessLevel: ACCESS_LEVEL_NOT_PROTECTED, runUntagged: false }} | ${'None'}
${'Maximum job timeout'} | ${{ maximumTimeout: null }} | ${'None'}
${'Maximum job timeout'} | ${{ maximumTimeout: 0 }} | ${'0 seconds'}
${'Maximum job timeout'} | ${{ maximumTimeout: 59 }} | ${'59 seconds'}
${'Maximum job timeout'} | ${{ maximumTimeout: 10 * 60 + 5 }} | ${'10 minutes 5 seconds'}
`('"$field" field', ({ field, runner, expectedValue }) => {
beforeEach(() => {
createComponent({
runner,
});
});
it(`displays expected value "${expectedValue}"`, () => {
expect(findDd(field).text()).toBe(expectedValue);
});
});
describe('"Tags" field', () => {
it('displays expected value "tag-1 tag-2"', () => {
createComponent({
runner: { tagList: ['tag-1', 'tag-2'] },
mountFn: mountExtended,
});
expect(findDd('Tags').text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2');
});
it('displays "None" when runner has no tags', () => {
createComponent({
runner: { tagList: [] },
mountFn: mountExtended,
});
expect(findDd('Tags').text().replace(/\s+/g, ' ')).toBe('None');
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('RunnerEditButton', () => {
let wrapper;
const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value;
const createComponent = ({ attrs = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(RunnerEditButton, {
attrs,
directives: {
GlTooltip: createMockDirective(),
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays Edit text', () => {
expect(wrapper.attributes('aria-label')).toBe('Edit');
});
it('Displays Edit tooltip', () => {
expect(getTooltipValue()).toBe('Edit');
});
it('Renders a link and adds an href attribute', () => {
createComponent({ attrs: { href: '/edit' }, mountFn: mount });
expect(wrapper.element.tagName).toBe('A');
expect(wrapper.attributes('href')).toBe('/edit');
});
});
import { GlSprintf } from '@gitlab/ui'; import { GlSprintf } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { GROUP_TYPE, STATUS_ONLINE } from '~/runner/constants'; import { GROUP_TYPE, STATUS_ONLINE } from '~/runner/constants';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId } from '~/graphql_shared/utils';
...@@ -18,9 +18,10 @@ describe('RunnerHeader', () => { ...@@ -18,9 +18,10 @@ describe('RunnerHeader', () => {
const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge);
const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge); const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge);
const findRunnerLockedIcon = () => wrapper.findByTestId('lock-icon');
const findTimeAgo = () => wrapper.findComponent(TimeAgo); const findTimeAgo = () => wrapper.findComponent(TimeAgo);
const createComponent = ({ runner = {}, mountFn = shallowMount } = {}) => { const createComponent = ({ runner = {}, options = {}, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerHeader, { wrapper = mountFn(RunnerHeader, {
propsData: { propsData: {
runner: { runner: {
...@@ -32,6 +33,7 @@ describe('RunnerHeader', () => { ...@@ -32,6 +33,7 @@ describe('RunnerHeader', () => {
GlSprintf, GlSprintf,
TimeAgo, TimeAgo,
}, },
...options,
}); });
}; };
...@@ -41,24 +43,24 @@ describe('RunnerHeader', () => { ...@@ -41,24 +43,24 @@ describe('RunnerHeader', () => {
it('displays the runner status', () => { it('displays the runner status', () => {
createComponent({ createComponent({
mountFn: mount, mountFn: mountExtended,
runner: { runner: {
status: STATUS_ONLINE, status: STATUS_ONLINE,
}, },
}); });
expect(findRunnerStatusBadge().text()).toContain(`online`); expect(findRunnerStatusBadge().text()).toContain('online');
}); });
it('displays the runner type', () => { it('displays the runner type', () => {
createComponent({ createComponent({
mountFn: mount, mountFn: mountExtended,
runner: { runner: {
runnerType: GROUP_TYPE, runnerType: GROUP_TYPE,
}, },
}); });
expect(findRunnerTypeBadge().text()).toContain(`group`); expect(findRunnerTypeBadge().text()).toContain('group');
}); });
it('displays the runner id', () => { it('displays the runner id', () => {
...@@ -68,7 +70,18 @@ describe('RunnerHeader', () => { ...@@ -68,7 +70,18 @@ describe('RunnerHeader', () => {
}, },
}); });
expect(wrapper.text()).toContain(`Runner #99`); expect(wrapper.text()).toContain('Runner #99');
});
it('displays the runner locked icon', () => {
createComponent({
runner: {
locked: true,
},
mountFn: mountExtended,
});
expect(findRunnerLockedIcon().exists()).toBe(true);
}); });
it('displays the runner creation time', () => { it('displays the runner creation time', () => {
...@@ -78,7 +91,7 @@ describe('RunnerHeader', () => { ...@@ -78,7 +91,7 @@ describe('RunnerHeader', () => {
expect(findTimeAgo().props('time')).toBe(mockRunner.createdAt); expect(findTimeAgo().props('time')).toBe(mockRunner.createdAt);
}); });
it('does not display runner creation time if createdAt missing', () => { it('does not display runner creation time if "createdAt" is missing', () => {
createComponent({ createComponent({
runner: { runner: {
id: convertToGraphQLId(TYPE_CI_RUNNER, 99), id: convertToGraphQLId(TYPE_CI_RUNNER, 99),
...@@ -86,8 +99,21 @@ describe('RunnerHeader', () => { ...@@ -86,8 +99,21 @@ describe('RunnerHeader', () => {
}, },
}); });
expect(wrapper.text()).toContain(`Runner #99`); expect(wrapper.text()).toContain('Runner #99');
expect(wrapper.text()).not.toMatch(/created .+/); expect(wrapper.text()).not.toMatch(/created .+/);
expect(findTimeAgo().exists()).toBe(false); expect(findTimeAgo().exists()).toBe(false);
}); });
it('displays actions in a slot', () => {
createComponent({
options: {
slots: {
actions: '<div data-testid="actions-content">My Actions</div>',
},
mountFn: mountExtended,
},
});
expect(wrapper.findByTestId('actions-content').text()).toBe('My Actions');
});
}); });
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
} from 'helpers/vue_test_utils_helper'; } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue'; import RunnerList from '~/runner/components/runner_list.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import { runnersData } from '../mock_data'; import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes; const mockRunners = runnersData.data.runners.nodes;
...@@ -90,7 +91,7 @@ describe('RunnerList', () => { ...@@ -90,7 +91,7 @@ describe('RunnerList', () => {
// Actions // Actions
const actions = findCell({ fieldKey: 'actions' }); const actions = findCell({ fieldKey: 'actions' });
expect(actions.findByTestId('edit-runner').exists()).toBe(true); expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true);
}); });
......
...@@ -121,8 +121,18 @@ describe('RunnerUpdateForm', () => { ...@@ -121,8 +121,18 @@ describe('RunnerUpdateForm', () => {
it('Updates runner with no changes', async () => { it('Updates runner with no changes', async () => {
await submitFormAndWait(); await submitFormAndWait();
// Some fields are not submitted // Some read-only fields are not submitted
const { ipAddress, runnerType, createdAt, status, ...submitted } = mockRunner; const {
ipAddress,
runnerType,
createdAt,
status,
editAdminUrl,
contactedAt,
userPermissions,
version,
...submitted
} = mockRunner;
expectToHaveSubmittedRunnerContaining(submitted); expectToHaveSubmittedRunnerContaining(submitted);
}); });
......
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