Commit 57422633 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '329658-migrate-admin-runner-list-from-haml-to-vue' into 'master'

Migrate admin runner list from HAML to Vue [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!61241
parents 40960ae6 e8c0d65c
......@@ -2,6 +2,7 @@ import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import { initRunnerList } from '~/runner/runner_list';
initFilteredSearch({
page: FILTERED_SEARCH.ADMIN_RUNNERS,
......@@ -10,3 +11,7 @@ initFilteredSearch({
});
initInstallRunner();
if (gon.features?.runnerListViewVueUi) {
initRunnerList();
}
<script>
import { GlLink } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
components: {
GlLink,
TooltipOnTruncate,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
runnerNumericalId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerUrl() {
// TODO implement using webUrl from the API
return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
},
description() {
return this.runner.description;
},
shortSha() {
return this.runner.shortSha;
},
},
};
</script>
<template>
<div>
<gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link>
<tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
<div class="gl-text-truncate">
{{ description }}
</div>
</tooltip-on-truncate>
</div>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
import RunnerTypeBadge from '../runner_type_badge.vue';
export default {
components: {
GlBadge,
RunnerTypeBadge,
},
props: {
runner: {
type: Object,
required: true,
},
},
computed: {
runnerType() {
return this.runner.runnerType;
},
locked() {
return this.runner.locked;
},
paused() {
return !this.runner.active;
},
},
};
</script>
<template>
<div>
<runner-type-badge :type="runnerType" size="sm" />
<gl-badge v-if="locked" variant="warning" size="sm">
{{ __('locked') }}
</gl-badge>
<gl-badge v-if="paused" variant="danger" size="sm">
{{ __('paused') }}
</gl-badge>
</div>
</template>
<script>
import { GlTable, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { formatNumber, sprintf, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerNameCell from './cells/runner_name_cell.vue';
import RunnerTypeCell from './cells/runner_type_cell.vue';
import RunnerTags from './runner_tags.vue';
const tableField = ({ key, label = '', width = 10 }) => {
return {
key,
label,
thClass: [
`gl-w-${width}p`,
'gl-bg-transparent!',
'gl-border-b-solid!',
'gl-border-b-gray-100!',
'gl-py-5!',
'gl-px-0!',
'gl-border-b-1!',
],
tdClass: ['gl-py-5!', 'gl-px-1!'],
tdAttr: {
'data-testid': `td-${key}`,
},
};
};
export default {
components: {
GlTable,
GlSkeletonLoader,
TimeAgo,
RunnerNameCell,
RunnerTags,
RunnerTypeCell,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
loading: {
type: Boolean,
required: false,
default: false,
},
runners: {
type: Array,
required: true,
},
activeRunnersCount: {
type: Number,
required: true,
},
},
computed: {
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
},
methods: {
runnerTrAttr(runner) {
if (runner) {
return {
'data-testid': `runner-row-${getIdFromGraphQLId(runner.id)}`,
};
}
return {};
},
},
fields: [
tableField({ key: 'type', label: __('Type/State') }),
tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
tableField({ key: 'jobCount', label: __('Jobs'), width: 5 }),
tableField({ key: 'tagList', label: __('Tags') }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
],
};
</script>
<template>
<div>
<div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
<gl-table
:busy="loading"
:items="runners"
:fields="$options.fields"
:tbody-tr-attr="runnerTrAttr"
stacked="md"
fixed
>
<template #table-busy>
<gl-skeleton-loader />
</template>
<template #cell(type)="{ item }">
<runner-type-cell :runner="item" />
</template>
<template #cell(name)="{ item }">
<runner-name-cell :runner="item" />
</template>
<template #cell(version)="{ item: { version } }">
{{ version }}
</template>
<template #cell(ipAddress)="{ item: { ipAddress } }">
{{ ipAddress }}
</template>
<template #cell(projectCount)>
<!-- TODO add projects count -->
</template>
<template #cell(jobCount)>
<!-- TODO add jobs count -->
</template>
<template #cell(tagList)="{ item: { tagList } }">
<runner-tags :tag-list="tagList" size="sm" />
</template>
<template #cell(contactedAt)="{ item: { contactedAt } }">
<time-ago v-if="contactedAt" :time="contactedAt" />
<template v-else>{{ __('Never') }}</template>
</template>
<template #cell(actions)>
<!-- TODO add actions to update runners -->
</template>
</gl-table>
<!-- TODO implement pagination -->
</div>
</template>
<script>
import { GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
export default {
components: {
GlLink,
GlSprintf,
ClipboardButton,
RunnerInstructions,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
runnerInstallHelpPage: {
default: null,
},
},
props: {
registrationToken: {
type: String,
required: true,
},
typeName: {
type: String,
required: false,
default: __('shared'),
},
},
computed: {
rootUrl() {
return gon.gitlab_url || '';
},
},
};
</script>
<template>
<div class="bs-callout">
<h5 data-testid="runner-help-title">
<gl-sprintf :message="__('Set up a %{type} runner manually')">
<template #type>
{{ typeName }}
</template>
</gl-sprintf>
</h5>
<ol>
<li>
<gl-link :href="runnerInstallHelpPage" data-testid="runner-help-link" target="_blank">
{{ __("Install GitLab Runner and ensure it's running.") }}
</gl-link>
</li>
<li>
{{ __('Register the runner with this URL:') }}
<br />
<code data-testid="coordinator-url">{{ rootUrl }}</code>
<clipboard-button :title="__('Copy URL')" :text="rootUrl" />
</li>
<li>
{{ __('And this registration token:') }}
<br />
<code data-testid="registration-token">{{ registrationToken }}</code>
<clipboard-button :title="__('Copy token')" :text="registrationToken" />
</li>
</ol>
<!-- TODO Implement reset token functionality -->
<runner-instructions />
</div>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
export default {
components: {
GlBadge,
},
props: {
tagList: {
type: Array,
required: false,
default: () => [],
},
size: {
type: String,
required: false,
default: 'md',
},
variant: {
type: String,
required: false,
default: 'info',
},
},
};
</script>
<template>
<div>
<gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant">
{{ tag }}
</gl-badge>
</div>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerTypeBadge from './runner_type_badge.vue';
export default {
components: {
GlBadge,
RunnerTypeBadge,
},
runnerTypes: {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
},
};
</script>
<template>
<div class="bs-callout">
<p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p>
<p>
{{
__(
'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.',
)
}}
</p>
<div>
<span> {{ __('Runners can be:') }}</span>
<ul>
<li>
<runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" />
- {{ __('Runs jobs from all unassigned projects.') }}
</li>
<li>
<runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" />
- {{ __('Runs jobs from all unassigned projects in its group.') }}
</li>
<li>
<runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" />
- {{ __('Runs jobs from assigned projects.') }}
</li>
<li>
<gl-badge variant="warning" size="sm">
{{ __('locked') }}
</gl-badge>
- {{ __('Cannot be assigned to other projects.') }}
</li>
<li>
<gl-badge variant="danger" size="sm">
{{ __('paused') }}
</gl-badge>
- {{ __('Not available to run jobs.') }}
</li>
</ul>
</div>
</div>
</template>
query getRunners {
runners {
nodes {
id
description
runnerType
shortSha
version
revision
ipAddress
active
locked
tagList
contactedAt
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import RunnerDetailsApp from './runner_list_app.vue';
Vue.use(VueApollo);
export const initRunnerList = (selector = '#js-runner-list') => {
const el = document.querySelector(selector);
if (!el) {
return null;
}
// TODO `activeRunnersCount` should be implemented using a GraphQL API.
const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
assumeImmutableResults: true,
},
),
});
return new Vue({
el,
apolloProvider,
provide: {
runnerInstallHelpPage,
},
render(h) {
return h(RunnerDetailsApp, {
props: {
activeRunnersCount: parseInt(activeRunnersCount, 10),
registrationToken,
},
});
},
});
};
<script>
import * as Sentry from '@sentry/browser';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
export default {
components: {
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
},
props: {
activeRunnersCount: {
type: Number,
required: true,
},
registrationToken: {
type: String,
required: true,
},
},
data() {
return {
runners: [],
};
},
apollo: {
runners: {
query: getRunnersQuery,
update({ runners }) {
return runners?.nodes || [];
},
error(err) {
this.captureException(err);
},
},
},
computed: {
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
noRunnersFound() {
return !this.runnersLoading && !this.runners.length;
},
},
errorCaptured(err) {
this.captureException(err);
},
methods: {
captureException(err) {
Sentry.withScope((scope) => {
scope.setTag('component', 'runner_list_app');
Sentry.captureException(err);
});
},
},
};
</script>
<template>
<div>
<div class="row">
<div class="col-sm-6">
<runner-type-help />
</div>
<div class="col-sm-6">
<runner-manual-setup-help :registration-token="registrationToken" />
</div>
</div>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
<runner-list
v-else
:runners="runners"
:loading="runnersLoading"
:active-runners-count="activeRunnersCount"
/>
</div>
</template>
......@@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController
include RunnerSetupScripts
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
push_frontend_feature_flag(:runner_list_view_vue_ui, current_user, default_enabled: :yaml)
end
feature_category :continuous_integration
......
-# Note: This file should stay aligned with:
-# `app/views/groups/runners/_runner.html.haml`
.gl-responsive-table-row{ id: dom_id(runner) }
.gl-responsive-table-row{ data: { testid: "runner-row-#{runner.id}" } }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
.table-mobile-content
......
This diff is collapsed.
......@@ -14,7 +14,7 @@
%br
= _("And this registration token:")
%br
%code#registration_token= registration_token
%code#registration_token{ data: {testid: 'registration_token' } }= registration_token
= clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard")
.gl-mt-3.gl-mb-3
......
---
name: runner_list_view_vue_ui
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61241
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330969
milestone: '13.12'
type: development
group: group::runner
default_enabled: false
......@@ -12,6 +12,10 @@ RSpec.describe Admin::RunnersController do
describe '#index' do
render_views
before do
stub_feature_flags(runner_list_view_vue_ui: false)
end
it 'lists all runners' do
get :index
......
......@@ -17,6 +17,10 @@ RSpec.describe "Admin Runners" do
describe "Runners page" do
let(:pipeline) { create(:ci_pipeline) }
before do
stub_feature_flags(runner_list_view_vue_ui: false)
end
context "when there are runners" do
it 'has all necessary texts' do
runner = create(:ci_runner, contacted_at: Time.now)
......@@ -240,7 +244,7 @@ RSpec.describe "Admin Runners" do
it 'shows the label and does not show the project count' do
visit admin_runners_path
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'group'
expect(page).to have_text 'n/a'
end
......@@ -253,7 +257,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'shared'
expect(page).to have_text 'n/a'
end
......@@ -267,12 +271,36 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within "#runner_#{runner.id}" do
within "[data-testid='runner-row-#{runner.id}']" do
expect(page).to have_selector '.badge', text: 'specific'
expect(page).to have_text '1'
end
end
end
describe 'runners registration token' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
end
it 'has a registration token' do
expect(page.find('[data-testid="registration_token"]')).to have_content(token)
end
describe 'reset registration token' do
let(:page_token) { find('[data-testid="registration_token"]').text }
before do
click_button 'Reset registration token'
end
it 'changes registration token' do
expect(page_token).not_to eq token
end
end
end
end
describe "Runner show page" do
......@@ -381,28 +409,4 @@ RSpec.describe "Admin Runners" do
end
end
end
describe 'runners registration token' do
let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
before do
visit admin_runners_path
end
it 'has a registration token' do
expect(page.find('#registration_token')).to have_content(token)
end
describe 'reload registration token' do
let(:page_token) { find('#registration_token').text }
before do
click_button 'Reset registration token'
end
it 'changes registration token' do
expect(page_token).not_to eq token
end
end
end
end
import { GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue';
const mockId = '1';
const mockShortSha = '2P6oDVDm';
const mockDescription = 'runner-1';
describe('RunnerTypeCell', () => {
let wrapper;
const findLink = () => wrapper.findComponent(GlLink);
const createComponent = () => {
wrapper = mount(RunnerNameCell, {
propsData: {
runner: {
id: `gid://gitlab/Ci::Runner/${mockId}`,
shortSha: mockShortSha,
description: mockDescription,
},
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner link with id and short token', () => {
expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`);
expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`);
});
it('Displays the runner description', () => {
expect(wrapper.text()).toContain(mockDescription);
});
});
import { GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerTypeCell from '~/runner/components/cells/runner_type_cell.vue';
import { INSTANCE_TYPE } from '~/runner/constants';
describe('RunnerTypeCell', () => {
let wrapper;
const findBadges = () => wrapper.findAllComponents(GlBadge);
const createComponent = ({ runner = {} } = {}) => {
wrapper = mount(RunnerTypeCell, {
propsData: {
runner: {
runnerType: INSTANCE_TYPE,
active: true,
locked: false,
...runner,
},
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('Displays the runner type', () => {
createComponent();
expect(findBadges()).toHaveLength(1);
expect(findBadges().at(0).text()).toBe('shared');
});
it('Displays locked and paused states', () => {
createComponent({
runner: {
active: false,
locked: true,
},
});
expect(findBadges()).toHaveLength(3);
expect(findBadges().at(0).text()).toBe('shared');
expect(findBadges().at(1).text()).toBe('locked');
expect(findBadges().at(2).text()).toBe('paused');
});
});
import { GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
const mockActiveRunnersCount = mockRunners.length;
describe('RunnerList', () => {
let wrapper;
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
findRows().at(row).find(`[data-testid="td-${fieldKey}"]`);
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
mount(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays active runner count', () => {
expect(findActiveRunnersMessage().text()).toBe(
`Runners currently online: ${mockActiveRunnersCount}`,
);
});
it('Displays a large active runner count', () => {
createComponent({ props: { activeRunnersCount: 2000 } });
expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
});
it('Displays headers', () => {
const headerLabels = findHeaders().wrappers.map((w) => w.text());
expect(headerLabels).toEqual([
'Type/State',
'Runner',
'Version',
'IP Address',
'Projects',
'Jobs',
'Tags',
'Last contact',
'', // actions has no label
]);
});
it('Displays a list of runners', () => {
expect(findRows()).toHaveLength(2);
expect(findSkeletonLoader().exists()).toBe(false);
});
it('Displays details of a runner', () => {
const { id, description, version, ipAddress, shortSha } = mockRunners[0];
// Badges
expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('shared locked');
// Runner identifier
expect(findCell({ fieldKey: 'name' }).text()).toContain(
`#${getIdFromGraphQLId(id)} (${shortSha})`,
);
expect(findCell({ fieldKey: 'name' }).text()).toContain(description);
// Other fields: some cells are empty in the first iteration
// See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features
expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('');
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('');
expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
expect(findCell({ fieldKey: 'actions' }).text()).toBe('');
});
it('Links to the runner page', () => {
const { id } = mockRunners[0];
expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe(
`/admin/runners/${getIdFromGraphQLId(id)}`,
);
});
describe('When data is loading', () => {
beforeEach(() => {
createComponent({ props: { loading: true } });
});
it('shows an skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
});
});
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunnerInstallHelpPage = 'https://docs.gitlab.com/runner/install/';
describe('RunnerManualSetupHelp', () => {
let wrapper;
let originalGon;
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
const findRegistrationToken = () => wrapper.findByTestId('registration-token');
const findRunnerHelpLink = () => wrapper.findByTestId('runner-help-link');
const createComponent = ({ props = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(RunnerManualSetupHelp, {
provide: {
runnerInstallHelpPage: mockRunnerInstallHelpPage,
},
propsData: {
registrationToken: mockRegistrationToken,
...props,
},
stubs: {
GlSprintf,
},
}),
);
};
beforeAll(() => {
originalGon = global.gon;
global.gon = { gitlab_url: TEST_HOST };
});
afterAll(() => {
global.gon = originalGon;
});
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Title contains the default runner type', () => {
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
});
it('Title contains the group runner type', () => {
createComponent({ props: { typeName: 'group' } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
});
it('Runner Install Page link', () => {
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
});
it('Displays the coordinator URL token', () => {
expect(findCoordinatorUrl().text()).toBe(TEST_HOST);
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
});
it('Displays the registration token', () => {
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
});
it('Displays the runner instructions', () => {
expect(findRunnerInstructions().exists()).toBe(true);
});
});
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTags from '~/runner/components/runner_tags.vue';
describe('RunnerTags', () => {
let wrapper;
const findBadge = () => wrapper.findComponent(GlBadge);
const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(RunnerTags, {
propsData: {
tagList: ['tag1', 'tag2'],
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays tags text', () => {
expect(wrapper.text()).toMatchInterpolatedText('tag1 tag2');
expect(findBadgesAt(0).text()).toBe('tag1');
expect(findBadgesAt(1).text()).toBe('tag2');
});
it('Displays tags with correct style', () => {
expect(findBadge().props('size')).toBe('md');
expect(findBadge().props('variant')).toBe('info');
});
it('Displays tags with small size', () => {
createComponent({
props: { size: 'sm' },
});
expect(findBadge().props('size')).toBe('sm');
});
it('Displays tags with a variant', () => {
createComponent({
props: { variant: 'warning' },
});
expect(findBadge().props('variant')).toBe('warning');
});
it('Is empty when there are no tags', () => {
createComponent({
props: { tagList: null },
});
expect(wrapper.text()).toBe('');
expect(findBadge().exists()).toBe(false);
});
});
import { GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
describe('RunnerTypeHelp', () => {
let wrapper;
const findBadges = () => wrapper.findAllComponents(GlBadge);
const createComponent = () => {
wrapper = mount(RunnerTypeHelp);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays each of the runner types', () => {
expect(findBadges().at(0).text()).toBe('shared');
expect(findBadges().at(1).text()).toBe('group');
expect(findBadges().at(2).text()).toBe('specific');
});
it('Displays runner states', () => {
expect(findBadges().at(3).text()).toBe('locked');
expect(findBadges().at(4).text()).toBe('paused');
});
});
export const runnersData = {
data: {
runners: {
nodes: [
{
id: 'gid://gitlab/Ci::Runner/1',
description: 'runner-1',
runnerType: 'INSTANCE_TYPE',
shortSha: '2P6oDVDm',
version: '13.12.0',
revision: '11223344',
ipAddress: '127.0.0.1',
active: true,
locked: true,
tagList: [],
contactedAt: '2021-05-14T11:44:03Z',
__typename: 'CiRunner',
},
{
id: 'gid://gitlab/Ci::Runner/2',
description: 'runner-2',
runnerType: 'GROUP_TYPE',
shortSha: 'dpSCAC31',
version: '13.12.0',
revision: '11223344',
ipAddress: '127.0.0.1',
active: true,
locked: true,
tagList: [],
contactedAt: '2021-05-14T11:44:02Z',
__typename: 'CiRunner',
},
],
__typename: 'CiRunnerConnection',
},
},
};
import * as Sentry from '@sentry/browser';
import { createLocalVue, 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 RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
import { runnersData } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = 2;
const mocKRunners = runnersData.data.runners.nodes;
jest.mock('@sentry/browser');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('RunnerListApp', () => {
let wrapper;
let mockRunnersQuery;
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
wrapper = mountFn(RunnerListApp, {
localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
activeRunnersCount: mockActiveRunnersCount,
registrationToken: mockRegistrationToken,
...props,
},
});
};
beforeEach(async () => {
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: jest.fn() };
fn(scope);
});
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo();
await waitForPromises();
});
afterEach(() => {
mockRunnersQuery.mockReset();
wrapper.destroy();
});
it('shows the runners list', () => {
expect(mocKRunners).toMatchObject(findRunnerList().props('runners'));
});
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
});
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().exists()).toBe(true);
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });
createComponentWithApollo();
await waitForPromises();
});
it('shows a message for no results', async () => {
expect(wrapper.text()).toContain('No runners found');
});
});
it('when runners have not loaded, shows a loading state', () => {
createComponentWithApollo();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when runners query fails', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error());
createComponentWithApollo();
await waitForPromises();
});
it('error is reported to sentry', async () => {
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalled();
});
});
});
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