summaryrefslogtreecommitdiff
path: root/spec/frontend/runner
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/runner')
-rw-r--r--spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js14
-rw-r--r--spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js146
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js51
-rw-r--r--spec/frontend/runner/components/cells/link_cell_spec.js72
-rw-r--r--spec/frontend/runner/components/cells/runner_actions_cell_spec.js201
-rw-r--r--spec/frontend/runner/components/registration/registration_dropdown_spec.js15
-rw-r--r--spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js15
-rw-r--r--spec/frontend/runner/components/registration/registration_token_spec.js20
-rw-r--r--spec/frontend/runner/components/runner_assigned_item_spec.js53
-rw-r--r--spec/frontend/runner/components/runner_details_spec.js189
-rw-r--r--spec/frontend/runner/components/runner_edit_button_spec.js41
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js41
-rw-r--r--spec/frontend/runner/components/runner_groups_spec.js67
-rw-r--r--spec/frontend/runner/components/runner_header_spec.js44
-rw-r--r--spec/frontend/runner/components/runner_jobs_spec.js156
-rw-r--r--spec/frontend/runner/components/runner_jobs_table_spec.js119
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js50
-rw-r--r--spec/frontend/runner/components/runner_pagination_spec.js1
-rw-r--r--spec/frontend/runner/components/runner_pause_button_spec.js239
-rw-r--r--spec/frontend/runner/components/runner_projects_spec.js193
-rw-r--r--spec/frontend/runner/components/runner_type_tabs_spec.js22
-rw-r--r--spec/frontend/runner/components/runner_update_form_spec.js46
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js79
-rw-r--r--spec/frontend/runner/mock_data.js10
-rw-r--r--spec/frontend/runner/utils_spec.js65
25 files changed, 1636 insertions, 313 deletions
diff --git a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js
index ad0bce5c9af..ff6a632a4f8 100644
--- a/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js
+++ b/spec/frontend/runner/admin_runner_edit/admin_runner_edit_app_spec.js
@@ -1,4 +1,5 @@
-import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -18,8 +19,7 @@ jest.mock('~/runner/sentry_utils');
const mockRunnerGraphqlId = runnerData.data.runner.id;
const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
describe('AdminRunnerEditApp', () => {
let wrapper;
@@ -29,7 +29,6 @@ describe('AdminRunnerEditApp', () => {
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(AdminRunnerEditApp, {
- localVue,
apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
propsData: {
runnerId: mockRunnerId,
@@ -55,10 +54,11 @@ describe('AdminRunnerEditApp', () => {
expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId });
});
- it('displays the runner id', async () => {
+ it('displays the runner id and creation date', async () => {
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 () => {
@@ -76,7 +76,7 @@ describe('AdminRunnerEditApp', () => {
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
- error: new Error('Network error: Error!'),
+ error: new Error('Error!'),
component: 'AdminRunnerEditApp',
});
});
diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
new file mode 100644
index 00000000000..4b651961112
--- /dev/null
+++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
@@ -0,0 +1,146 @@
+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 RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
+import RunnerEditButton from '~/runner/components/runner_edit_button.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 mockRunner = runnerData.data.runner;
+const mockRunnerGraphqlId = mockRunner.id;
+const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+
+Vue.use(VueApollo);
+
+describe('AdminRunnerShowApp', () => {
+ let wrapper;
+ let mockRunnerQuery;
+
+ const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
+ const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton);
+ const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton);
+
+ const mockRunnerQueryResult = (runner = {}) => {
+ mockRunnerQuery = jest.fn().mockResolvedValue({
+ data: {
+ runner: { ...mockRunner, ...runner },
+ },
+ });
+ };
+
+ const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
+ wrapper = mountFn(AdminRunnerShowApp, {
+ apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]),
+ propsData: {
+ runnerId: mockRunnerId,
+ ...props,
+ },
+ });
+
+ return waitForPromises();
+ };
+
+ afterEach(() => {
+ mockRunnerQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ describe('When showing runner details', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult();
+
+ 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('displays the runner edit and pause buttons', async () => {
+ expect(findRunnerEditButton().exists()).toBe(true);
+ expect(findRunnerPauseButton().exists()).toBe(true);
+ });
+
+ it('shows basic runner details', async () => {
+ const expected = `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(wrapper.text().replace(/\s+/g, ' ')).toContain(expected);
+ });
+
+ describe('when runner cannot be updated', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult({
+ userPermissions: {
+ updateRunner: false,
+ },
+ });
+
+ await createComponent({
+ mountFn: mount,
+ });
+ });
+
+ it('does not display the runner edit and pause buttons', () => {
+ expect(findRunnerEditButton().exists()).toBe(false);
+ expect(findRunnerPauseButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when runner does not have an edit url ', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult({
+ editAdminUrl: null,
+ });
+
+ await createComponent({
+ mountFn: mount,
+ });
+ });
+
+ it('does not display the runner edit button', () => {
+ expect(findRunnerEditButton().exists()).toBe(false);
+ expect(findRunnerPauseButton().exists()).toBe(true);
+ });
+ });
+ });
+
+ 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('Error!'),
+ component: 'AdminRunnerShowApp',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createAlert).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 42be691ba4c..995f0cf7ba1 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -1,9 +1,13 @@
+import Vue from 'vue';
import { GlLink } from '@gitlab/ui';
-import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import {
+ extendedWrapper,
+ shallowMountExtended,
+ mountExtended,
+} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -46,8 +50,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
updateHistory: jest.fn(),
}));
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
describe('AdminRunnersApp', () => {
let wrapper;
@@ -65,22 +68,19 @@ describe('AdminRunnersApp', () => {
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
- const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const handlers = [
[getRunnersQuery, mockRunnersQuery],
[getRunnersCountQuery, mockRunnersCountQuery],
];
- wrapper = extendedWrapper(
- mountFn(AdminRunnersApp, {
- localVue,
- apolloProvider: createMockApollo(handlers),
- propsData: {
- registrationToken: mockRegistrationToken,
- ...props,
- },
- }),
- );
+ wrapper = mountFn(AdminRunnersApp, {
+ apolloProvider: createMockApollo(handlers),
+ propsData: {
+ registrationToken: mockRegistrationToken,
+ ...props,
+ },
+ });
};
beforeEach(async () => {
@@ -98,7 +98,7 @@ describe('AdminRunnersApp', () => {
});
it('shows total runner counts', async () => {
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
await waitForPromises();
@@ -129,7 +129,7 @@ describe('AdminRunnersApp', () => {
return Promise.resolve({ data: { runners: { count } } });
});
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
await waitForPromises();
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
@@ -157,7 +157,7 @@ describe('AdminRunnersApp', () => {
return Promise.resolve({ data: { runners: { count } } });
});
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
await waitForPromises();
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
@@ -175,7 +175,7 @@ describe('AdminRunnersApp', () => {
});
it('runner item links to the runner admin page', async () => {
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
await waitForPromises();
@@ -198,7 +198,7 @@ describe('AdminRunnersApp', () => {
});
it('sets tokens in the filtered search', () => {
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
@@ -281,6 +281,7 @@ describe('AdminRunnersApp', () => {
},
});
createComponent();
+ await waitForPromises();
});
it('shows a message for no results', async () => {
@@ -289,9 +290,10 @@ describe('AdminRunnersApp', () => {
});
describe('when runners query fails', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponent();
+ await waitForPromises();
});
it('error is shown to the user', async () => {
@@ -300,17 +302,18 @@ describe('AdminRunnersApp', () => {
it('error is reported to sentry', async () => {
expect(captureException).toHaveBeenCalledWith({
- error: new Error('Network error: Error!'),
+ error: new Error('Error!'),
component: 'AdminRunnersApp',
});
});
});
describe('Pagination', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated);
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
+ await waitForPromises();
});
it('more pages can be selected', () => {
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 00000000000..a59a0eaa5d8
--- /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/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
index 4233d86c24c..dcb0af67784 100644
--- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -1,7 +1,7 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { createAlert } from '~/flash';
@@ -9,11 +9,12 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { captureException } from '~/runner/sentry_utils';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
+import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
+import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
-import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
@@ -21,8 +22,7 @@ const mockRunner = runnersData.data.runners.nodes[0];
const getRunnersQueryName = getRunnersQuery.definitions[0].name.value;
const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value;
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@@ -32,44 +32,37 @@ describe('RunnerTypeCell', () => {
const mockToastShow = jest.fn();
const runnerDeleteMutationHandler = jest.fn();
- const runnerActionsUpdateMutationHandler = jest.fn();
- const findEditBtn = () => wrapper.findByTestId('edit-runner');
- const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
+ const findEditBtn = () => wrapper.findComponent(RunnerEditButton);
+ const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value;
const createComponent = (runner = {}, options) => {
- wrapper = extendedWrapper(
- shallowMount(RunnerActionCell, {
- propsData: {
- runner: {
- id: mockRunner.id,
- shortSha: mockRunner.shortSha,
- editAdminUrl: mockRunner.editAdminUrl,
- userPermissions: mockRunner.userPermissions,
- active: mockRunner.active,
- ...runner,
- },
+ wrapper = shallowMountExtended(RunnerActionCell, {
+ propsData: {
+ runner: {
+ id: mockRunner.id,
+ shortSha: mockRunner.shortSha,
+ editAdminUrl: mockRunner.editAdminUrl,
+ userPermissions: mockRunner.userPermissions,
+ active: mockRunner.active,
+ ...runner,
},
- localVue,
- apolloProvider: createMockApollo([
- [runnerDeleteMutation, runnerDeleteMutationHandler],
- [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler],
- ]),
- directives: {
- GlTooltip: createMockDirective(),
- GlModal: createMockDirective(),
- },
- mocks: {
- $toast: {
- show: mockToastShow,
- },
+ },
+ apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]),
+ directives: {
+ GlTooltip: createMockDirective(),
+ GlModal: createMockDirective(),
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
},
- ...options,
- }),
- );
+ },
+ ...options,
+ });
};
beforeEach(() => {
@@ -80,21 +73,11 @@ describe('RunnerTypeCell', () => {
},
},
});
-
- runnerActionsUpdateMutationHandler.mockResolvedValue({
- data: {
- runnerUpdate: {
- runner: mockRunner,
- errors: [],
- },
- },
- });
});
afterEach(() => {
mockToastShow.mockReset();
runnerDeleteMutationHandler.mockReset();
- runnerActionsUpdateMutationHandler.mockReset();
wrapper.destroy();
});
@@ -126,116 +109,14 @@ describe('RunnerTypeCell', () => {
});
});
- describe('Toggle active action', () => {
- describe.each`
- state | label | icon | isActive | newActiveValue
- ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
- ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
- `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
- beforeEach(() => {
- createComponent({ active: isActive });
- });
-
- it(`Displays a ${icon} button`, () => {
- expect(findToggleActiveBtn().props('loading')).toBe(false);
- expect(findToggleActiveBtn().props('icon')).toBe(icon);
- expect(getTooltip(findToggleActiveBtn())).toBe(label);
- expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
- });
-
- it(`After clicking the ${icon} button, the button has a loading state`, async () => {
- await findToggleActiveBtn().vm.$emit('click');
-
- expect(findToggleActiveBtn().props('loading')).toBe(true);
- });
-
- it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
- await findToggleActiveBtn().vm.$emit('click');
-
- expect(getTooltip(findToggleActiveBtn())).toBe('');
- expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
- });
-
- describe(`When clicking on the ${icon} button`, () => {
- it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => {
- expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0);
-
- await findToggleActiveBtn().vm.$emit('click');
-
- expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1);
- expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({
- input: {
- id: mockRunner.id,
- active: newActiveValue,
- },
- });
- });
-
- it('The button does not have a loading state after the mutation occurs', async () => {
- await findToggleActiveBtn().vm.$emit('click');
-
- expect(findToggleActiveBtn().props('loading')).toBe(true);
-
- await waitForPromises();
-
- expect(findToggleActiveBtn().props('loading')).toBe(false);
- });
- });
-
- describe('When update fails', () => {
- describe('On a network error', () => {
- const mockErrorMsg = 'Update error!';
-
- beforeEach(async () => {
- runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
-
- await findToggleActiveBtn().vm.$emit('click');
- });
-
- it('error is reported to sentry', () => {
- expect(captureException).toHaveBeenCalledWith({
- error: new Error(`Network error: ${mockErrorMsg}`),
- component: 'RunnerActionsCell',
- });
- });
-
- it('error is shown to the user', () => {
- expect(createAlert).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('On a validation error', () => {
- const mockErrorMsg = 'Runner not found!';
- const mockErrorMsg2 = 'User not allowed!';
-
- beforeEach(async () => {
- runnerActionsUpdateMutationHandler.mockResolvedValue({
- data: {
- runnerUpdate: {
- runner: mockRunner,
- errors: [mockErrorMsg, mockErrorMsg2],
- },
- },
- });
-
- await findToggleActiveBtn().vm.$emit('click');
- });
-
- it('error is reported to sentry', () => {
- expect(captureException).toHaveBeenCalledWith({
- error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
- component: 'RunnerActionsCell',
- });
- });
+ describe('Pause action', () => {
+ it('Renders a compact pause button', () => {
+ createComponent();
- it('error is shown to the user', () => {
- expect(createAlert).toHaveBeenCalledTimes(1);
- });
- });
- });
+ expect(findRunnerPauseBtn().props('compact')).toBe(true);
});
- it('Does not render the runner toggle active button when user cannot update', () => {
+ it('Does not render the runner pause button when user cannot update', () => {
createComponent({
userPermissions: {
...mockRunner.userPermissions,
@@ -243,7 +124,7 @@ describe('RunnerTypeCell', () => {
},
});
- expect(findToggleActiveBtn().exists()).toBe(false);
+ expect(findRunnerPauseBtn().exists()).toBe(false);
});
});
@@ -308,8 +189,9 @@ describe('RunnerTypeCell', () => {
});
describe('When delete is clicked', () => {
- beforeEach(() => {
+ beforeEach(async () => {
findRunnerDeleteModal().vm.$emit('primary');
+ await waitForPromises();
});
it('The delete mutation is called correctly', () => {
@@ -324,7 +206,8 @@ describe('RunnerTypeCell', () => {
expect(getTooltip(findDeleteBtn())).toBe('');
});
- it('The toast notification is shown', () => {
+ it('The toast notification is shown', async () => {
+ await waitForPromises();
expect(mockToastShow).toHaveBeenCalledTimes(1);
expect(mockToastShow).toHaveBeenCalledWith(
expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`),
@@ -336,15 +219,16 @@ describe('RunnerTypeCell', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Delete error!';
- beforeEach(() => {
+ beforeEach(async () => {
runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
findRunnerDeleteModal().vm.$emit('primary');
+ await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
- error: new Error(`Network error: ${mockErrorMsg}`),
+ error: new Error(mockErrorMsg),
component: 'RunnerActionsCell',
});
});
@@ -362,7 +246,7 @@ describe('RunnerTypeCell', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
- beforeEach(() => {
+ beforeEach(async () => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
@@ -372,6 +256,7 @@ describe('RunnerTypeCell', () => {
});
findRunnerDeleteModal().vm.$emit('primary');
+ await waitForPromises();
});
it('error is reported to sentry', () => {
diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js
index d18d2bec18e..da8ef7c3af0 100644
--- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js
+++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js
@@ -1,9 +1,11 @@
import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui';
-import { createLocalVue, mount, shallowMount, createWrapper } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { mount, shallowMount, createWrapper } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+
import VueApollo from 'vue-apollo';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue';
@@ -73,8 +75,7 @@ describe('RegistrationDropdown', () => {
});
describe('When the dropdown item is clicked', () => {
- const localVue = createLocalVue();
- localVue.use(VueApollo);
+ Vue.use(VueApollo);
const requestHandlers = [
[getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
@@ -84,10 +85,9 @@ describe('RegistrationDropdown', () => {
const findModalInBody = () =>
createWrapper(document.body).find('[data-testid="runner-instructions-modal"]');
- beforeEach(() => {
+ beforeEach(async () => {
createComponent(
{
- localVue,
// Mock load modal contents from API
apolloProvider: createMockApollo(requestHandlers),
// Use `attachTo` to find the modal
@@ -96,7 +96,8 @@ describe('RegistrationDropdown', () => {
mount,
);
- findRegistrationInstructionsDropdownItem().trigger('click');
+ await findRegistrationInstructionsDropdownItem().trigger('click');
+ await waitForPromises();
});
afterEach(() => {
diff --git a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js
index e75decddf70..d2deb49a5f7 100644
--- a/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js
+++ b/spec/frontend/runner/components/registration/registration_token_reset_dropdown_item_spec.js
@@ -1,6 +1,7 @@
import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -14,9 +15,8 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-localVue.use(GlToast);
+Vue.use(VueApollo);
+Vue.use(GlToast);
const mockNewToken = 'NEW_TOKEN';
const modalID = 'token-reset-modal';
@@ -34,7 +34,6 @@ describe('RegistrationTokenResetDropdownItem', () => {
const createComponent = ({ props, provide = {} } = {}) => {
wrapper = shallowMount(RegistrationTokenResetDropdownItem, {
- localVue,
provide,
propsData: {
type: INSTANCE_TYPE,
@@ -163,10 +162,10 @@ describe('RegistrationTokenResetDropdownItem', () => {
await waitForPromises();
expect(createAlert).toHaveBeenLastCalledWith({
- message: `Network error: ${mockErrorMsg}`,
+ message: mockErrorMsg,
});
expect(captureException).toHaveBeenCalledWith({
- error: new Error(`Network error: ${mockErrorMsg}`),
+ error: new Error(mockErrorMsg),
component: 'RunnerRegistrationTokenReset',
});
});
diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js
index f53ae165344..6b9708cc525 100644
--- a/spec/frontend/runner/components/registration/registration_token_spec.js
+++ b/spec/frontend/runner/components/registration/registration_token_spec.js
@@ -1,7 +1,7 @@
import { nextTick } from 'vue';
import { GlToast } from '@gitlab/ui';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { createLocalVue } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RegistrationToken from '~/runner/components/registration/registration_token.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -25,15 +25,13 @@ describe('RegistrationToken', () => {
const createComponent = ({ props = {}, withGlToast = true } = {}) => {
const localVue = withGlToast ? vueWithGlToast() : undefined;
- wrapper = extendedWrapper(
- shallowMount(RegistrationToken, {
- propsData: {
- value: mockToken,
- ...props,
- },
- localVue,
- }),
- );
+ wrapper = shallowMountExtended(RegistrationToken, {
+ propsData: {
+ value: mockToken,
+ ...props,
+ },
+ localVue,
+ });
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
};
diff --git a/spec/frontend/runner/components/runner_assigned_item_spec.js b/spec/frontend/runner/components/runner_assigned_item_spec.js
new file mode 100644
index 00000000000..c6156c16d4a
--- /dev/null
+++ b/spec/frontend/runner/components/runner_assigned_item_spec.js
@@ -0,0 +1,53 @@
+import { GlAvatar } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
+
+const mockHref = '/group/project';
+const mockName = 'Project';
+const mockFullName = 'Group / Project';
+const mockAvatarUrl = '/avatar.png';
+
+describe('RunnerAssignedItem', () => {
+ let wrapper;
+
+ const findAvatar = () => wrapper.findByTestId('item-avatar');
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(RunnerAssignedItem, {
+ propsData: {
+ href: mockHref,
+ name: mockName,
+ fullName: mockFullName,
+ avatarUrl: mockAvatarUrl,
+ ...props,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Shows an avatar', () => {
+ const avatar = findAvatar();
+
+ expect(avatar.attributes('href')).toBe(mockHref);
+ expect(avatar.findComponent(GlAvatar).props()).toMatchObject({
+ alt: mockName,
+ entityName: mockName,
+ src: mockAvatarUrl,
+ shape: 'rect',
+ size: 48,
+ });
+ });
+
+ it('Shows an item link', () => {
+ const groupFullName = wrapper.findByText(mockFullName);
+
+ expect(groupFullName.attributes('href')).toBe(mockHref);
+ });
+});
diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js
new file mode 100644
index 00000000000..6bf4a52a799
--- /dev/null
+++ b/spec/frontend/runner/components/runner_details_spec.js
@@ -0,0 +1,189 @@
+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';
+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 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';
+
+import { runnerData, runnerWithGroupData } from '../mock_data';
+
+const mockRunner = runnerData.data.runner;
+const mockGroupRunner = runnerWithGroupData.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 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, {
+ propsData: {
+ ...props,
+ },
+ stubs: {
+ RunnerDetail,
+ ...stubs,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('when no runner is present, no contents are shown', () => {
+ createComponent({
+ props: {
+ runner: null,
+ },
+ });
+
+ expect(wrapper.text()).toBe('');
+ });
+
+ describe('Details tab', () => {
+ 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({
+ props: {
+ runner: {
+ ...mockRunner,
+ ...runner,
+ },
+ },
+ stubs: {
+ GlIntersperse,
+ GlSprintf,
+ TimeAgo,
+ },
+ });
+ });
+
+ it(`displays expected value "${expectedValue}"`, () => {
+ expect(findDd(field).text()).toBe(expectedValue);
+ });
+ });
+
+ describe('"Tags" field', () => {
+ const stubs = { RunnerTags, RunnerTag };
+
+ it('displays expected value "tag-1 tag-2"', () => {
+ createComponent({
+ props: {
+ runner: { ...mockRunner, tagList: ['tag-1', 'tag-2'] },
+ },
+ stubs,
+ });
+
+ expect(findDd('Tags').text().replace(/\s+/g, ' ')).toBe('tag-1 tag-2');
+ });
+
+ it('displays "None" when runner has no tags', () => {
+ createComponent({
+ props: {
+ runner: { ...mockRunner, tagList: [] },
+ },
+ stubs,
+ });
+
+ expect(findDd('Tags').text().replace(/\s+/g, ' ')).toBe('None');
+ });
+ });
+
+ describe('Group runners', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ runner: mockGroupRunner,
+ },
+ });
+ });
+
+ it('Shows a group runner details', () => {
+ expect(findDetailGroups().props('runner')).toEqual(mockGroupRunner);
+ });
+ });
+ });
+
+ 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_edit_button_spec.js b/spec/frontend/runner/components/runner_edit_button_spec.js
new file mode 100644
index 00000000000..428c1ef07e9
--- /dev/null
+++ b/spec/frontend/runner/components/runner_edit_button_spec.js
@@ -0,0 +1,41 @@
+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');
+ });
+});
diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
index 5ab0db019a3..fda96e5918e 100644
--- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
@@ -1,6 +1,5 @@
import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config';
import TagToken from '~/runner/components/search_tokens/tag_token.vue';
@@ -29,27 +28,25 @@ describe('RunnerList', () => {
};
const createComponent = ({ props = {}, options = {} } = {}) => {
- wrapper = extendedWrapper(
- shallowMount(RunnerFilteredSearchBar, {
- propsData: {
- namespace: 'runners',
- tokens: [],
- value: {
- runnerType: null,
- filters: [],
- sort: mockDefaultSort,
- },
- ...props,
+ wrapper = shallowMountExtended(RunnerFilteredSearchBar, {
+ propsData: {
+ namespace: 'runners',
+ tokens: [],
+ value: {
+ runnerType: null,
+ filters: [],
+ sort: mockDefaultSort,
},
- stubs: {
- FilteredSearch,
- GlFilteredSearch,
- GlDropdown,
- GlDropdownItem,
- },
- ...options,
- }),
- );
+ ...props,
+ },
+ stubs: {
+ FilteredSearch,
+ GlFilteredSearch,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ ...options,
+ });
};
beforeEach(() => {
diff --git a/spec/frontend/runner/components/runner_groups_spec.js b/spec/frontend/runner/components/runner_groups_spec.js
new file mode 100644
index 00000000000..b83733b9972
--- /dev/null
+++ b/spec/frontend/runner/components/runner_groups_spec.js
@@ -0,0 +1,67 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+import RunnerGroups from '~/runner/components/runner_groups.vue';
+import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
+
+import { runnerData, runnerWithGroupData } from '../mock_data';
+
+const mockInstanceRunner = runnerData.data.runner;
+const mockGroupRunner = runnerWithGroupData.data.runner;
+const mockGroup = mockGroupRunner.groups.nodes[0];
+
+describe('RunnerGroups', () => {
+ let wrapper;
+
+ const findHeading = () => wrapper.find('h3');
+ const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem);
+
+ const createComponent = ({ runner = mockGroupRunner, mountFn = shallowMountExtended } = {}) => {
+ wrapper = mountFn(RunnerGroups, {
+ propsData: {
+ runner,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Shows a heading', () => {
+ createComponent();
+
+ expect(findHeading().text()).toBe('Assigned Group');
+ });
+
+ describe('When there is a group runner', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('Shows a project', () => {
+ createComponent();
+
+ const item = findRunnerAssignedItems().at(0);
+ const { webUrl, name, fullName, avatarUrl } = mockGroup;
+
+ expect(item.props()).toMatchObject({
+ href: webUrl,
+ name,
+ fullName,
+ avatarUrl,
+ });
+ });
+ });
+
+ describe('When there are no groups', () => {
+ beforeEach(() => {
+ createComponent({
+ runner: mockInstanceRunner,
+ });
+ });
+
+ it('Shows a "None" label', () => {
+ expect(wrapper.findByText('None').exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_header_spec.js b/spec/frontend/runner/components/runner_header_spec.js
index 50699df3a44..8799c218b06 100644
--- a/spec/frontend/runner/components/runner_header_spec.js
+++ b/spec/frontend/runner/components/runner_header_spec.js
@@ -1,5 +1,5 @@
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 { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -18,9 +18,10 @@ describe('RunnerHeader', () => {
const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge);
const findRunnerStatusBadge = () => wrapper.findComponent(RunnerStatusBadge);
+ const findRunnerLockedIcon = () => wrapper.findByTestId('lock-icon');
const findTimeAgo = () => wrapper.findComponent(TimeAgo);
- const createComponent = ({ runner = {}, mountFn = shallowMount } = {}) => {
+ const createComponent = ({ runner = {}, options = {}, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerHeader, {
propsData: {
runner: {
@@ -32,6 +33,7 @@ describe('RunnerHeader', () => {
GlSprintf,
TimeAgo,
},
+ ...options,
});
};
@@ -41,24 +43,24 @@ describe('RunnerHeader', () => {
it('displays the runner status', () => {
createComponent({
- mountFn: mount,
+ mountFn: mountExtended,
runner: {
status: STATUS_ONLINE,
},
});
- expect(findRunnerStatusBadge().text()).toContain(`online`);
+ expect(findRunnerStatusBadge().text()).toContain('online');
});
it('displays the runner type', () => {
createComponent({
- mountFn: mount,
+ mountFn: mountExtended,
runner: {
runnerType: GROUP_TYPE,
},
});
- expect(findRunnerTypeBadge().text()).toContain(`group`);
+ expect(findRunnerTypeBadge().text()).toContain('group');
});
it('displays the runner id', () => {
@@ -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', () => {
@@ -78,7 +91,7 @@ describe('RunnerHeader', () => {
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({
runner: {
id: convertToGraphQLId(TYPE_CI_RUNNER, 99),
@@ -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(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');
+ });
});
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 00000000000..97339056370
--- /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 00000000000..5f4905ad2a8
--- /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_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 452430b7237..42d6ecca09e 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -1,8 +1,13 @@
import { GlTable, GlSkeletonLoader } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import {
+ extendedWrapper,
+ shallowMountExtended,
+ mountExtended,
+} from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
+import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
+import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
@@ -18,20 +23,18 @@ describe('RunnerList', () => {
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
- const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
- wrapper = extendedWrapper(
- mountFn(RunnerList, {
- propsData: {
- runners: mockRunners,
- activeRunnersCount: mockActiveRunnersCount,
- ...props,
- },
- }),
- );
+ const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => {
+ wrapper = mountFn(RunnerList, {
+ propsData: {
+ runners: mockRunners,
+ activeRunnersCount: mockActiveRunnersCount,
+ ...props,
+ },
+ });
};
beforeEach(() => {
- createComponent({}, mount);
+ createComponent({}, mountExtended);
});
afterEach(() => {
@@ -43,9 +46,9 @@ describe('RunnerList', () => {
expect(headerLabels).toEqual([
'Status',
- 'Runner ID',
+ 'Runner',
'Version',
- 'IP Address',
+ 'IP',
'Jobs',
'Tags',
'Last contact',
@@ -54,7 +57,7 @@ describe('RunnerList', () => {
});
it('Sets runner id as a row key', () => {
- createComponent({}, shallowMount);
+ createComponent({});
expect(findTable().attributes('primary-key')).toBe('id');
});
@@ -89,8 +92,9 @@ describe('RunnerList', () => {
// Actions
const actions = findCell({ fieldKey: 'actions' });
- expect(actions.findByTestId('edit-runner').exists()).toBe(true);
- expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true);
+ expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
+ expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true);
+ expect(actions.findByTestId('delete-runner').exists()).toBe(true);
});
describe('Table data formatting', () => {
@@ -107,7 +111,7 @@ describe('RunnerList', () => {
it('Formats job counts', () => {
mockRunnersCopy[0].jobCount = 1;
- createComponent({ props: { runners: mockRunnersCopy } }, mount);
+ createComponent({ props: { runners: mockRunnersCopy } }, mountExtended);
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1');
});
@@ -115,7 +119,7 @@ describe('RunnerList', () => {
it('Formats large job counts', () => {
mockRunnersCopy[0].jobCount = 1000;
- createComponent({ props: { runners: mockRunnersCopy } }, mount);
+ createComponent({ props: { runners: mockRunnersCopy } }, mountExtended);
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000');
});
@@ -123,7 +127,7 @@ describe('RunnerList', () => {
it('Formats large job counts with a plus symbol', () => {
mockRunnersCopy[0].jobCount = 1001;
- createComponent({ props: { runners: mockRunnersCopy } }, mount);
+ createComponent({ props: { runners: mockRunnersCopy } }, mountExtended);
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+');
});
@@ -143,13 +147,13 @@ describe('RunnerList', () => {
});
it('when there are no runners, shows an skeleton loader', () => {
- createComponent({ props: { runners: [], loading: true } }, mount);
+ createComponent({ props: { runners: [], loading: true } }, mountExtended);
expect(findSkeletonLoader().exists()).toBe(true);
});
it('when there are runners, shows a busy indicator skeleton loader', () => {
- createComponent({ props: { loading: true } }, mount);
+ createComponent({ props: { loading: true } }, mountExtended);
expect(findSkeletonLoader().exists()).toBe(false);
});
diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js
index 59feb32dd2a..ecd6e6bd7f9 100644
--- a/spec/frontend/runner/components/runner_pagination_spec.js
+++ b/spec/frontend/runner/components/runner_pagination_spec.js
@@ -104,7 +104,6 @@ describe('RunnerPagination', () => {
expect(wrapper.emitted('input')[0]).toEqual([
{
- before: mockStartCursor,
page: 1,
},
]);
diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js
new file mode 100644
index 00000000000..278f3dec2ee
--- /dev/null
+++ b/spec/frontend/runner/components/runner_pause_button_spec.js
@@ -0,0 +1,239 @@
+import Vue from 'vue';
+import { GlButton } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import runnerToggleActiveMutation from '~/runner/graphql/runner_toggle_active.mutation.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import { captureException } from '~/runner/sentry_utils';
+import { createAlert } from '~/flash';
+
+import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
+import { runnersData } from '../mock_data';
+
+const mockRunner = runnersData.data.runners.nodes[0];
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+
+describe('RunnerPauseButton', () => {
+ let wrapper;
+ let runnerToggleActiveHandler;
+
+ const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
+ const findBtn = () => wrapper.findComponent(GlButton);
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ const { runner, ...propsData } = props;
+
+ wrapper = mountFn(RunnerPauseButton, {
+ propsData: {
+ runner: {
+ id: mockRunner.id,
+ active: mockRunner.active,
+ ...runner,
+ },
+ ...propsData,
+ },
+ apolloProvider: createMockApollo([[runnerToggleActiveMutation, runnerToggleActiveHandler]]),
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const clickAndWait = async () => {
+ findBtn().vm.$emit('click');
+ await waitForPromises();
+ };
+
+ beforeEach(() => {
+ runnerToggleActiveHandler = jest.fn().mockImplementation(({ input }) => {
+ return Promise.resolve({
+ data: {
+ runnerUpdate: {
+ runner: {
+ id: input.id,
+ active: input.active,
+ },
+ errors: [],
+ },
+ },
+ });
+ });
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Pause/Resume action', () => {
+ describe.each`
+ runnerState | icon | content | isActive | newActiveValue
+ ${'paused'} | ${'play'} | ${'Resume'} | ${false} | ${true}
+ ${'active'} | ${'pause'} | ${'Pause'} | ${true} | ${false}
+ `('When the runner is $runnerState', ({ icon, content, isActive, newActiveValue }) => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ runner: {
+ active: isActive,
+ },
+ },
+ });
+ });
+
+ it(`Displays a ${icon} button`, () => {
+ expect(findBtn().props('loading')).toBe(false);
+ expect(findBtn().props('icon')).toBe(icon);
+ expect(findBtn().text()).toBe(content);
+ });
+
+ it('Does not display redundant text for screen readers', () => {
+ expect(findBtn().attributes('aria-label')).toBe(undefined);
+ });
+
+ describe(`Before the ${icon} button is clicked`, () => {
+ it('The mutation has not been called', () => {
+ expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe(`Immediately after the ${icon} button is clicked`, () => {
+ beforeEach(async () => {
+ findBtn().vm.$emit('click');
+ });
+
+ it('The button has a loading state', async () => {
+ expect(findBtn().props('loading')).toBe(true);
+ });
+
+ it('The stale tooltip is removed', async () => {
+ expect(getTooltip()).toBe('');
+ });
+ });
+
+ describe(`After clicking on the ${icon} button`, () => {
+ beforeEach(async () => {
+ await clickAndWait();
+ });
+
+ it(`The mutation to that sets active to ${newActiveValue} is called`, async () => {
+ expect(runnerToggleActiveHandler).toHaveBeenCalledTimes(1);
+ expect(runnerToggleActiveHandler).toHaveBeenCalledWith({
+ input: {
+ id: mockRunner.id,
+ active: newActiveValue,
+ },
+ });
+ });
+
+ it('The button does not have a loading state', () => {
+ expect(findBtn().props('loading')).toBe(false);
+ });
+ });
+
+ describe('When update fails', () => {
+ describe('On a network error', () => {
+ const mockErrorMsg = 'Update error!';
+
+ beforeEach(async () => {
+ runnerToggleActiveHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
+
+ await clickAndWait();
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error(mockErrorMsg),
+ component: 'RunnerPauseButton',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('On a validation error', () => {
+ const mockErrorMsg = 'Runner not found!';
+ const mockErrorMsg2 = 'User not allowed!';
+
+ beforeEach(async () => {
+ runnerToggleActiveHandler.mockResolvedValueOnce({
+ data: {
+ runnerUpdate: {
+ runner: {
+ id: mockRunner.id,
+ active: isActive,
+ },
+ errors: [mockErrorMsg, mockErrorMsg2],
+ },
+ },
+ });
+
+ await clickAndWait();
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
+ component: 'RunnerPauseButton',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+ });
+ });
+
+ describe('When displaying a compact button for an active runner', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ runner: {
+ active: true,
+ },
+ compact: true,
+ },
+ mountFn: mountExtended,
+ });
+ });
+
+ it('Displays no text', () => {
+ expect(findBtn().text()).toBe('');
+
+ // Note: Use <template v-if> to ensure rendering a
+ // text-less button. Ensure we don't send even empty an
+ // content slot to prevent a distorted/rectangular button.
+ expect(wrapper.find('.gl-button-text').exists()).toBe(false);
+ });
+
+ it('Display correctly for screen readers', () => {
+ expect(findBtn().attributes('aria-label')).toBe('Pause');
+ expect(getTooltip()).toBe('Pause');
+ });
+
+ describe('Immediately after the button is clicked', () => {
+ beforeEach(async () => {
+ findBtn().vm.$emit('click');
+ });
+
+ it('The button has a loading state', async () => {
+ expect(findBtn().props('loading')).toBe(true);
+ });
+
+ it('The stale tooltip is removed', async () => {
+ expect(getTooltip()).toBe('');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js
new file mode 100644
index 00000000000..68a2130d6d9
--- /dev/null
+++ b/spec/frontend/runner/components/runner_projects_spec.js
@@ -0,0 +1,193 @@
+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 { sprintf } from '~/locale';
+import {
+ I18N_ASSIGNED_PROJECTS,
+ I18N_NONE,
+ RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+} from '~/runner/constants';
+import RunnerProjects from '~/runner/components/runner_projects.vue';
+import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
+import RunnerPagination from '~/runner/components/runner_pagination.vue';
+import { captureException } from '~/runner/sentry_utils';
+
+import getRunnerProjectsQuery from '~/runner/graphql/get_runner_projects.query.graphql';
+
+import { runnerData, runnerProjectsData } from '../mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+
+const mockRunner = runnerData.data.runner;
+const mockRunnerWithProjects = runnerProjectsData.data.runner;
+const mockProjects = mockRunnerWithProjects.projects.nodes;
+
+Vue.use(VueApollo);
+
+describe('RunnerProjects', () => {
+ let wrapper;
+ let mockRunnerProjectsQuery;
+
+ const findHeading = () => wrapper.find('h3');
+ const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
+ const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem);
+ const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
+
+ const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
+ wrapper = mountFn(RunnerProjects, {
+ apolloProvider: createMockApollo([[getRunnerProjectsQuery, mockRunnerProjectsQuery]]),
+ propsData: {
+ runner: mockRunner,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockRunnerProjectsQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ mockRunnerProjectsQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ it('Requests runner projects', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1);
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledWith({
+ id: mockRunner.id,
+ first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ });
+ });
+
+ describe('When there are projects assigned', () => {
+ beforeEach(async () => {
+ mockRunnerProjectsQuery.mockResolvedValueOnce(runnerProjectsData);
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('Shows a heading', async () => {
+ const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length });
+
+ expect(findHeading().text()).toBe(expected);
+ });
+
+ it('Shows projects', () => {
+ expect(findRunnerAssignedItems().length).toBe(mockProjects.length);
+ });
+
+ it('Shows a project', () => {
+ const item = findRunnerAssignedItems().at(0);
+ const { webUrl, name, nameWithNamespace, avatarUrl } = mockProjects[0];
+
+ expect(item.props()).toMatchObject({
+ href: webUrl,
+ name,
+ fullName: nameWithNamespace,
+ avatarUrl,
+ });
+ });
+
+ describe('When "Next" page is clicked', () => {
+ beforeEach(async () => {
+ findRunnerPagination().vm.$emit('input', { page: 3, after: 'AFTER_CURSOR' });
+
+ await waitForPromises();
+ });
+
+ it('A new page is requested', () => {
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(2);
+ expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({
+ id: mockRunner.id,
+ first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ after: 'AFTER_CURSOR',
+ });
+ });
+
+ it('When "Prev" page is clicked, the previous page is requested', async () => {
+ findRunnerPagination().vm.$emit('input', { page: 2, before: 'BEFORE_CURSOR' });
+
+ await waitForPromises();
+
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(3);
+ expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({
+ id: mockRunner.id,
+ last: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ before: 'BEFORE_CURSOR',
+ });
+ });
+ });
+ });
+
+ describe('When loading', () => {
+ it('shows loading indicator and no other content', () => {
+ createComponent();
+
+ expect(findGlSkeletonLoading().exists()).toBe(true);
+
+ expect(wrapper.findByText(I18N_NONE).exists()).toBe(false);
+ expect(findRunnerAssignedItems().length).toBe(0);
+
+ expect(findRunnerPagination().attributes('disabled')).toBe('true');
+ });
+ });
+
+ describe('When there are no projects', () => {
+ beforeEach(async () => {
+ mockRunnerProjectsQuery.mockResolvedValueOnce({
+ data: {
+ runner: {
+ id: mockRunner.id,
+ projectCount: 0,
+ projects: {
+ nodes: [],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ },
+ },
+ },
+ });
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('Shows a "None" label', () => {
+ expect(wrapper.findByText(I18N_NONE).exists()).toBe(true);
+ });
+ });
+
+ describe('When an error occurs', () => {
+ beforeEach(async () => {
+ mockRunnerProjectsQuery.mockRejectedValue(new Error('Error!'));
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('shows an error', () => {
+ expect(createAlert).toHaveBeenCalled();
+ });
+
+ it('reports an error', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ component: 'RunnerProjects',
+ error: expect.any(Error),
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js
index 4871d9c470a..9da5d842d8f 100644
--- a/spec/frontend/runner/components/runner_type_tabs_spec.js
+++ b/spec/frontend/runner/components/runner_type_tabs_spec.js
@@ -1,7 +1,7 @@
import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
-import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' };
@@ -13,6 +13,7 @@ describe('RunnerTypeTabs', () => {
findTabs()
.filter((tab) => tab.attributes('active') === 'true')
.at(0);
+ const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text());
const createComponent = ({ props, ...options } = {}) => {
wrapper = shallowMount(RunnerTypeTabs, {
@@ -35,13 +36,18 @@ describe('RunnerTypeTabs', () => {
wrapper.destroy();
});
- it('Renders options to filter runners', () => {
- expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([
- 'All',
- 'Instance',
- 'Group',
- 'Project',
- ]);
+ it('Renders all options to filter runners by default', () => {
+ expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']);
+ });
+
+ it('Renders fewer options to filter runners', () => {
+ createComponent({
+ props: {
+ runnerTypes: [GROUP_TYPE, PROJECT_TYPE],
+ },
+ });
+
+ expect(getTabsTitles()).toEqual(['All', 'Group', 'Project']);
});
it('"All" is selected by default', () => {
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
index ebb2e67d1e2..8b76be396ef 100644
--- a/spec/frontend/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -1,9 +1,8 @@
+import Vue, { nextTick } from 'vue';
import { GlForm } from '@gitlab/ui';
-import { createLocalVue, mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import RunnerUpdateForm from '~/runner/components/runner_update_form.vue';
@@ -23,8 +22,7 @@ jest.mock('~/runner/sentry_utils');
const mockRunner = runnerData.data.runner;
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
describe('RunnerUpdateForm', () => {
let wrapper;
@@ -61,16 +59,13 @@ describe('RunnerUpdateForm', () => {
});
const createComponent = ({ props } = {}) => {
- wrapper = extendedWrapper(
- mount(RunnerUpdateForm, {
- localVue,
- propsData: {
- runner: mockRunner,
- ...props,
- },
- apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]),
- }),
- );
+ wrapper = mountExtended(RunnerUpdateForm, {
+ propsData: {
+ runner: mockRunner,
+ ...props,
+ },
+ apolloProvider: createMockApollo([[runnerUpdateMutation, runnerUpdateHandler]]),
+ });
};
const expectToHaveSubmittedRunnerContaining = (submittedRunner) => {
@@ -126,8 +121,21 @@ describe('RunnerUpdateForm', () => {
it('Updates runner with no changes', async () => {
await submitFormAndWait();
- // Some fields are not submitted
- const { ipAddress, runnerType, createdAt, status, ...submitted } = mockRunner;
+ // Some read-only fields are not submitted
+ const {
+ __typename,
+ ipAddress,
+ runnerType,
+ createdAt,
+ status,
+ editAdminUrl,
+ contactedAt,
+ userPermissions,
+ version,
+ groups,
+ jobCount,
+ ...submitted
+ } = mockRunner;
expectToHaveSubmittedRunnerContaining(submitted);
});
@@ -239,11 +247,11 @@ describe('RunnerUpdateForm', () => {
await submitFormAndWait();
expect(createAlert).toHaveBeenLastCalledWith({
- message: `Network error: ${mockErrorMsg}`,
+ message: mockErrorMsg,
});
expect(captureException).toHaveBeenCalledWith({
component: 'RunnerUpdateForm',
- error: new Error(`Network error: ${mockErrorMsg}`),
+ error: new Error(mockErrorMsg),
});
expect(findSubmitDisabledAttr()).toBeUndefined();
});
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
index 034b7848f35..7cb1f49d4f7 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -1,15 +1,19 @@
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
import { GlLink } from '@gitlab/ui';
-import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import {
+ extendedWrapper,
+ shallowMountExtended,
+ mountExtended,
+} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
+import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
@@ -22,6 +26,7 @@ import {
DEFAULT_SORT,
INSTANCE_TYPE,
GROUP_TYPE,
+ PROJECT_TYPE,
PARAM_KEY_STATUS,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
@@ -33,8 +38,7 @@ import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC';
@@ -54,6 +58,7 @@ describe('GroupRunnersApp', () => {
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
+ const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
@@ -62,14 +67,18 @@ describe('GroupRunnersApp', () => {
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
- const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
+ const mockCountQueryResult = (count) =>
+ Promise.resolve({
+ data: { group: { id: groupRunnersCountData.data.group.id, runners: { count } } },
+ });
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const handlers = [
[getGroupRunnersQuery, mockGroupRunnersQuery],
[getGroupRunnersCountQuery, mockGroupRunnersCountQuery],
];
wrapper = mountFn(GroupRunnersApp, {
- localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
registrationToken: mockRegistrationToken,
@@ -91,7 +100,7 @@ describe('GroupRunnersApp', () => {
});
it('shows total runner counts', async () => {
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
await waitForPromises();
@@ -102,6 +111,44 @@ describe('GroupRunnersApp', () => {
expect(stats).toMatch('Stale runners 2');
});
+ it('shows the runner tabs with a runner count for each type', async () => {
+ mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
+ switch (type) {
+ case GROUP_TYPE:
+ return mockCountQueryResult(2);
+ case PROJECT_TYPE:
+ return mockCountQueryResult(1);
+ default:
+ return mockCountQueryResult(4);
+ }
+ });
+
+ createComponent({ mountFn: mountExtended });
+ await waitForPromises();
+
+ expect(findRunnerTypeTabs().text()).toMatchInterpolatedText('All 4 Group 2 Project 1');
+ });
+
+ it('shows the runner tabs with a formatted runner count', async () => {
+ mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
+ switch (type) {
+ case GROUP_TYPE:
+ return mockCountQueryResult(2000);
+ case PROJECT_TYPE:
+ return mockCountQueryResult(1000);
+ default:
+ return mockCountQueryResult(3000);
+ }
+ });
+
+ createComponent({ mountFn: mountExtended });
+ await waitForPromises();
+
+ expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
+ 'All 3,000 Group 2,000 Project 1,000',
+ );
+ });
+
it('shows the runner setup instructions', () => {
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
@@ -116,7 +163,7 @@ describe('GroupRunnersApp', () => {
const { webUrl, node } = groupRunnersData.data.group.runners.edges[0];
const { id, shortSha } = node;
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
await waitForPromises();
@@ -136,7 +183,7 @@ describe('GroupRunnersApp', () => {
});
it('sets tokens in the filtered search', () => {
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
const tokens = findFilteredSearch().props('tokens');
@@ -215,11 +262,13 @@ describe('GroupRunnersApp', () => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue({
data: {
group: {
+ id: '1',
runners: { nodes: [] },
},
},
});
createComponent();
+ await waitForPromises();
});
it('shows a message for no results', async () => {
@@ -228,9 +277,10 @@ describe('GroupRunnersApp', () => {
});
describe('when runners query fails', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponent();
+ await waitForPromises();
});
it('error is shown to the user', async () => {
@@ -239,17 +289,18 @@ describe('GroupRunnersApp', () => {
it('error is reported to sentry', async () => {
expect(captureException).toHaveBeenCalledWith({
- error: new Error('Network error: Error!'),
+ error: new Error('Error!'),
component: 'GroupRunnersApp',
});
});
});
describe('Pagination', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
- createComponent({ mountFn: mount });
+ createComponent({ mountFn: mountExtended });
+ await waitForPromises();
});
it('more pages can be selected', () => {
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index 9c430e205ea..d80caa47752 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -5,6 +5,9 @@ import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql.
import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.query.graphql.json';
import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json';
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';
@@ -12,10 +15,13 @@ import groupRunnersCountData from 'test_fixtures/graphql/runner/get_group_runner
import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json';
export {
- runnerData,
+ runnersData,
runnersCountData,
runnersDataPaginated,
- runnersData,
+ runnerData,
+ runnerWithGroupData,
+ runnerProjectsData,
+ runnerJobsData,
groupRunnersData,
groupRunnersCountData,
groupRunnersDataPaginated,
diff --git a/spec/frontend/runner/utils_spec.js b/spec/frontend/runner/utils_spec.js
new file mode 100644
index 00000000000..3fa9784ecdf
--- /dev/null
+++ b/spec/frontend/runner/utils_spec.js
@@ -0,0 +1,65 @@
+import { formatJobCount, tableField, getPaginationVariables } from '~/runner/utils';
+
+describe('~/runner/utils', () => {
+ describe('formatJobCount', () => {
+ it('formats a number', () => {
+ expect(formatJobCount(1)).toBe('1');
+ expect(formatJobCount(99)).toBe('99');
+ });
+
+ it('formats a large count', () => {
+ expect(formatJobCount(1000)).toBe('1,000');
+ expect(formatJobCount(1001)).toBe('1,000+');
+ });
+
+ it('returns an empty string for non-numeric values', () => {
+ expect(formatJobCount(undefined)).toBe('');
+ expect(formatJobCount(null)).toBe('');
+ expect(formatJobCount('number')).toBe('');
+ });
+ });
+
+ describe('tableField', () => {
+ it('a field with options', () => {
+ expect(tableField({ key: 'name' })).toEqual({
+ key: 'name',
+ label: '',
+ tdAttr: { 'data-testid': 'td-name' },
+ thClass: expect.any(Array),
+ });
+ });
+
+ it('a field with a label', () => {
+ const label = 'A field name';
+
+ expect(tableField({ key: 'name', label })).toMatchObject({
+ label,
+ });
+ });
+
+ it('a field with custom classes', () => {
+ const mockClasses = ['foo', 'bar'];
+
+ expect(tableField({ thClasses: mockClasses })).toMatchObject({
+ thClass: expect.arrayContaining(mockClasses),
+ });
+ });
+ });
+
+ describe('getPaginationVariables', () => {
+ const after = 'AFTER_CURSOR';
+ const before = 'BEFORE_CURSOR';
+
+ it.each`
+ case | pagination | pageSize | variables
+ ${'next page'} | ${{ after }} | ${undefined} | ${{ after, first: 10 }}
+ ${'prev page'} | ${{ before }} | ${undefined} | ${{ before, last: 10 }}
+ ${'first page'} | ${{}} | ${undefined} | ${{ first: 10 }}
+ ${'next page with N items'} | ${{ after }} | ${20} | ${{ after, first: 20 }}
+ ${'prev page with N items'} | ${{ before }} | ${20} | ${{ before, last: 20 }}
+ ${'first page with N items'} | ${{}} | ${20} | ${{ first: 20 }}
+ `('navigates to $case', ({ pagination, pageSize, variables }) => {
+ expect(getPaginationVariables(pagination, pageSize)).toEqual(variables);
+ });
+ });
+});