Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-08-31 15:13:01 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-08-31 15:13:01 +0300
commit3034c7e6aa99d21c3d9fa1df01f60fdd3f32d914 (patch)
tree424f5a291abf1a93ff9870667ecb301b899972fc /spec
parent6170bdc060501ecf6f817a530b3dc9f2e39ad4c3 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/search_controller_spec.rb11
-rw-r--r--spec/features/admin/admin_runners_spec.rb2
-rw-r--r--spec/features/projects/fork_spec.rb5
-rw-r--r--spec/frontend/boards/components/board_blocked_icon_spec.js74
-rw-r--r--spec/frontend/boards/mock_data.js74
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_form_spec.js167
-rw-r--r--spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js177
-rw-r--r--spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js164
-rw-r--r--spec/frontend/runner/components/cells/runner_summary_field_spec.js49
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js62
-rw-r--r--spec/frontend/set_status_modal/set_status_form_spec.js167
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js8
-rw-r--r--spec/frontend/surveys/merge_request_performance/app_spec.js40
-rw-r--r--spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js13
-rw-r--r--spec/lib/gitlab/ci/config/entry/job_spec.rb3
-rw-r--r--spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb173
-rw-r--r--spec/lib/gitlab/ci/config/entry/processable_spec.rb32
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb27
-rw-r--r--spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/config/entry/variable_spec.rb212
-rw-r--r--spec/lib/gitlab/ci/config/entry/variables_spec.rb82
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb117
-rw-r--r--spec/lib/gitlab/config/entry/composable_hash_spec.rb9
-rw-r--r--spec/lib/gitlab/metrics/global_search_slis_spec.rb114
-rw-r--r--spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb23
-rw-r--r--spec/requests/admin/hook_logs_controller_spec.rb15
-rw-r--r--spec/requests/api/search_spec.rb11
-rw-r--r--spec/requests/projects/hook_logs_controller_spec.rb19
-rw-r--r--spec/requests/projects/settings/integration_hook_logs_controller_spec.rb20
-rw-r--r--spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb47
-rw-r--r--spec/tooling/danger/project_helper_spec.rb2
31 files changed, 1742 insertions, 195 deletions
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 14b198dbefe..0b8e6a6b6a2 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -270,6 +270,17 @@ RSpec.describe SearchController do
get(:show, params: { search: 'foo@bar.com', scope: 'users' })
end
end
+
+ it 'increments the custom search sli apdex' do
+ expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with(
+ elapsed: a_kind_of(Numeric),
+ search_scope: 'issues',
+ search_type: 'basic',
+ search_level: 'global'
+ )
+
+ get :show, params: { scope: 'issues', search: 'hello world' }
+ end
end
describe 'GET #count', :aggregate_failures do
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 91971406bd6..e50674228f6 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -81,7 +81,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
within_runner_row(runner.id) do
- expect(find("[data-label='Jobs']")).to have_content '2'
+ expect(find("[data-testid='job-count']")).to have_content '2'
end
end
diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb
index fb27f0961b6..b8c127f0078 100644
--- a/spec/features/projects/fork_spec.rb
+++ b/spec/features/projects/fork_spec.rb
@@ -126,7 +126,10 @@ RSpec.describe 'Project fork' do
let(:user) { create(:group_member, :maintainer, user: create(:user), group: group ).user }
def submit_form
- select(group.name)
+ find('[data-testid="select_namespace_dropdown"]').click
+ find('[data-testid="select_namespace_dropdown_search_field"]').fill_in(with: group.name)
+ click_button group.name
+
click_button 'Fork project'
end
diff --git a/spec/frontend/boards/components/board_blocked_icon_spec.js b/spec/frontend/boards/components/board_blocked_icon_spec.js
index cf4ba07da16..ffdc0a7cecc 100644
--- a/spec/frontend/boards/components/board_blocked_icon_spec.js
+++ b/spec/frontend/boards/components/board_blocked_icon_spec.js
@@ -10,13 +10,17 @@ import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
import { truncate } from '~/lib/utils/text_utility';
import {
mockIssue,
+ mockEpic,
mockBlockingIssue1,
mockBlockingIssue2,
+ mockBlockingEpic1,
mockBlockingIssuablesResponse1,
mockBlockingIssuablesResponse2,
mockBlockingIssuablesResponse3,
mockBlockedIssue1,
mockBlockedIssue2,
+ mockBlockedEpic1,
+ mockBlockingEpicIssuablesResponse1,
} from '../mock_data';
describe('BoardBlockedIcon', () => {
@@ -51,9 +55,11 @@ describe('BoardBlockedIcon', () => {
const createWrapperWithApollo = ({
item = mockBlockedIssue1,
blockingIssuablesSpy = jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1),
+ issuableItem = mockIssue,
+ issuableType = issuableTypes.issue,
} = {}) => {
mockApollo = createMockApollo([
- [blockingIssuablesQueries[issuableTypes.issue].query, blockingIssuablesSpy],
+ [blockingIssuablesQueries[issuableType].query, blockingIssuablesSpy],
]);
Vue.use(VueApollo);
@@ -62,27 +68,34 @@ describe('BoardBlockedIcon', () => {
apolloProvider: mockApollo,
propsData: {
item: {
- ...mockIssue,
+ ...issuableItem,
...item,
},
uniqueId: 'uniqueId',
- issuableType: issuableTypes.issue,
+ issuableType,
},
attachTo: document.body,
}),
);
};
- const createWrapper = ({ item = {}, queries = {}, data = {}, loading = false } = {}) => {
+ const createWrapper = ({
+ item = {},
+ queries = {},
+ data = {},
+ loading = false,
+ mockIssuable = mockIssue,
+ issuableType = issuableTypes.issue,
+ } = {}) => {
wrapper = extendedWrapper(
shallowMount(BoardBlockedIcon, {
propsData: {
item: {
- ...mockIssue,
+ ...mockIssuable,
...item,
},
uniqueId: 'uniqueid',
- issuableType: issuableTypes.issue,
+ issuableType,
},
data() {
return {
@@ -105,11 +118,24 @@ describe('BoardBlockedIcon', () => {
);
};
- it('should render blocked icon', () => {
- createWrapper();
+ it.each`
+ mockIssuable | issuableType | expectedIcon
+ ${mockIssue} | ${issuableTypes.issue} | ${'issue-block'}
+ ${mockEpic} | ${issuableTypes.epic} | ${'entity-blocked'}
+ `(
+ 'should render blocked icon for $issuableType',
+ ({ mockIssuable, issuableType, expectedIcon }) => {
+ createWrapper({
+ mockIssuable,
+ issuableType,
+ });
- expect(findGlIcon().exists()).toBe(true);
- });
+ expect(findGlIcon().exists()).toBe(true);
+ const icon = findGlIcon();
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe(expectedIcon);
+ },
+ );
it('should display a loading spinner while loading', () => {
createWrapper({ loading: true });
@@ -124,17 +150,29 @@ describe('BoardBlockedIcon', () => {
});
describe('on mouseenter on blocked icon', () => {
- it('should query for blocking issuables and render the result', async () => {
- createWrapperWithApollo();
+ it.each`
+ item | issuableType | mockBlockingIssuable | issuableItem | blockingIssuablesSpy
+ ${mockBlockedIssue1} | ${issuableTypes.issue} | ${mockBlockingIssue1} | ${mockIssue} | ${jest.fn().mockResolvedValue(mockBlockingIssuablesResponse1)}
+ ${mockBlockedEpic1} | ${issuableTypes.epic} | ${mockBlockingEpic1} | ${mockEpic} | ${jest.fn().mockResolvedValue(mockBlockingEpicIssuablesResponse1)}
+ `(
+ 'should query for blocking issuables and render the result for $issuableType',
+ async ({ item, issuableType, issuableItem, mockBlockingIssuable, blockingIssuablesSpy }) => {
+ createWrapperWithApollo({
+ item,
+ issuableType,
+ issuableItem,
+ blockingIssuablesSpy,
+ });
- expect(findGlPopover().text()).not.toContain(mockBlockingIssue1.title);
+ expect(findGlPopover().text()).not.toContain(mockBlockingIssuable.title);
- await mouseenter();
+ await mouseenter();
- expect(findGlPopover().exists()).toBe(true);
- expect(findIssuableTitle().text()).toContain(mockBlockingIssue1.title);
- expect(wrapper.vm.skip).toBe(true);
- });
+ expect(findGlPopover().exists()).toBe(true);
+ expect(findIssuableTitle().text()).toContain(mockBlockingIssuable.title);
+ expect(wrapper.vm.skip).toBe(true);
+ },
+ );
it('should emit "blocking-issuables-error" event on query error', async () => {
const mockError = new Error('mayday');
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 0e739f03f31..dc1f3246be0 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -266,6 +266,7 @@ export const rawIssue = {
};
export const mockIssueFullPath = 'gitlab-org/test-subgroup/gitlab-test';
+export const mockEpicFullPath = 'gitlab-org/test-subgroup';
export const mockIssue = {
id: 'gid://gitlab/Issue/436',
@@ -291,6 +292,47 @@ export const mockIssue = {
type: 'ISSUE',
};
+export const mockEpic = {
+ id: 'gid://gitlab/Epic/26',
+ iid: '1',
+ group: {
+ id: 'gid://gitlab/Group/33',
+ fullPath: 'twitter',
+ __typename: 'Group',
+ },
+ title: 'Eum animi debitis occaecati ad non odio repellat voluptatem similique.',
+ state: 'opened',
+ reference: '&1',
+ referencePath: `${mockEpicFullPath}&1`,
+ webPath: `/groups/${mockEpicFullPath}/-/epics/1`,
+ webUrl: `${mockEpicFullPath}/-/epics/1`,
+ createdAt: '2022-01-18T05:15:15Z',
+ closedAt: null,
+ __typename: 'Epic',
+ relativePosition: null,
+ confidential: false,
+ subscribed: true,
+ blocked: true,
+ blockedByCount: 1,
+ labels: {
+ nodes: [],
+ __typename: 'LabelConnection',
+ },
+ hasIssues: true,
+ descendantCounts: {
+ closedEpics: 0,
+ closedIssues: 0,
+ openedEpics: 0,
+ openedIssues: 2,
+ __typename: 'EpicDescendantCount',
+ },
+ descendantWeightSum: {
+ closedIssues: 0,
+ openedIssues: 0,
+ __typename: 'EpicDescendantWeights',
+ },
+};
+
export const mockActiveIssue = {
...mockIssue,
id: 'gid://gitlab/Issue/436',
@@ -523,6 +565,15 @@ export const mockBlockingIssue1 = {
__typename: 'Issue',
};
+export const mockBlockingEpic1 = {
+ id: 'gid://gitlab/Epic/29',
+ iid: '4',
+ title: 'Sint nihil exercitationem aspernatur unde molestiae rem accusantium.',
+ reference: 'twitter&4',
+ webUrl: 'http://gdk.test:3000/groups/gitlab-org/test-subgroup/-/epics/4',
+ __typename: 'Epic',
+};
+
export const mockBlockingIssue2 = {
id: 'gid://gitlab/Issue/524',
iid: '5',
@@ -564,6 +615,23 @@ export const mockBlockingIssuablesResponse1 = {
},
};
+export const mockBlockingEpicIssuablesResponse1 = {
+ data: {
+ group: {
+ __typename: 'Group',
+ id: 'gid://gitlab/Group/33',
+ issuable: {
+ __typename: 'Epic',
+ id: 'gid://gitlab/Epic/26',
+ blockingIssuables: {
+ __typename: 'EpicConnection',
+ nodes: [mockBlockingEpic1],
+ },
+ },
+ },
+ },
+};
+
export const mockBlockingIssuablesResponse2 = {
data: {
issuable: {
@@ -601,6 +669,12 @@ export const mockBlockedIssue2 = {
webUrl: 'http://gdk.test:3000/gitlab-org/my-project-1/-/issues/0',
};
+export const mockBlockedEpic1 = {
+ id: '26',
+ blockedByCount: 1,
+ webUrl: 'http://gdk.test:3000/gitlab-org/test-subgroup/-/epics/1',
+};
+
export const mockMoveIssueParams = {
itemId: 1,
fromListId: 'gid://gitlab/List/1',
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
index 2a0fde45384..f676f2db08e 100644
--- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
@@ -4,11 +4,14 @@ import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter';
import { kebabCase } from 'lodash';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import createFlash from '~/flash';
-import httpStatus from '~/lib/utils/http_status';
import * as urlUtility from '~/lib/utils/url_utility';
import ForkForm from '~/pages/projects/forks/new/components/fork_form.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
+import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
jest.mock('~/flash');
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
@@ -16,6 +19,7 @@ jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('ForkForm component', () => {
let wrapper;
let axiosMock;
+ let mockQueryResponse;
const PROJECT_VISIBILITY_TYPE = {
private:
@@ -24,26 +28,11 @@ describe('ForkForm component', () => {
public: 'Public The project can be accessed without any authentication.',
};
- const GON_GITLAB_URL = 'https://gitlab.com';
const GON_API_VERSION = 'v7';
- const MOCK_NAMESPACES_RESPONSE = [
- {
- name: 'one',
- full_name: 'one-group/one',
- id: 1,
- },
- {
- name: 'two',
- full_name: 'two-group/two',
- id: 2,
- },
- ];
-
const DEFAULT_PROVIDE = {
newGroupPath: 'some/groups/path',
visibilityHelpPath: 'some/visibility/help/path',
- endpoint: '/some/project-full-path/-/forks/new.json',
projectFullPath: '/some/project-full-path',
projectId: '10',
projectName: 'Project Name',
@@ -53,12 +42,44 @@ describe('ForkForm component', () => {
restrictedVisibilityLevels: [],
};
- const mockGetRequest = (data = {}, statusCode = httpStatus.OK) => {
- axiosMock.onGet(DEFAULT_PROVIDE.endpoint).replyOnce(statusCode, data);
- };
+ Vue.use(VueApollo);
const createComponentFactory = (mountFn) => (provide = {}, data = {}) => {
+ const queryResponse = {
+ project: {
+ id: 'gid://gitlab/Project/1',
+ forkTargets: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Group/21',
+ fullPath: 'flightjs',
+ name: 'Flight JS',
+ visibility: 'public',
+ },
+ {
+ id: 'gid://gitlab/Namespace/4',
+ fullPath: 'root',
+ name: 'Administrator',
+ visibility: 'public',
+ },
+ ],
+ },
+ },
+ };
+
+ mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse });
+ const requestHandlers = [[searchQuery, mockQueryResponse]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: searchQuery,
+ data: {
+ ...queryResponse,
+ },
+ });
+
wrapper = mountFn(ForkForm, {
+ apolloProvider,
provide: {
...DEFAULT_PROVIDE,
...provide,
@@ -83,7 +104,6 @@ describe('ForkForm component', () => {
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
window.gon = {
- gitlab_url: GON_GITLAB_URL,
api_version: GON_API_VERSION,
};
});
@@ -93,12 +113,11 @@ describe('ForkForm component', () => {
axiosMock.restore();
});
- const findFormSelectOptions = () => wrapper.find('select[name="namespace"]').findAll('option');
const findPrivateRadio = () => wrapper.find('[data-testid="radio-private"]');
const findInternalRadio = () => wrapper.find('[data-testid="radio-internal"]');
const findPublicRadio = () => wrapper.find('[data-testid="radio-public"]');
const findForkNameInput = () => wrapper.find('[data-testid="fork-name-input"]');
- const findForkUrlInput = () => wrapper.find('[data-testid="fork-url-input"]');
+ const findForkUrlInput = () => wrapper.findComponent(ProjectNamespace);
const findForkSlugInput = () => wrapper.find('[data-testid="fork-slug-input"]');
const findForkDescriptionTextarea = () =>
wrapper.find('[data-testid="fork-description-textarea"]');
@@ -106,7 +125,6 @@ describe('ForkForm component', () => {
wrapper.find('[data-testid="fork-visibility-radio-group"]');
it('will go to projectFullPath when click cancel button', () => {
- mockGetRequest();
createComponent();
const { projectFullPath } = DEFAULT_PROVIDE;
@@ -115,8 +133,13 @@ describe('ForkForm component', () => {
expect(cancelButton.attributes('href')).toBe(projectFullPath);
});
+ const selectedMockNamespace = { name: 'two', full_name: 'two-group/two', id: 2 };
+
+ const fillForm = () => {
+ findForkUrlInput().vm.$emit('select', selectedMockNamespace);
+ };
+
it('has input with csrf token', () => {
- mockGetRequest();
createComponent();
expect(wrapper.find('input[name="authenticity_token"]').attributes('value')).toBe(
@@ -125,7 +148,6 @@ describe('ForkForm component', () => {
});
it('pre-populate form from project props', () => {
- mockGetRequest();
createComponent();
expect(findForkNameInput().attributes('value')).toBe(DEFAULT_PROVIDE.projectName);
@@ -135,75 +157,19 @@ describe('ForkForm component', () => {
);
});
- it('sets project URL prepend text with gon.gitlab_url', () => {
- mockGetRequest();
- createComponent();
-
- expect(wrapper.find(GlFormInputGroup).text()).toContain(`${GON_GITLAB_URL}/`);
- });
-
it('will have required attribute for required fields', () => {
- mockGetRequest();
createComponent();
expect(findForkNameInput().attributes('required')).not.toBeUndefined();
- expect(findForkUrlInput().attributes('required')).not.toBeUndefined();
expect(findForkSlugInput().attributes('required')).not.toBeUndefined();
expect(findVisibilityRadioGroup().attributes('required')).not.toBeUndefined();
expect(findForkDescriptionTextarea().attributes('required')).toBeUndefined();
});
- describe('forks namespaces', () => {
- beforeEach(() => {
- mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE });
- createFullComponent();
- });
-
- it('make GET request from endpoint', async () => {
- await axios.waitForAll();
-
- expect(axiosMock.history.get[0].url).toBe(DEFAULT_PROVIDE.endpoint);
- });
-
- it('generate default option', async () => {
- await axios.waitForAll();
-
- const optionsArray = findForkUrlInput().findAll('option');
-
- expect(optionsArray.at(0).text()).toBe('Select a namespace');
- });
-
- it('populate project url namespace options', async () => {
- await axios.waitForAll();
-
- const optionsArray = findForkUrlInput().findAll('option');
-
- expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1);
- expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].full_name);
- expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].full_name);
- });
-
- it('set namespaces in alphabetical order', async () => {
- const namespace = {
- name: 'three',
- full_name: 'aaa/three',
- id: 3,
- };
- mockGetRequest({
- namespaces: [...MOCK_NAMESPACES_RESPONSE, namespace],
- });
- createComponent();
- await axios.waitForAll();
-
- expect(wrapper.vm.namespaces).toEqual([namespace, ...MOCK_NAMESPACES_RESPONSE]);
- });
- });
-
describe('project slug', () => {
const projectPath = 'some other project slug';
beforeEach(() => {
- mockGetRequest();
createComponent({
projectPath,
});
@@ -232,7 +198,6 @@ describe('ForkForm component', () => {
describe('visibility level', () => {
it('displays the correct description', () => {
- mockGetRequest();
createComponent();
const formRadios = wrapper.findAll(GlFormRadio);
@@ -243,7 +208,6 @@ describe('ForkForm component', () => {
});
it('displays all 3 visibility levels', () => {
- mockGetRequest();
createComponent();
expect(wrapper.findAll(GlFormRadio)).toHaveLength(3);
@@ -262,16 +226,12 @@ describe('ForkForm component', () => {
},
];
- beforeEach(() => {
- mockGetRequest();
- });
-
it('resets the visibility to default "private"', async () => {
createFullComponent({ projectVisibility: 'public' }, { namespaces });
expect(wrapper.vm.form.fields.visibility.value).toBe('public');
- await findFormSelectOptions().at(1).setSelected();
+ fillForm();
await nextTick();
expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true);
@@ -280,8 +240,7 @@ describe('ForkForm component', () => {
it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => {
createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces });
- await findFormSelectOptions().at(1).setSelected();
-
+ fillForm();
await nextTick();
const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i });
@@ -315,8 +274,7 @@ describe('ForkForm component', () => {
${'public'} | ${[0, 20]}
${'public'} | ${[10, 20]}
${'public'} | ${[0, 10, 20]}
- `('checks the correct radio button', async ({ project, restrictedVisibilityLevels }) => {
- mockGetRequest();
+ `('checks the correct radio button', ({ project, restrictedVisibilityLevels }) => {
createFullComponent({
projectVisibility: project,
restrictedVisibilityLevels,
@@ -357,7 +315,7 @@ describe('ForkForm component', () => {
${'public'} | ${'public'} | ${undefined} | ${'true'} | ${'true'} | ${[0, 10, 20]}
`(
'sets appropriate radio button disabled state',
- async ({
+ ({
project,
namespace,
privateIsDisabled,
@@ -365,7 +323,6 @@ describe('ForkForm component', () => {
publicIsDisabled,
restrictedVisibilityLevels,
}) => {
- mockGetRequest();
createComponent(
{
projectVisibility: project,
@@ -387,11 +344,9 @@ describe('ForkForm component', () => {
const setupComponent = (fields = {}) => {
jest.spyOn(urlUtility, 'redirectTo').mockImplementation();
- mockGetRequest();
createFullComponent(
{},
{
- namespaces: MOCK_NAMESPACES_RESPONSE,
form: {
state: true,
...fields,
@@ -400,17 +355,13 @@ describe('ForkForm component', () => {
);
};
- const selectedMockNamespaceIndex = 1;
- const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id;
-
- const fillForm = async () => {
- const namespaceOptions = findForkUrlInput().findAll('option');
-
- await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected();
- };
+ beforeEach(() => {
+ setupComponent();
+ });
const submitForm = async () => {
- await fillForm();
+ fillForm();
+ await nextTick();
const form = wrapper.find(GlForm);
await form.trigger('submit');
@@ -418,7 +369,7 @@ describe('ForkForm component', () => {
};
describe('with invalid form', () => {
- it('does not make POST request', async () => {
+ it('does not make POST request', () => {
jest.spyOn(axios, 'post');
setupComponent();
@@ -471,7 +422,7 @@ describe('ForkForm component', () => {
description: projectDescription,
id: projectId,
name: projectName,
- namespace_id: namespaceId,
+ namespace_id: selectedMockNamespace.id,
path: projectPath,
visibility: projectVisibility,
};
diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
new file mode 100644
index 00000000000..1a88aebae32
--- /dev/null
+++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js
@@ -0,0 +1,177 @@
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+} from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import createFlash from '~/flash';
+import searchQuery from '~/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql';
+import ProjectNamespace from '~/pages/projects/forks/new/components/project_namespace.vue';
+
+jest.mock('~/flash');
+
+describe('ProjectNamespace component', () => {
+ let wrapper;
+ let originalGon;
+
+ const data = {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/1',
+ forkTargets: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Group/21',
+ fullPath: 'flightjs',
+ name: 'Flight JS',
+ visibility: 'public',
+ },
+ {
+ id: 'gid://gitlab/Namespace/4',
+ fullPath: 'root',
+ name: 'Administrator',
+ visibility: 'public',
+ },
+ ],
+ },
+ },
+ };
+
+ const mockQueryResponse = jest.fn().mockResolvedValue({ data });
+
+ const emptyQueryResponse = {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/1',
+ forkTargets: {
+ nodes: [],
+ },
+ },
+ };
+
+ const mockQueryError = jest.fn().mockRejectedValue(new Error('Network error'));
+
+ Vue.use(VueApollo);
+
+ const gitlabUrl = 'https://gitlab.com';
+
+ const defaultProvide = {
+ projectFullPath: 'gitlab-org/project',
+ };
+
+ const mountComponent = ({
+ provide = defaultProvide,
+ queryHandler = mockQueryResponse,
+ mountFn = shallowMount,
+ } = {}) => {
+ const requestHandlers = [[searchQuery, queryHandler]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = mountFn(ProjectNamespace, {
+ apolloProvider,
+ provide,
+ });
+ };
+
+ const findButtonLabel = () => wrapper.findComponent(GlButton);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownText = () => wrapper.findComponent(GlTruncate);
+ const findInput = () => wrapper.findComponent(GlSearchBoxByType);
+
+ const clickDropdownItem = async () => {
+ wrapper.findComponent(GlDropdownItem).vm.$emit('click');
+ await nextTick();
+ };
+
+ const showDropdown = () => {
+ findDropdown().vm.$emit('shown');
+ };
+
+ beforeAll(() => {
+ originalGon = window.gon;
+ window.gon = { gitlab_url: gitlabUrl };
+ });
+
+ afterAll(() => {
+ window.gon = originalGon;
+ wrapper.destroy();
+ });
+
+ describe('Initial state', () => {
+ beforeEach(() => {
+ mountComponent({ mountFn: mount });
+ jest.runOnlyPendingTimers();
+ });
+
+ it('renders the root url as a label', () => {
+ expect(findButtonLabel().text()).toBe(`${gitlabUrl}/`);
+ expect(findButtonLabel().props('label')).toBe(true);
+ });
+
+ it('renders placeholder text', () => {
+ expect(findDropdownText().props('text')).toBe('Select a namespace');
+ });
+ });
+
+ describe('After user interactions', () => {
+ beforeEach(async () => {
+ mountComponent({ mountFn: mount });
+ jest.runOnlyPendingTimers();
+ await nextTick();
+ showDropdown();
+ });
+
+ it('focuses on the input when the dropdown is opened', () => {
+ const spy = jest.spyOn(findInput().vm, 'focusInput');
+ showDropdown();
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('displays fetched namespaces', () => {
+ const listItems = wrapper.findAll('li');
+ expect(listItems).toHaveLength(3);
+ expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces');
+ expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath);
+ expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath);
+ });
+
+ it('sets the selected namespace', async () => {
+ const { fullPath } = data.project.forkTargets.nodes[0];
+ await clickDropdownItem();
+ expect(findDropdownText().props('text')).toBe(fullPath);
+ });
+ });
+
+ describe('With empty query response', () => {
+ beforeEach(() => {
+ mountComponent({ queryHandler: emptyQueryResponse, mountFn: mount });
+ jest.runOnlyPendingTimers();
+ });
+
+ it('renders `No matches found`', () => {
+ expect(wrapper.find('li').text()).toBe('No matches found');
+ });
+ });
+
+ describe('With error while fetching data', () => {
+ beforeEach(async () => {
+ mountComponent({ queryHandler: mockQueryError });
+ jest.runOnlyPendingTimers();
+ await nextTick();
+ });
+
+ it('creates a flash message and captures the error', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Something went wrong while loading data. Please refresh the page to try again.',
+ captureError: true,
+ error: expect.any(Error),
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js
new file mode 100644
index 00000000000..65550524baa
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_stacked_summary_cell_spec.js
@@ -0,0 +1,164 @@
+import { __ } from '~/locale';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerStackedSummaryCell from '~/runner/components/cells/runner_stacked_summary_cell.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RunnerTags from '~/runner/components/runner_tags.vue';
+import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants';
+
+import { allRunnersData } from '../../mock_data';
+
+const mockRunner = allRunnersData.data.runners.nodes[0];
+
+describe('RunnerTypeCell', () => {
+ let wrapper;
+
+ const findLockIcon = () => wrapper.findByTestId('lock-icon');
+ const findRunnerTags = () => wrapper.findComponent(RunnerTags);
+ const findRunnerSummaryField = (icon) =>
+ wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon)
+ .wrappers[0];
+
+ const createComponent = (runner, options) => {
+ wrapper = mountExtended(RunnerStackedSummaryCell, {
+ propsData: {
+ runner: {
+ ...mockRunner,
+ ...runner,
+ },
+ },
+ stubs: {
+ RunnerSummaryField,
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays the runner name as id and short token', () => {
+ expect(wrapper.text()).toContain(
+ `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
+ );
+ });
+
+ it('Does not display the locked icon', () => {
+ expect(findLockIcon().exists()).toBe(false);
+ });
+
+ it('Displays the locked icon for locked runners', () => {
+ createComponent({
+ runnerType: PROJECT_TYPE,
+ locked: true,
+ });
+
+ expect(findLockIcon().exists()).toBe(true);
+ });
+
+ it('Displays the runner type', () => {
+ createComponent({
+ runnerType: INSTANCE_TYPE,
+ locked: true,
+ });
+
+ expect(wrapper.text()).toContain('shared');
+ });
+
+ it('Displays the runner version', () => {
+ expect(wrapper.text()).toContain(mockRunner.version);
+ });
+
+ it('Displays the runner description', () => {
+ expect(wrapper.text()).toContain(mockRunner.description);
+ });
+
+ it('Displays last contact', () => {
+ createComponent({
+ contactedAt: '2022-01-02',
+ });
+
+ expect(findRunnerSummaryField('clock').find(TimeAgo).props('time')).toBe('2022-01-02');
+ });
+
+ it('Displays empty last contact', () => {
+ createComponent({
+ contactedAt: null,
+ });
+
+ expect(findRunnerSummaryField('clock').find(TimeAgo).exists()).toBe(false);
+ expect(findRunnerSummaryField('clock').text()).toContain(__('Never'));
+ });
+
+ it('Displays ip address', () => {
+ createComponent({
+ ipAddress: '127.0.0.1',
+ });
+
+ expect(findRunnerSummaryField('disk').text()).toContain('127.0.0.1');
+ });
+
+ it('Displays no ip address', () => {
+ createComponent({
+ ipAddress: null,
+ });
+
+ expect(findRunnerSummaryField('disk')).toBeUndefined();
+ });
+
+ it('Displays job count', () => {
+ expect(findRunnerSummaryField('pipeline').text()).toContain(`${mockRunner.jobCount}`);
+ });
+
+ it('Formats large job counts ', () => {
+ createComponent({
+ jobCount: 1000,
+ });
+
+ expect(findRunnerSummaryField('pipeline').text()).toContain('1,000');
+ });
+
+ it('Formats large job counts with a plus symbol', () => {
+ createComponent({
+ jobCount: 1001,
+ });
+
+ expect(findRunnerSummaryField('pipeline').text()).toContain('1,000+');
+ });
+
+ it('Displays created at', () => {
+ expect(findRunnerSummaryField('calendar').find(TimeAgo).props('time')).toBe(
+ mockRunner.createdAt,
+ );
+ });
+
+ it('Displays tag list', () => {
+ createComponent({
+ tagList: ['shell', 'linux'],
+ });
+
+ expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']);
+ });
+
+ it('Displays a custom slot', () => {
+ const slotContent = 'My custom runner name';
+
+ createComponent(
+ {},
+ {
+ slots: {
+ 'runner-name': slotContent,
+ },
+ },
+ );
+
+ expect(wrapper.text()).toContain(slotContent);
+ });
+});
diff --git a/spec/frontend/runner/components/cells/runner_summary_field_spec.js b/spec/frontend/runner/components/cells/runner_summary_field_spec.js
new file mode 100644
index 00000000000..b49addf112f
--- /dev/null
+++ b/spec/frontend/runner/components/cells/runner_summary_field_spec.js
@@ -0,0 +1,49 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerSummaryField from '~/runner/components/cells/runner_summary_field.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+describe('RunnerSummaryField', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip').value;
+
+ const createComponent = ({ props, ...options } = {}) => {
+ wrapper = shallowMount(RunnerSummaryField, {
+ propsData: {
+ icon: '',
+ tooltip: '',
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows content in slot', () => {
+ createComponent({
+ slots: { default: 'content' },
+ });
+
+ expect(wrapper.text()).toBe('content');
+ });
+
+ it('shows icon', () => {
+ createComponent({ props: { icon: 'git' } });
+
+ expect(findIcon().props('name')).toBe('git');
+ });
+
+ it('shows tooltip', () => {
+ createComponent({ props: { tooltip: 'tooltip' } });
+
+ expect(getTooltipValue()).toBe('tooltip');
+ });
+});
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 95673b3932b..c3c7616f338 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -22,7 +22,10 @@ describe('RunnerList', () => {
const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
- const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
+ const createComponent = (
+ { props = {}, provide = {}, ...options } = {},
+ mountFn = shallowMountExtended,
+ ) => {
wrapper = mountFn(RunnerList, {
propsData: {
runners: mockRunners,
@@ -32,6 +35,7 @@ describe('RunnerList', () => {
provide: {
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ ...provide,
},
...options,
});
@@ -221,4 +225,60 @@ describe('RunnerList', () => {
expect(findSkeletonLoader().exists()).toBe(false);
});
});
+
+ describe.each`
+ glFeatures
+ ${{ runnerListStackedLayoutAdmin: true }}
+ ${{ runnerListStackedLayout: true }}
+ `('When glFeatures = $glFeatures', ({ glFeatures }) => {
+ beforeEach(() => {
+ createComponent(
+ {
+ stubs: {
+ RunnerStatusPopover: {
+ template: '<div/>',
+ },
+ },
+ provide: {
+ glFeatures,
+ },
+ },
+ mountExtended,
+ );
+ });
+
+ it('Displays stacked list headers', () => {
+ const headerLabels = findHeaders().wrappers.map((w) => w.text());
+
+ expect(headerLabels).toEqual([
+ 'Status',
+ 'Runner',
+ '', // actions has no label
+ ]);
+ });
+
+ it('Displays stacked details of a runner', () => {
+ const { id, description, version, shortSha } = mockRunners[0];
+ const numericId = getIdFromGraphQLId(id);
+
+ // Badges
+ expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText('never contacted');
+
+ // Runner summary
+ const summary = findCell({ fieldKey: 'summary' }).text();
+
+ expect(summary).toContain(`#${numericId} (${shortSha})`);
+ expect(summary).toContain('specific');
+
+ expect(summary).toContain(version);
+ expect(summary).toContain(description);
+
+ expect(summary).toContain('Last contact');
+ expect(summary).toContain('0'); // job count
+ expect(summary).toContain('Created');
+
+ // Actions
+ expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/set_status_modal/set_status_form_spec.js b/spec/frontend/set_status_modal/set_status_form_spec.js
new file mode 100644
index 00000000000..8e1623eedf5
--- /dev/null
+++ b/spec/frontend/set_status_modal/set_status_form_spec.js
@@ -0,0 +1,167 @@
+import $ from 'jquery';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import SetStatusForm from '~/set_status_modal/set_status_form.vue';
+import EmojiPicker from '~/emoji/components/picker.vue';
+import { timeRanges } from '~/vue_shared/constants';
+import { sprintf } from '~/locale';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+
+describe('SetStatusForm', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ defaultEmoji: 'speech_balloon',
+ emoji: 'thumbsup',
+ message: 'Foo bar',
+ availability: false,
+ };
+
+ const createComponent = async ({ propsData = {} } = {}) => {
+ wrapper = mountExtended(SetStatusForm, {
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ const findMessageInput = () =>
+ wrapper.findByPlaceholderText(SetStatusForm.i18n.statusMessagePlaceholder);
+ const findSelectedEmoji = (emoji) =>
+ wrapper.findByTestId('selected-emoji').find(`gl-emoji[data-name="${emoji}"]`);
+
+ it('sets up emoji autocomplete for the message input', async () => {
+ const gfmAutoCompleteSetupSpy = jest.spyOn(GfmAutoComplete.prototype, 'setup');
+
+ await createComponent();
+
+ expect(gfmAutoCompleteSetupSpy).toHaveBeenCalledWith($(findMessageInput().element), {
+ emojis: true,
+ });
+ });
+
+ describe('when emoji is set', () => {
+ it('displays emoji', async () => {
+ await createComponent();
+
+ expect(findSelectedEmoji(defaultPropsData.emoji).exists()).toBe(true);
+ });
+ });
+
+ describe('when emoji is not set and message is changed', () => {
+ it('displays default emoji', async () => {
+ await createComponent({
+ propsData: {
+ emoji: '',
+ },
+ });
+
+ await findMessageInput().trigger('keyup');
+
+ expect(findSelectedEmoji(defaultPropsData.defaultEmoji).exists()).toBe(true);
+ });
+ });
+
+ describe('when message is set', () => {
+ it('displays filled in message input', async () => {
+ await createComponent();
+
+ expect(findMessageInput().element.value).toBe(defaultPropsData.message);
+ });
+ });
+
+ describe('when clear status after is set', () => {
+ it('displays value in dropdown toggle button', async () => {
+ const clearStatusAfter = timeRanges[0];
+
+ await createComponent({
+ propsData: {
+ clearStatusAfter,
+ },
+ });
+
+ expect(wrapper.findByRole('button', { name: clearStatusAfter.label }).exists()).toBe(true);
+ });
+ });
+
+ describe('when emoji is changed', () => {
+ beforeEach(async () => {
+ await createComponent();
+
+ wrapper.findComponent(EmojiPicker).vm.$emit('click', defaultPropsData.emoji);
+ });
+
+ it('emits `emoji-click` event', () => {
+ expect(wrapper.emitted('emoji-click')).toEqual([[defaultPropsData.emoji]]);
+ });
+ });
+
+ describe('when message is changed', () => {
+ it('emits `message-input` event', async () => {
+ await createComponent();
+
+ const newMessage = 'Foo bar baz';
+
+ await findMessageInput().setValue(newMessage);
+
+ expect(wrapper.emitted('message-input')).toEqual([[newMessage]]);
+ });
+ });
+
+ describe('when availability checkbox is changed', () => {
+ it('emits `availability-input` event', async () => {
+ await createComponent();
+
+ await wrapper
+ .findByLabelText(
+ `${SetStatusForm.i18n.availabilityCheckboxLabel} ${SetStatusForm.i18n.availabilityCheckboxHelpText}`,
+ )
+ .setChecked();
+
+ expect(wrapper.emitted('availability-input')).toEqual([[true]]);
+ });
+ });
+
+ describe('when `Clear status after` dropdown is changed', () => {
+ it('emits `clear-status-after-click`', async () => {
+ await wrapper.findByTestId('thirtyMinutes').trigger('click');
+
+ expect(wrapper.emitted('clear-status-after-click')).toEqual([[timeRanges[0]]]);
+ });
+ });
+
+ describe('when clear status button is clicked', () => {
+ beforeEach(async () => {
+ await createComponent();
+
+ await wrapper
+ .findByRole('button', { name: SetStatusForm.i18n.clearStatusButtonLabel })
+ .trigger('click');
+ });
+
+ it('clears emoji and message', () => {
+ expect(wrapper.emitted('emoji-click')).toEqual([['']]);
+ expect(wrapper.emitted('message-input')).toEqual([['']]);
+ expect(wrapper.findByTestId('no-emoji-placeholder').exists()).toBe(true);
+ });
+ });
+
+ describe('when `currentClearStatusAfter` prop is set', () => {
+ it('displays clear status message', async () => {
+ const date = '2022-08-25 21:14:48 UTC';
+
+ await createComponent({
+ propsData: {
+ currentClearStatusAfter: date,
+ },
+ });
+
+ expect(
+ wrapper.findByText(sprintf(SetStatusForm.i18n.clearStatusAfterMessage, { date })).exists(),
+ ).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
index e3b5478290a..4191c44bb99 100644
--- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -9,6 +9,7 @@ import stubChildren from 'helpers/stub_children';
import SetStatusModalWrapper, {
AVAILABILITY_STATUS,
} from '~/set_status_modal/set_status_modal_wrapper.vue';
+import SetStatusForm from '~/set_status_modal/set_status_form.vue';
jest.mock('~/flash');
@@ -42,6 +43,7 @@ describe('SetStatusModalWrapper', () => {
...stubChildren(SetStatusModalWrapper),
GlFormInput: false,
GlFormInputGroup: false,
+ SetStatusForm: false,
EmojiPicker: EmojiPickerStub,
},
mocks: {
@@ -118,10 +120,10 @@ describe('SetStatusModalWrapper', () => {
});
});
- it('sets emojiTag when clicking in emoji picker', async () => {
+ it('passes emoji to `SetStatusForm`', async () => {
await getEmojiPicker().vm.$emit('click', 'thumbsup');
- expect(wrapper.vm.emojiTag).toContain('data-name="thumbsup"');
+ expect(wrapper.findComponent(SetStatusForm).props('emoji')).toBe('thumbsup');
});
});
@@ -194,7 +196,7 @@ describe('SetStatusModalWrapper', () => {
findAvailabilityCheckbox().vm.$emit('input', true);
// set the currentClearStatusAfter to 30 minutes
- wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click');
+ wrapper.find('[data-testid="thirtyMinutes"]').trigger('click');
findModal().vm.$emit('primary');
await nextTick();
diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js
index 1d84685bf88..af91d8aeb6b 100644
--- a/spec/frontend/surveys/merge_request_performance/app_spec.js
+++ b/spec/frontend/surveys/merge_request_performance/app_spec.js
@@ -6,6 +6,17 @@ import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisse
import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
+const createRenderTrackedArguments = () => [
+ undefined,
+ 'survey:mr_experience',
+ {
+ label: 'render',
+ extra: {
+ accountAge: 0,
+ },
+ },
+];
+
describe('MergeRequestExperienceSurveyApp', () => {
let trackingSpy;
let wrapper;
@@ -24,6 +35,7 @@ describe('MergeRequestExperienceSurveyApp', () => {
dismiss,
shouldShowCallout,
});
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, {
propsData: {
accountAge: 0,
@@ -33,9 +45,12 @@ describe('MergeRequestExperienceSurveyApp', () => {
GlSprintf,
},
});
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
};
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
describe('when user callout is visible', () => {
beforeEach(() => {
createWrapper();
@@ -47,6 +62,16 @@ describe('MergeRequestExperienceSurveyApp', () => {
expect(wrapper.emitted().close).toBe(undefined);
});
+ it('tracks render once', async () => {
+ expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
+ });
+
+ it("doesn't track subsequent renders", async () => {
+ createWrapper();
+ expect(trackingSpy).toHaveBeenCalledWith(...createRenderTrackedArguments());
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ });
+
describe('when close button clicked', () => {
beforeEach(() => {
findCloseButton().vm.$emit('click');
@@ -68,6 +93,15 @@ describe('MergeRequestExperienceSurveyApp', () => {
},
});
});
+
+ it('tracks subsequent renders', async () => {
+ createWrapper();
+ expect(trackingSpy.mock.calls).toEqual([
+ createRenderTrackedArguments(),
+ expect.anything(),
+ createRenderTrackedArguments(),
+ ]);
+ });
});
it('applies correct feature name for user callout', () => {
@@ -148,6 +182,10 @@ describe('MergeRequestExperienceSurveyApp', () => {
it('emits close event', async () => {
expect(wrapper.emitted()).toMatchObject({ close: [[]] });
});
+
+ it("doesn't track anything", async () => {
+ expect(trackingSpy).toHaveBeenCalledTimes(0);
+ });
});
describe('when Escape key is pressed', () => {
diff --git a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
index cb53dc1fb61..063425454d7 100644
--- a/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/added_commit_message_spec.js
@@ -1,10 +1,10 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import AddedCommentMessage from '~/vue_merge_request_widget/components/added_commit_message.vue';
let wrapper;
function factory(propsData) {
- wrapper = shallowMount(AddedCommentMessage, {
+ wrapper = mount(AddedCommentMessage, {
propsData: {
isFastForwardEnabled: false,
targetBranch: 'main',
@@ -23,4 +23,13 @@ describe('Widget added commit message', () => {
expect(wrapper.element.outerHTML).toContain('The changes were not merged');
});
+
+ it('renders merge commit as a link', () => {
+ factory({ state: 'merged', mergeCommitPath: 'https://test.host/merge-commit-link' });
+
+ expect(wrapper.find('[data-testid="merge-commit-sha"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="merge-commit-sha"]').attributes('href')).toBe(
+ 'https://test.host/merge-commit-link',
+ );
+ });
});
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index ca336c3ecaa..75ac2ca87ab 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -605,8 +605,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Job do
let(:deps) do
double('deps',
'default_entry' => default,
- 'workflow_entry' => workflow,
- 'variables_value' => nil)
+ 'workflow_entry' => workflow)
end
context 'when job config overrides default config' do
diff --git a/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb b/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb
new file mode 100644
index 00000000000..252a2b4eacb
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/legacy_variables_spec.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::LegacyVariables do
+ let(:config) { {} }
+ let(:metadata) { {} }
+
+ subject(:entry) { described_class.new(config, **metadata) }
+
+ before do
+ entry.compose!
+ end
+
+ shared_examples 'valid config' do
+ describe '#value' do
+ it 'returns hash with key value strings' do
+ expect(entry.value).to eq result
+ end
+ end
+
+ describe '#errors' do
+ it 'does not append errors' do
+ expect(entry.errors).to be_empty
+ end
+ end
+
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
+ end
+ end
+ end
+
+ shared_examples 'invalid config' do |error_message|
+ describe '#valid?' do
+ it 'is not valid' do
+ expect(entry).not_to be_valid
+ end
+ end
+
+ describe '#errors' do
+ it 'saves errors' do
+ expect(entry.errors)
+ .to include(error_message)
+ end
+ end
+ end
+
+ context 'when entry config value has key-value pairs' do
+ let(:config) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
+
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: nil },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
+ end
+
+ context 'with numeric keys and values in the config' do
+ let(:config) { { 10 => 20 } }
+ let(:result) do
+ { '10' => '20' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+
+ context 'when key is an array' do
+ let(:config) { { ['VAR1'] => 'val1' } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+ end
+
+ context 'when value is a symbol' do
+ let(:config) { { 'VAR1' => :val1 } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+
+ context 'when value is a boolean' do
+ let(:config) { { 'VAR1' => true } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+ end
+
+ context 'when entry config value has key-value pair and hash' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
+ 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+
+ context 'when metadata has use_value_data: true' do
+ let(:metadata) { { use_value_data: true } }
+
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
+ end
+ end
+
+ context 'when entry value is an array' do
+ let(:config) { [:VAR, 'test'] }
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs/
+ end
+
+ context 'when metadata has use_value_data: true' do
+ let(:metadata) { { use_value_data: true } }
+
+ context 'when entry value has hash with other key-pairs' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' },
+ 'VARIABLE_2' => 'value 2' }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/
+ end
+
+ context 'when entry config value has hash with nil description' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1', description: nil } }
+ end
+
+ it_behaves_like 'invalid config', /should be a hash of key value pairs, value can be a hash/
+ end
+
+ context 'when entry config value has hash without description' do
+ let(:config) do
+ { 'VARIABLE_1' => { value: 'value 1' } }
+ end
+
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb
index 714b0a3b6aa..5f42a8c49a7 100644
--- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb
@@ -197,6 +197,34 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do
end
end
end
+
+ context 'when a variable has an invalid data attribute' do
+ let(:config) do
+ {
+ script: 'echo',
+ variables: { 'VAR1' => 'val 1', 'VAR2' => { value: 'val 2', description: 'hello var 2' } }
+ }
+ end
+
+ it 'reports error about variable' do
+ expect(entry.errors)
+ .to include 'variables:var2 config must be a string'
+ end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:entry_without_ff) { node_class.new(config, name: :rspec) }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ entry_without_ff.compose!
+ end
+
+ it 'reports error about variable' do
+ expect(entry_without_ff.errors)
+ .to include /config should be a hash of key value pairs/
+ end
+ end
+ end
end
end
@@ -212,13 +240,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do
let(:unspecified) { double('unspecified', 'specified?' => false) }
let(:default) { double('default', '[]' => unspecified) }
let(:workflow) { double('workflow', 'has_rules?' => false) }
- let(:variables) {}
let(:deps) do
double('deps',
default_entry: default,
- workflow_entry: workflow,
- variables_value: variables)
+ workflow_entry: workflow)
end
context 'with workflow rules' do
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index 55ad119ea21..5efc65f2117 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -350,6 +350,33 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
end
end
end
+
+ context 'when a variable has an invalid data key' do
+ let(:hash) do
+ { variables: { VAR1: { invalid: 'hello' } }, rspec: { script: 'hello' } }
+ end
+
+ describe '#errors' do
+ it 'reports errors about the invalid variable' do
+ expect(root.errors)
+ .to include /var1 config uses invalid data keys: invalid/
+ end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:root_without_ff) { described_class.new(hash, user: user, project: project) }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ root_without_ff.compose!
+ end
+
+ it 'reports errors about the invalid variable' do
+ expect(root_without_ff.errors)
+ .to include /variables config should be a hash of key value pairs, value can be a hash/
+ end
+ end
+ end
+ end
end
context 'when value is not a hash' do
diff --git a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
index c85fe366da6..303d825c591 100644
--- a/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/rules/rule_spec.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
require 'gitlab_chronic_duration'
-require_dependency 'active_model'
RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
let(:factory) do
@@ -363,7 +362,20 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
it { is_expected.not_to be_valid }
it 'returns an error about invalid variables:' do
- expect(subject.errors).to include(/variables config should be a hash of key value pairs/)
+ expect(subject.errors).to include(/variables config should be a hash/)
+ end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:entry_without_ff) { factory.create! }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ entry_without_ff.compose!
+ end
+
+ it 'returns an error about invalid variables:' do
+ expect(subject.errors).to include(/variables config should be a hash/)
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/variable_spec.rb b/spec/lib/gitlab/ci/config/entry/variable_spec.rb
new file mode 100644
index 00000000000..ebc4e8f9984
--- /dev/null
+++ b/spec/lib/gitlab/ci/config/entry/variable_spec.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Config::Entry::Variable do
+ let(:config) { {} }
+ let(:metadata) { {} }
+
+ subject(:entry) do
+ described_class.new(config, **metadata).tap do |entry|
+ entry.key = 'VAR1' # composable_hash requires key to be set
+ end
+ end
+
+ before do
+ entry.compose!
+ end
+
+ describe 'SimpleVariable' do
+ context 'when config is a string' do
+ let(:config) { 'value' }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+ end
+
+ context 'when config is an integer' do
+ let(:config) { 1 }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('1') }
+ end
+ end
+
+ context 'when config is an array' do
+ let(:config) { [] }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'variable definition must be either a string or a hash' }
+ end
+ end
+ end
+
+ describe 'ComplexVariable' do
+ context 'when config is a hash with description' do
+ let(:config) { { value: 'value', description: 'description' } }
+
+ context 'when metadata allowed_value_data is not provided' do
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config must be a string' }
+ end
+ end
+
+ context 'when metadata allowed_value_data is (value, description)' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: 'description') }
+ end
+
+ context 'when config value is a symbol' do
+ let(:config) { { value: :value, description: 'description' } }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: 'description') }
+ end
+ end
+
+ context 'when config value is an integer' do
+ let(:config) { { value: 123, description: 'description' } }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('123') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: '123', description: 'description') }
+ end
+ end
+
+ context 'when config value is an array' do
+ let(:config) { { value: ['value'], description: 'description' } }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config value must be an alphanumeric string' }
+ end
+ end
+
+ context 'when config description is a symbol' do
+ let(:config) { { value: 'value', description: :description } }
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: :description) }
+ end
+ end
+ end
+
+ context 'when metadata allowed_value_data is (value, xyz)' do
+ let(:metadata) { { allowed_value_data: %i[value xyz] } }
+
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config uses invalid data keys: description' }
+ end
+ end
+ end
+
+ context 'when config is a hash without description' do
+ let(:config) { { value: 'value' } }
+
+ context 'when metadata allowed_value_data is not provided' do
+ describe '#valid?' do
+ it { is_expected.not_to be_valid }
+ end
+
+ describe '#errors' do
+ subject(:errors) { entry.errors }
+
+ it { is_expected.to include 'var1 config must be a string' }
+ end
+ end
+
+ context 'when metadata allowed_value_data is (value, description)' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
+
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
+
+ describe '#value' do
+ subject(:value) { entry.value }
+
+ it { is_expected.to eq('value') }
+ end
+
+ describe '#value_with_data' do
+ subject(:value_with_data) { entry.value_with_data }
+
+ it { is_expected.to eq(value: 'value', description: nil) }
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb
index 78d37e228df..055975e4311 100644
--- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb
@@ -3,41 +3,46 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Variables do
+ let(:config) { {} }
let(:metadata) { {} }
- subject { described_class.new(config, **metadata) }
+ subject(:entry) { described_class.new(config, **metadata) }
+
+ before do
+ entry.compose!
+ end
shared_examples 'valid config' do
describe '#value' do
it 'returns hash with key value strings' do
- expect(subject.value).to eq result
+ expect(entry.value).to eq result
end
end
describe '#errors' do
it 'does not append errors' do
- expect(subject.errors).to be_empty
+ expect(entry.errors).to be_empty
end
end
describe '#valid?' do
it 'is valid' do
- expect(subject).to be_valid
+ expect(entry).to be_valid
end
end
end
- shared_examples 'invalid config' do
+ shared_examples 'invalid config' do |error_message|
describe '#valid?' do
it 'is not valid' do
- expect(subject).not_to be_valid
+ expect(entry).not_to be_valid
end
end
describe '#errors' do
it 'saves errors' do
- expect(subject.errors)
- .to include /should be a hash of key value pairs/
+ expect(entry.errors)
+ .to include(error_message)
end
end
end
@@ -52,6 +57,15 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
end
it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: nil },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
end
context 'with numeric keys and values in the config' do
@@ -63,33 +77,63 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
it_behaves_like 'valid config'
end
+ context 'when key is an array' do
+ let(:config) { { ['VAR1'] => 'val1' } }
+
+ it_behaves_like 'invalid config', /must be an alphanumeric string/
+ end
+
+ context 'when value is a symbol' do
+ let(:config) { { 'VAR1' => :val1 } }
+ let(:result) do
+ { 'VAR1' => 'val1' }
+ end
+
+ it_behaves_like 'valid config'
+ end
+
+ context 'when value is a boolean' do
+ let(:config) { { 'VAR1' => true } }
+
+ it_behaves_like 'invalid config', /must be either a string or a hash/
+ end
+
context 'when entry config value has key-value pair and hash' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
'VARIABLE_2' => 'value 2' }
end
- let(:result) do
- { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
- end
+ it_behaves_like 'invalid config', /variable_1 config must be a string/
- it_behaves_like 'invalid config'
+ context 'when metadata has allowed_value_data' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
- context 'when metadata has use_value_data' do
- let(:metadata) { { use_value_data: true } }
+ let(:result) do
+ { 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
+ end
it_behaves_like 'valid config'
+
+ describe '#value_with_data' do
+ it 'returns variable with data' do
+ expect(entry.value_with_data).to eq(
+ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
+ 'VARIABLE_2' => { value: 'value 2', description: nil }
+ )
+ end
+ end
end
end
context 'when entry value is an array' do
let(:config) { [:VAR, 'test'] }
- it_behaves_like 'invalid config'
+ it_behaves_like 'invalid config', /variables config should be a hash/
end
- context 'when metadata has use_value_data' do
- let(:metadata) { { use_value_data: true } }
+ context 'when metadata has allowed_value_data' do
+ let(:metadata) { { allowed_value_data: %i[value description] } }
context 'when entry value has hash with other key-pairs' do
let(:config) do
@@ -97,7 +141,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
'VARIABLE_2' => 'value 2' }
end
- it_behaves_like 'invalid config'
+ it_behaves_like 'invalid config', /variable_1 config uses invalid data keys: hello/
end
context 'when entry config value has hash with nil description' do
@@ -105,7 +149,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Variables do
{ 'VARIABLE_1' => { value: 'value 1', description: nil } }
end
- it_behaves_like 'invalid config'
+ it_behaves_like 'invalid config', /variable_1 config description must be an alphanumeric string/
end
context 'when entry config value has hash without description' do
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index eafa8f8fb25..bffdd370179 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -1033,24 +1033,26 @@ module Gitlab
end
end
- describe 'Variables' do
- subject { Gitlab::Ci::YamlProcessor.new(YAML.dump(config)).execute }
+ # Change this to a `describe` block when removing the FF ci_variables_refactoring_to_variable
+ shared_examples 'Variables' do
+ subject(:execute) { described_class.new(config).execute }
- let(:build) { subject.builds.first }
+ let(:build) { execute.builds.first }
let(:job_variables) { build[:job_variables] }
let(:root_variables_inheritance) { build[:root_variables_inheritance] }
context 'when global variables are defined' do
- let(:variables) do
- { 'VAR1' => 'value1', 'VAR2' => 'value2' }
- end
-
let(:config) do
- {
- variables: variables,
- before_script: ['pwd'],
- rspec: { script: 'rspec' }
- }
+ <<~YAML
+ variables:
+ VAR1: value1
+ VAR2: value2
+
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ YAML
end
it 'returns global variables' do
@@ -1060,16 +1062,17 @@ module Gitlab
end
context 'when job variables are defined' do
- let(:config) do
- {
- before_script: ['pwd'],
- rspec: { script: 'rspec', variables: variables }
- }
- end
-
context 'when syntax is correct' do
- let(:variables) do
- { 'VAR1' => 'value1', 'VAR2' => 'value2' }
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ variables:
+ VAR1: value1
+ VAR2: value2
+ YAML
end
it 'returns job variables' do
@@ -1083,16 +1086,28 @@ module Gitlab
context 'when syntax is incorrect' do
context 'when variables defined but invalid' do
- let(:variables) do
- %w(VAR1 value1 VAR2 value2)
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ variables: [VAR1 value1 VAR2 value2]
+ YAML
end
- it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash of key value pairs/
+ it_behaves_like 'returns errors', /jobs:rspec:variables config should be a hash/
end
context 'when variables key defined but value not specified' do
- let(:variables) do
- nil
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ script: rspec
+ variables: null
+ YAML
end
it 'returns empty array' do
@@ -1109,10 +1124,12 @@ module Gitlab
context 'when job variables are not defined' do
let(:config) do
- {
- before_script: ['pwd'],
- rspec: { script: 'rspec' }
- }
+ <<~YAML
+ before_script: ['pwd']
+
+ rspec:
+ script: rspec
+ YAML
end
it 'returns empty array' do
@@ -1120,6 +1137,42 @@ module Gitlab
expect(root_variables_inheritance).to eq(true)
end
end
+
+ context 'when variables have different type of values' do
+ let(:config) do
+ <<~YAML
+ before_script: [pwd]
+
+ rspec:
+ variables:
+ VAR1: value1
+ VAR2: :value2
+ VAR3: 123
+ script: rspec
+ YAML
+ end
+
+ it 'returns job variables' do
+ expect(job_variables).to contain_exactly(
+ { key: 'VAR1', value: 'value1', public: true },
+ { key: 'VAR2', value: 'value2', public: true },
+ { key: 'VAR3', value: '123', public: true }
+ )
+ expect(root_variables_inheritance).to eq(true)
+ end
+ end
+ end
+
+ context 'when ci_variables_refactoring_to_variable is enabled' do
+ it_behaves_like 'Variables'
+ end
+
+ context 'when ci_variables_refactoring_to_variable is disabled' do
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ end
+
+ it_behaves_like 'Variables'
end
context 'when using `extends`' do
@@ -2705,13 +2758,13 @@ module Gitlab
context 'returns errors if variables is not a map' do
let(:config) { YAML.dump({ variables: "test", rspec: { script: "test" } }) }
- it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash'
+ it_behaves_like 'returns errors', 'variables config should be a hash'
end
context 'returns errors if variables is not a map of key-value strings' do
let(:config) { YAML.dump({ variables: { test: false }, rspec: { script: "test" } }) }
- it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash'
+ it_behaves_like 'returns errors', 'variable definition must be either a string or a hash'
end
context 'returns errors if job when is not on_success, on_failure or always' do
diff --git a/spec/lib/gitlab/config/entry/composable_hash_spec.rb b/spec/lib/gitlab/config/entry/composable_hash_spec.rb
index f64b39231a3..331c9efc741 100644
--- a/spec/lib/gitlab/config/entry/composable_hash_spec.rb
+++ b/spec/lib/gitlab/config/entry/composable_hash_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe Gitlab::Config::Entry::ComposableHash, :aggregate_failures do
let(:valid_config) do
{
DATABASE_SECRET: 'passw0rd',
- API_TOKEN: 'passw0rd2'
+ API_TOKEN: 'passw0rd2',
+ ACCEPT_PASSWORD: false
}
end
@@ -55,6 +56,12 @@ RSpec.describe Gitlab::Config::Entry::ComposableHash, :aggregate_failures do
expect(entry[:API_TOKEN].metadata).to eq(name: :API_TOKEN)
expect(entry[:API_TOKEN].parent.class).to eq(Gitlab::Config::Entry::ComposableHash)
expect(entry[:API_TOKEN].value).to eq('passw0rd2')
+ expect(entry[:ACCEPT_PASSWORD]).to be_a(Gitlab::Config::Entry::Node)
+ expect(entry[:ACCEPT_PASSWORD].description).to eq('ACCEPT_PASSWORD node definition')
+ expect(entry[:ACCEPT_PASSWORD].key).to eq(:ACCEPT_PASSWORD)
+ expect(entry[:ACCEPT_PASSWORD].metadata).to eq(name: :ACCEPT_PASSWORD)
+ expect(entry[:ACCEPT_PASSWORD].parent.class).to eq(Gitlab::Config::Entry::ComposableHash)
+ expect(entry[:ACCEPT_PASSWORD].value).to eq(false)
end
describe '#descendants' do
diff --git a/spec/lib/gitlab/metrics/global_search_slis_spec.rb b/spec/lib/gitlab/metrics/global_search_slis_spec.rb
new file mode 100644
index 00000000000..23f79abc27e
--- /dev/null
+++ b/spec/lib/gitlab/metrics/global_search_slis_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Metrics::GlobalSearchSlis do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ stub_feature_flags(global_search_custom_slis: feature_flag_enabled)
+ end
+
+ describe '#initialize_slis!' do
+ context 'when global_search_custom_slis feature flag is enabled' do
+ let(:feature_flag_enabled) { true }
+
+ it 'initializes Apdex SLI for global_search' do
+ expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(
+ :global_search,
+ a_kind_of(Array)
+ )
+
+ described_class.initialize_slis!
+ end
+ end
+
+ context 'when global_search_custom_slis feature flag is disabled' do
+ let(:feature_flag_enabled) { false }
+
+ it 'does not initialzie the Apdex SLI for global_search' do
+ expect(Gitlab::Metrics::Sli::Apdex).not_to receive(:initialize_sli)
+
+ described_class.initialize_slis!
+ end
+ end
+ end
+
+ describe '#record_apdex' do
+ context 'when global_search_custom_slis feature flag is enabled' do
+ let(:feature_flag_enabled) { true }
+
+ where(:search_type, :code_search, :duration_target) do
+ 'basic' | false | 7.031
+ 'basic' | true | 21.903
+ 'advanced' | false | 4.865
+ 'advanced' | true | 13.546
+ end
+
+ with_them do
+ before do
+ allow(::Gitlab::ApplicationContext).to receive(:current_context_attribute).with(:caller_id).and_return('end')
+ end
+
+ let(:search_scope) { code_search ? 'blobs' : 'issues' }
+
+ it 'increments the global_search SLI as a success if the elapsed time is within the target' do
+ duration = duration_target - 0.1
+
+ expect(Gitlab::Metrics::Sli::Apdex[:global_search]).to receive(:increment).with(
+ labels: {
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope,
+ endpoint_id: 'end'
+ },
+ success: true
+ )
+
+ described_class.record_apdex(
+ elapsed: duration,
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope
+ )
+ end
+
+ it 'increments the global_search SLI as a failure if the elapsed time is not within the target' do
+ duration = duration_target + 0.1
+
+ expect(Gitlab::Metrics::Sli::Apdex[:global_search]).to receive(:increment).with(
+ labels: {
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope,
+ endpoint_id: 'end'
+ },
+ success: false
+ )
+
+ described_class.record_apdex(
+ elapsed: duration,
+ search_type: search_type,
+ search_level: 'global',
+ search_scope: search_scope
+ )
+ end
+ end
+ end
+
+ context 'when global_search_custom_slis feature flag is disabled' do
+ let(:feature_flag_enabled) { false }
+
+ it 'does not call increment on the apdex SLI' do
+ expect(Gitlab::Metrics::Sli::Apdex[:global_search]).not_to receive(:increment)
+
+ described_class.record_apdex(
+ elapsed: 1,
+ search_type: 'basic',
+ search_level: 'global',
+ search_scope: 'issues'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
index 7d96adf95e8..8d4629bf48b 100644
--- a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
+++ b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
@@ -150,6 +150,29 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do
}
)
end
+
+ context 'when the FF ci_variables_refactoring_to_variable is disabled' do
+ let(:entry_without_ff) { described_class.new(config, with_image_ports: true) }
+
+ before do
+ stub_feature_flags(ci_variables_refactoring_to_variable: false)
+ entry_without_ff.compose!
+ end
+
+ it 'returns correct value' do
+ expect(entry_without_ff.value)
+ .to eq(
+ tag_list: ['webide'],
+ job_variables: [{ key: 'KEY', value: 'value', public: true }],
+ options: {
+ image: { name: "image:1.0" },
+ services: [{ name: "mysql" }],
+ before_script: %w[ls pwd],
+ script: ['sleep 100']
+ }
+ )
+ end
+ end
end
end
end
diff --git a/spec/requests/admin/hook_logs_controller_spec.rb b/spec/requests/admin/hook_logs_controller_spec.rb
new file mode 100644
index 00000000000..f8d3381c052
--- /dev/null
+++ b/spec/requests/admin/hook_logs_controller_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::HookLogsController, :enable_admin_mode do
+ let_it_be(:user) { create(:admin) }
+ let_it_be_with_refind(:web_hook) { create(:system_hook) }
+ let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) }
+
+ it_behaves_like WebHooks::HookLogActions do
+ let!(:show_path) { admin_hook_hook_log_path(web_hook, web_hook_log) }
+ let!(:retry_path) { retry_admin_hook_hook_log_path(web_hook, web_hook_log) }
+ let(:edit_hook_path) { edit_admin_hook_path(web_hook) }
+ end
+end
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 6034d26f1d2..bc410f657b9 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -351,6 +351,17 @@ RSpec.describe API::Search do
end
end
+ it 'increments the custom search sli apdex' do
+ expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with(
+ elapsed: a_kind_of(Numeric),
+ search_scope: 'issues',
+ search_type: 'basic',
+ search_level: 'global'
+ )
+
+ get api(endpoint, user), params: { scope: 'issues', search: 'john doe' }
+ end
+
it 'sets global search information for logging' do
expect(Gitlab::Instrumentation::GlobalSearchApi).to receive(:set_information).with(
type: 'basic',
diff --git a/spec/requests/projects/hook_logs_controller_spec.rb b/spec/requests/projects/hook_logs_controller_spec.rb
new file mode 100644
index 00000000000..8b3ec307e53
--- /dev/null
+++ b/spec/requests/projects/hook_logs_controller_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::HookLogsController do
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_refind(:web_hook) { create(:project_hook) }
+ let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) }
+
+ let(:project) { web_hook.project }
+
+ it_behaves_like WebHooks::HookLogActions do
+ let(:edit_hook_path) { edit_project_hook_url(project, web_hook) }
+
+ before do
+ project.add_owner(user)
+ end
+ end
+end
diff --git a/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb b/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb
new file mode 100644
index 00000000000..77daff901a1
--- /dev/null
+++ b/spec/requests/projects/settings/integration_hook_logs_controller_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Settings::IntegrationHookLogsController do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:integration) { create(:datadog_integration) }
+ let_it_be_with_refind(:web_hook) { integration.service_hook }
+ let_it_be_with_refind(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) }
+
+ let(:project) { integration.project }
+
+ it_behaves_like WebHooks::HookLogActions do
+ let(:edit_hook_path) { edit_project_settings_integration_url(project, integration) }
+
+ before do
+ project.add_owner(user)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb
new file mode 100644
index 00000000000..62c9c3508a8
--- /dev/null
+++ b/spec/support/shared_examples/controllers/concerns/web_hooks/integrations_hook_log_actions_shared_examples.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples WebHooks::HookLogActions do
+ let!(:show_path) { web_hook_log.present.details_path }
+ let!(:retry_path) { web_hook_log.present.retry_path }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ it 'renders a 200 if the hook exists' do
+ get show_path
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('hook_logs/show')
+ end
+
+ it 'renders a 404 if the hook does not exist' do
+ web_hook.destroy!
+ get show_path
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'POST #retry' do
+ it 'executes the hook and redirects to the service form' do
+ stub_request(:post, web_hook.url)
+
+ expect_next_found_instance_of(web_hook.class) do |hook|
+ expect(hook).to receive(:execute).and_call_original
+ end
+
+ post retry_path
+
+ expect(response).to redirect_to(edit_hook_path)
+ end
+
+ it 'renders a 404 if the hook does not exist' do
+ web_hook.destroy!
+ post retry_path
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
index 2f52c0fd36c..4cc5df385a5 100644
--- a/spec/tooling/danger/project_helper_spec.rb
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -31,6 +31,8 @@ RSpec.describe Tooling::Danger::ProjectHelper do
end
where(:path, :expected_categories) do
+ 'glfm_specification/example_snapshots/prosemirror_json.yml' | [:frontend]
+ 'glfm_specification/input/glfm_anything.yml' | [:frontend, :backend]
'usage_data.rb' | [:database, :backend, :product_intelligence]
'doc/foo.md' | [:docs]
'CONTRIBUTING.md' | [:docs]