diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-16 15:09:17 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-16 15:09:17 +0300 |
commit | 09dff3eec735ccbe001d165293ecebf195452071 (patch) | |
tree | 03c73077d0703edb9452145e7109835da2cd4918 /spec | |
parent | 78e911431fc575ff4f6c9b7e0f95c02b57a5e926 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
26 files changed, 887 insertions, 267 deletions
diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 704adfa568d..a13aed35768 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -7,6 +7,7 @@ RSpec.describe 'Value Stream Analytics', :js do let_it_be(:guest) { create(:user) } let_it_be(:project) { create(:project, :repository) } let_it_be(:stage_table_selector) { '[data-testid="vsa-stage-table"]' } + let_it_be(:metrics_selector) { "[data-testid='vsa-time-metrics']" } let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let(:milestone) { create(:milestone, project: project) } @@ -26,11 +27,13 @@ RSpec.describe 'Value Stream Analytics', :js do wait_for_requests end - it 'shows pipeline summary' do - expect(new_issues_counter).to have_content('-') - expect(commits_counter).to have_content('-') - expect(deploys_counter).to have_content('-') - expect(deployment_frequency_counter).to have_content('-') + it 'displays metrics' do + aggregate_failures 'with relevant values' do + expect(new_issues_counter).to have_content('-') + expect(commits_counter).to have_content('-') + expect(deploys_counter).to have_content('-') + expect(deployment_frequency_counter).to have_content('-') + end end it 'shows active stage with empty message' do @@ -60,11 +63,15 @@ RSpec.describe 'Value Stream Analytics', :js do visit project_cycle_analytics_path(project) end - it 'shows pipeline summary' do - expect(new_issues_counter).to have_content('1') - expect(commits_counter).to have_content('2') - expect(deploys_counter).to have_content('1') - expect(deployment_frequency_counter).to have_content('0') + it 'displays metrics' do + metrics_tiles = page.find(metrics_selector) + + aggregate_failures 'with relevant values' do + expect(metrics_tiles).to have_content('Commit') + expect(metrics_tiles).to have_content('Deploy') + expect(metrics_tiles).to have_content('Deployment Frequency') + expect(metrics_tiles).to have_content('New Issue') + end end it 'shows data on each stage', :sidekiq_might_not_need_inline do @@ -96,7 +103,7 @@ RSpec.describe 'Value Stream Analytics', :js do end it 'shows only relevant data' do - expect(new_issues_counter).to have_content('1') + expect(new_issue_counter).to have_content('1') end end end @@ -116,7 +123,7 @@ RSpec.describe 'Value Stream Analytics', :js do end it 'does not show the commit stats' do - expect(page).to have_no_selector(:xpath, commits_counter_selector) + expect(page.find(metrics_selector)).not_to have_selector("#commits") end it 'needs permissions to see restricted stages' do @@ -130,28 +137,29 @@ RSpec.describe 'Value Stream Analytics', :js do end end - def new_issues_counter - find(:xpath, "//p[contains(text(),'New Issue')]/preceding-sibling::h3") + def find_metric_tile(sel) + page.find("#{metrics_selector} #{sel}") end - def commits_counter_selector - "//p[contains(text(),'Commits')]/preceding-sibling::h3" + # When now use proper pluralization for the metric names, which affects the id + def new_issue_counter + find_metric_tile("#new-issue") end - def commits_counter - find(:xpath, commits_counter_selector) + def new_issues_counter + find_metric_tile("#new-issues") end - def deploys_counter - find(:xpath, "//p[contains(text(),'Deploy')]/preceding-sibling::h3", match: :first) + def commits_counter + find_metric_tile("#commits") end - def deployment_frequency_counter_selector - "//p[contains(text(),'Deployment Frequency')]/preceding-sibling::h3" + def deploys_counter + find_metric_tile("#deploys") end def deployment_frequency_counter - find(:xpath, deployment_frequency_counter_selector) + find_metric_tile("#deployment-frequency") end def expect_issue_to_be_present diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 5ca20028485..4bad67acc87 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -6,13 +6,13 @@ RSpec.describe 'New/edit issue', :js do include ActionView::Helpers::JavaScriptHelper include FormHelper - let!(:project) { create(:project) } - let!(:user) { create(:user)} - let!(:user2) { create(:user)} - let!(:milestone) { create(:milestone, project: project) } - let!(:label) { create(:label, project: project) } - let!(:label2) { create(:label, project: project) } - let!(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user)} + let_it_be(:user2) { create(:user)} + let_it_be(:milestone) { create(:milestone, project: project) } + let_it_be(:label) { create(:label, project: project) } + let_it_be(:label2) { create(:label, project: project) } + let_it_be(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } before do stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) @@ -234,6 +234,28 @@ RSpec.describe 'New/edit issue', :js do expect(page).to have_selector('.atwho-view') end + describe 'displays issue type options in the dropdown' do + before do + page.within('.issue-form') do + click_button 'Issue' + end + end + + it 'correctly displays the Issue type option with an icon', :aggregate_failures do + page.within('[data-testid="issue-type-select-dropdown"]') do + expect(page).to have_selector('[data-testid="issue-type-issue-icon"]') + expect(page).to have_content('Issue') + end + end + + it 'correctly displays the Incident type option with an icon', :aggregate_failures do + page.within('[data-testid="issue-type-select-dropdown"]') do + expect(page).to have_selector('[data-testid="issue-type-incident-icon"]') + expect(page).to have_content('Incident') + end + end + end + describe 'milestone' do let!(:milestone) { create(:milestone, title: '"><img src=x onerror=alert(document.domain)>', project: project) } diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 0b1a4f7ad1c..71830eed3ef 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -6,6 +6,7 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BaseComponent from '~/cycle_analytics/components/base.vue'; import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; import StageTable from '~/cycle_analytics/components/stage_table.vue'; +import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; import initState from '~/cycle_analytics/store/state'; import { @@ -23,6 +24,7 @@ const selectedStageEvents = issueEvents.events; const noDataSvgPath = 'path/to/no/data'; const noAccessSvgPath = 'path/to/no/access'; const selectedStageCount = stageCounts[selectedStage.id]; +const fullPath = 'full/path/to/foo'; Vue.use(Vuex); @@ -34,6 +36,7 @@ const defaultState = { createdBefore, createdAfter, stageCounts, + endpoints: { fullPath }, }; function createStore({ initialState = {}, initialGetters = {} }) { @@ -45,6 +48,10 @@ function createStore({ initialState = {}, initialGetters = {} }) { }, getters: { pathNavigationData: () => transformedProjectStagePathData, + filterParams: () => ({ + created_after: createdAfter, + created_before: createdBefore, + }), ...initialGetters, }, }); @@ -67,11 +74,17 @@ function createComponent({ initialState, initialGetters } = {}) { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPathNavigation = () => wrapper.findComponent(PathNavigation); -const findOverviewMetrics = () => wrapper.findByTestId('vsa-stage-overview-metrics'); +const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics); const findStageTable = () => wrapper.findComponent(StageTable); const findStageEvents = () => findStageTable().props('stageEvents'); const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title'); +const hasMetricsRequests = (reqs) => { + const foundReqs = findOverviewMetrics().props('requests'); + expect(foundReqs.length).toEqual(reqs.length); + expect(foundReqs.map(({ name }) => name)).toEqual(reqs); +}; + describe('Value stream analytics component', () => { beforeEach(() => { wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); @@ -94,6 +107,10 @@ describe('Value stream analytics component', () => { expect(findOverviewMetrics().exists()).toBe(true); }); + it('passes requests prop to the metrics component', () => { + hasMetricsRequests(['recent activity']); + }); + it('renders the stage table', () => { expect(findStageTable().exists()).toBe(true); }); @@ -110,6 +127,16 @@ describe('Value stream analytics component', () => { expect(findLoadingIcon().exists()).toBe(false); }); + describe('with `cycleAnalyticsForGroups=true` license', () => { + beforeEach(() => { + wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } }); + }); + + it('passes requests prop to the metrics component', () => { + hasMetricsRequests(['time summary', 'recent activity']); + }); + }); + describe('isLoading = true', () => { beforeEach(() => { wrapper = createComponent({ @@ -121,14 +148,14 @@ describe('Value stream analytics component', () => { expect(findPathNavigation().props('loading')).toBe(true); }); - it('does not render the overview metrics', () => { - expect(findOverviewMetrics().exists()).toBe(false); - }); - it('does not render the stage table', () => { expect(findStageTable().exists()).toBe(false); }); + it('renders the overview metrics', () => { + expect(findOverviewMetrics().exists()).toBe(true); + }); + it('renders the loading icon', () => { expect(findLoadingIcon().exists()).toBe(true); }); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index 71bb9fc63ed..c29e81edf46 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -15,8 +15,11 @@ export const getStageByTitle = (stages, title) => const fixtureEndpoints = { customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages', stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`, + metricsData: 'projects/analytics/value_stream_analytics/summary', }; +export const metricsData = getJSONFixture(fixtureEndpoints.metricsData); + export const customizableStagesAndEvents = getJSONFixture( fixtureEndpoints.customizableCycleAnalyticsStagesAndEvents, ); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 3d4a1ac672e..7fcfef98547 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -6,8 +6,6 @@ import { selectedStage, rawIssueEvents, issueEvents, - rawData, - convertedData, selectedValueStream, rawValueStreamStages, valueStreamStages, @@ -90,18 +88,17 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY} - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} - ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} - ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} - ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} - ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} - ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} + mutation | payload | stateKey | value + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'daysInPast'} | ${DEFAULT_DAYS_TO_DISPLAY} + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${DEFAULT_DAYS_TO_DISPLAY} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} + ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} + ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index b8bc2bf512a..69fed879fd8 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,40 +1,24 @@ import { useFakeDate } from 'helpers/fake_date'; import { - decorateData, transformStagesForPathNavigation, timeSummaryForPathNavigation, medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, calculateFormattedDayInPast, + prepareTimeMetricsData, } from '~/cycle_analytics/utils'; +import { slugify } from '~/lib/utils/text_utility'; import { selectedStage, - rawData, - convertedData, allowedStages, stageMedians, pathNavIssueMetric, rawStageMedians, + metricsData, } from './mock_data'; describe('Value stream analytics utils', () => { - describe('decorateData', () => { - const result = decorateData(rawData); - it('returns the summary data', () => { - expect(result.summary).toEqual(convertedData.summary); - }); - - it('returns `-` for summary data that has no value', () => { - const singleSummaryResult = decorateData({ - stats: [], - permissions: { issue: true }, - summary: [{ value: null, title: 'Commits' }], - }); - expect(singleSummaryResult.summary).toEqual([{ value: '-', title: 'Commits' }]); - }); - }); - describe('transformStagesForPathNavigation', () => { const stages = allowedStages; const response = transformStagesForPathNavigation({ @@ -129,4 +113,32 @@ describe('Value stream analytics utils', () => { expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' }); }); }); + + describe('prepareTimeMetricsData', () => { + let prepared; + const [first, second] = metricsData; + const firstKey = slugify(first.title); + const secondKey = slugify(second.title); + + beforeEach(() => { + prepared = prepareTimeMetricsData([first, second], { + [firstKey]: { description: 'Is a value that is good' }, + }); + }); + + it('will add a `key` based on the title', () => { + expect(prepared).toMatchObject([{ key: firstKey }, { key: secondKey }]); + }); + + it('will add a `label` key', () => { + expect(prepared).toMatchObject([{ label: 'New Issues' }, { label: 'Commits' }]); + }); + + it('will add a popover description using the key if it is provided', () => { + expect(prepared).toMatchObject([ + { description: 'Is a value that is good' }, + { description: '' }, + ]); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js new file mode 100644 index 00000000000..ffdb49a828c --- /dev/null +++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js @@ -0,0 +1,128 @@ +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { METRIC_TYPE_SUMMARY } from '~/api/analytics_api'; +import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue'; +import createFlash from '~/flash'; +import { group, metricsData } from './mock_data'; + +jest.mock('~/flash'); + +describe('ValueStreamMetrics', () => { + let wrapper; + let mockGetValueStreamSummaryMetrics; + + const { full_path: requestPath } = group; + const fakeReqName = 'Mock metrics'; + const metricsRequestFactory = () => ({ + request: mockGetValueStreamSummaryMetrics, + endpoint: METRIC_TYPE_SUMMARY, + name: fakeReqName, + }); + + const createComponent = ({ requestParams = {} } = {}) => { + return shallowMount(ValueStreamMetrics, { + propsData: { + requestPath, + requestParams, + requests: [metricsRequestFactory()], + }, + }); + }; + + const findMetrics = () => wrapper.findAllComponents(GlSingleStat); + + const expectToHaveRequest = (fields) => { + expect(mockGetValueStreamSummaryMetrics).toHaveBeenCalledWith({ + endpoint: METRIC_TYPE_SUMMARY, + requestPath, + ...fields, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with successful requests', () => { + beforeEach(() => { + mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData }); + wrapper = createComponent(); + }); + + it('will display a loader with pending requests', async () => { + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + }); + + describe('with data loaded', () => { + beforeEach(async () => { + await waitForPromises(); + }); + + it('fetches data from the value stream analytics endpoint', () => { + expectToHaveRequest({ params: {} }); + }); + + it.each` + index | value | title | unit + ${0} | ${metricsData[0].value} | ${metricsData[0].title} | ${metricsData[0].unit} + ${1} | ${metricsData[1].value} | ${metricsData[1].title} | ${metricsData[1].unit} + ${2} | ${metricsData[2].value} | ${metricsData[2].title} | ${metricsData[2].unit} + ${3} | ${metricsData[3].value} | ${metricsData[3].title} | ${metricsData[3].unit} + `( + 'renders a single stat component for the $title with value and unit', + ({ index, value, title, unit }) => { + const metric = findMetrics().at(index); + expect(metric.props()).toMatchObject({ value, title, unit: unit ?? '' }); + }, + ); + + it('will not display a loading icon', () => { + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + }); + + describe('with additional params', () => { + beforeEach(async () => { + wrapper = createComponent({ + requestParams: { + 'project_ids[]': [1], + created_after: '2020-01-01', + created_before: '2020-02-01', + }, + }); + + await waitForPromises(); + }); + + it('fetches data for the `getValueStreamSummaryMetrics` request', () => { + expectToHaveRequest({ + params: { + 'project_ids[]': [1], + created_after: '2020-01-01', + created_before: '2020-02-01', + }, + }); + }); + }); + }); + }); + + describe('with a request failing', () => { + beforeEach(async () => { + mockGetValueStreamSummaryMetrics = jest.fn().mockRejectedValue(); + wrapper = createComponent(); + + await waitForPromises(); + }); + + it('it should render an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: `There was an error while fetching value stream analytics ${fakeReqName} data.`, + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index efadb9b717d..9335d800a16 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -17,6 +17,8 @@ const defaultMockDiscussion = { notes, }; +const DEFAULT_TODO_COUNT = 2; + describe('Design discussions component', () => { let wrapper; @@ -41,8 +43,14 @@ describe('Design discussions component', () => { }, }; const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } }); + const readQuery = jest.fn().mockReturnValue({ + project: { + issue: { designCollection: { designs: { nodes: [{ currentUserTodos: { nodes: [] } }] } } }, + }, + }); const $apollo = { mutate, + provider: { clients: { defaultClient: { readQuery } } }, }; function createComponent(props = {}, data = {}) { @@ -69,6 +77,12 @@ describe('Design discussions component', () => { $apollo, $route: { hash: '#note_1', + params: { + id: 1, + }, + query: { + version: null, + }, }, }, }); @@ -138,7 +152,13 @@ describe('Design discussions component', () => { }); describe('when discussion is resolved', () => { + let dispatchEventSpy; + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(document, 'querySelector').mockReturnValue({ + innerText: DEFAULT_TODO_COUNT, + }); createComponent({ discussion: { ...defaultMockDiscussion, @@ -174,6 +194,24 @@ describe('Design discussions component', () => { expect(findResolveIcon().props('name')).toBe('check-circle-filled'); }); + it('emit todo:toggle when discussion is resolved', async () => { + createComponent( + { discussionWithOpenForm: defaultMockDiscussion.id }, + { discussionComment: 'test', isFormRendered: true }, + ); + findResolveButton().trigger('click'); + findReplyForm().vm.$emit('submitForm'); + + await mutate(); + await wrapper.vm.$nextTick(); + + const dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchedEvent.detail).toEqual({ count: DEFAULT_TODO_COUNT }); + expect(dispatchedEvent.type).toBe('todo:toggle'); + }); + describe('when replies are expanded', () => { beforeEach(() => { findRepliesWidget().vm.$emit('toggle'); diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index e53ad2e6afe..cdd07a16e90 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -172,3 +172,40 @@ export const moveDesignMutationResponseWithErrors = { }, }, }; + +export const resolveCommentMutationResponse = { + discussionToggleResolve: { + discussion: { + noteable: { + id: 'gid://gitlab/DesignManagement::Design/1', + currentUserTodos: { + nodes: [], + __typename: 'TodoConnection', + }, + __typename: 'Design', + }, + __typename: 'Discussion', + }, + errors: [], + __typename: 'DiscussionToggleResolvePayload', + }, +}; + +export const getDesignQueryResponse = { + project: { + issue: { + designCollection: { + designs: { + nodes: [ + { + id: 'gid://gitlab/DesignManagement::Design/1', + currentUserTodos: { + nodes: [{ id: 'gid://gitlab/Todo::1' }], + }, + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 943e21250b4..d9a6b65f986 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,16 +1,34 @@ +import MockAdapter from 'axios-mock-adapter'; import { Range, Position } from 'monaco-editor'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, + EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, + EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, +} from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import SourceEditor from '~/editor/source_editor'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import syntaxHighlight from '~/syntax_highlight'; + +jest.mock('~/syntax_highlight'); +jest.mock('~/flash'); describe('Markdown Extension for Source Editor', () => { let editor; let instance; let editorEl; + let panelSpy; + let mockAxios; + const projectPath = 'fooGroup/barProj'; const firstLine = 'This is a'; const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; const filePath = 'foo.md'; + const responseData = '<div>FooBar</div>'; const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); @@ -22,7 +40,13 @@ describe('Markdown Extension for Source Editor', () => { const selectionToString = () => instance.getSelection().toString(); const positionToString = () => instance.getPosition().toString(); + const togglePreview = async () => { + instance.togglePreview(); + await waitForPromises(); + }; + beforeEach(() => { + mockAxios = new MockAdapter(axios); setFixtures('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); @@ -31,12 +55,313 @@ describe('Markdown Extension for Source Editor', () => { blobPath: filePath, blobContent: text, }); - editor.use(new EditorMarkdownExtension()); + editor.use(new EditorMarkdownExtension({ instance, projectPath })); + panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); }); afterEach(() => { instance.dispose(); editorEl.remove(); + mockAxios.restore(); + }); + + it('sets up the instance', () => { + expect(instance.preview).toEqual({ + el: undefined, + action: expect.any(Object), + shown: false, + }); + expect(instance.projectPath).toBe(projectPath); + }); + + describe('cleanup', () => { + beforeEach(async () => { + mockAxios.onPost().reply(200, { body: responseData }); + await togglePreview(); + }); + + it('removes the contextual menu action', () => { + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); + + instance.cleanup(); + + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null); + }); + + it('toggles the `shown` flag', () => { + expect(instance.preview.shown).toBe(true); + instance.cleanup(); + expect(instance.preview.shown).toBe(false); + }); + + it('toggles the panel only if the preview is visible', () => { + const { el: previewEl } = instance.preview; + const parentEl = previewEl.parentElement; + + expect(previewEl).toBeVisible(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true); + + instance.cleanup(); + expect(previewEl).toBeHidden(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + + instance.cleanup(); + expect(previewEl).toBeHidden(); + expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + }); + + it('toggles the layout only if the preview is visible', () => { + const { width } = instance.getLayoutInfo(); + + expect(instance.preview.shown).toBe(true); + + instance.cleanup(); + + const { width: newWidth } = instance.getLayoutInfo(); + expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); + + instance.cleanup(); + expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); + }); + }); + + describe('fetchPreview', () => { + const group = 'foo'; + const project = 'bar'; + const setData = (path, g, p) => { + instance.projectPath = path; + document.body.setAttribute('data-group', g); + document.body.setAttribute('data-project', p); + }; + const fetchPreview = async () => { + instance.fetchPreview(); + await waitForPromises(); + }; + + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('correctly fetches preview based on projectPath', async () => { + setData(projectPath, group, project); + await fetchPreview(); + expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`); + expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text })); + }); + + it('correctly fetches preview based on group and project data attributes', async () => { + setData(undefined, group, project); + await fetchPreview(); + expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`); + expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text })); + }); + + it('puts the fetched content into the preview DOM element', async () => { + instance.preview.el = editorEl.parentElement; + await fetchPreview(); + expect(instance.preview.el.innerHTML).toEqual(responseData); + }); + + it('applies syntax highlighting to the preview content', async () => { + instance.preview.el = editorEl.parentElement; + await fetchPreview(); + expect(syntaxHighlight).toHaveBeenCalled(); + }); + + it('catches the errors when fetching the preview', async () => { + mockAxios.onPost().reply(500); + + await fetchPreview(); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('setupPreviewAction', () => { + it('adds the contextual menu action', () => { + expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); + }); + + it('does not set up action if one already exists', () => { + jest.spyOn(instance, 'addAction').mockImplementation(); + + instance.setupPreviewAction(); + expect(instance.addAction).not.toHaveBeenCalled(); + }); + + it('toggles preview when the action is triggered', () => { + jest.spyOn(instance, 'togglePreview').mockImplementation(); + + expect(instance.togglePreview).not.toHaveBeenCalled(); + + const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); + action.run(); + + expect(instance.togglePreview).toHaveBeenCalled(); + }); + }); + + describe('togglePreview', () => { + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('toggles preview flag on instance', () => { + expect(instance.preview.shown).toBe(false); + + instance.togglePreview(); + expect(instance.preview.shown).toBe(true); + + instance.togglePreview(); + expect(instance.preview.shown).toBe(false); + }); + + describe('model language changes', () => { + const plaintextPath = 'foo.txt'; + const markdownPath = 'foo.md'; + let cleanupSpy; + let actionSpy; + + beforeEach(() => { + cleanupSpy = jest.spyOn(instance, 'cleanup'); + actionSpy = jest.spyOn(instance, 'setupPreviewAction'); + instance.togglePreview(); + }); + + it('cleans up when switching away from markdown', async () => { + expect(instance.cleanup).not.toHaveBeenCalled(); + expect(instance.setupPreviewAction).not.toHaveBeenCalled(); + + instance.updateModelLanguage(plaintextPath); + + expect(cleanupSpy).toHaveBeenCalled(); + expect(actionSpy).not.toHaveBeenCalled(); + }); + + it('re-enables the action when switching back to markdown', () => { + instance.updateModelLanguage(plaintextPath); + + jest.clearAllMocks(); + + instance.updateModelLanguage(markdownPath); + + expect(cleanupSpy).not.toHaveBeenCalled(); + expect(actionSpy).toHaveBeenCalled(); + }); + + it('does not re-enable the action if we do not change the language', () => { + instance.updateModelLanguage(markdownPath); + + expect(cleanupSpy).not.toHaveBeenCalled(); + expect(actionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('panel DOM element set up', () => { + it('sets up an element to contain the preview and stores it on instance', () => { + expect(instance.preview.el).toBeUndefined(); + + instance.togglePreview(); + + expect(instance.preview.el).toBeDefined(); + expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe( + true, + ); + }); + + it('re-uses existing preview DOM element on repeated calls', () => { + instance.togglePreview(); + const origPreviewEl = instance.preview.el; + instance.togglePreview(); + + expect(instance.preview.el).toBe(origPreviewEl); + }); + + it('hides the preview DOM element by default', () => { + panelSpy.mockImplementation(); + instance.togglePreview(); + expect(instance.preview.el.style.display).toBe('none'); + }); + }); + + describe('preview layout setup', () => { + it('sets correct preview layout', () => { + jest.spyOn(instance, 'layout'); + const { width, height } = instance.getLayoutInfo(); + + instance.togglePreview(); + + expect(instance.layout).toHaveBeenCalledWith({ + width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, + height, + }); + }); + }); + + describe('preview panel', () => { + it('toggles preview CSS class on the editor', () => { + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + instance.togglePreview(); + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + true, + ); + instance.togglePreview(); + expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( + false, + ); + }); + + it('toggles visibility of the preview DOM element', async () => { + await togglePreview(); + expect(instance.preview.el.style.display).toBe('block'); + await togglePreview(); + expect(instance.preview.el.style.display).toBe('none'); + }); + + describe('hidden preview DOM element', () => { + it('listens to model changes and re-fetches preview', async () => { + expect(mockAxios.history.post).toHaveLength(0); + await togglePreview(); + expect(mockAxios.history.post).toHaveLength(1); + + instance.setValue('New Value'); + await waitForPromises(); + expect(mockAxios.history.post).toHaveLength(2); + }); + + it('stores disposable listener for model changes', async () => { + expect(instance.modelChangeListener).toBeUndefined(); + await togglePreview(); + expect(instance.modelChangeListener).toBeDefined(); + }); + }); + + describe('already visible preview', () => { + beforeEach(async () => { + await togglePreview(); + mockAxios.resetHistory(); + }); + + it('does not re-fetch the preview', () => { + instance.togglePreview(); + expect(mockAxios.history.post).toHaveLength(0); + }); + + it('disposes the model change event listener', () => { + const disposeSpy = jest.fn(); + instance.modelChangeListener = { + dispose: disposeSpy, + }; + instance.togglePreview(); + expect(disposeSpy).toHaveBeenCalled(); + }); + }); + }); }); describe('getSelectedText', () => { diff --git a/spec/frontend/fixtures/analytics.rb b/spec/frontend/fixtures/analytics.rb index 87cfbb72382..6d106dce166 100644 --- a/spec/frontend/fixtures/analytics.rb +++ b/spec/frontend/fixtures/analytics.rb @@ -51,4 +51,21 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do end end end + + describe Projects::Analytics::CycleAnalytics::SummaryController, type: :controller do + render_views + let(:params) { { namespace_id: group, project_id: project, value_stream_id: value_stream_id } } + + before do + project.add_developer(user) + + sign_in(user) + end + + it "projects/analytics/value_stream_analytics/summary" do + get(:show, params: params, format: :json) + + expect(response).to be_successful + end + end end diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issue_show/components/fields/type_spec.js index 0c8af60d50d..fac745716d7 100644 --- a/spec/frontend/issue_show/components/fields/type_spec.js +++ b/spec/frontend/issue_show/components/fields/type_spec.js @@ -1,4 +1,4 @@ -import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -35,6 +35,9 @@ describe('Issue type field component', () => { const findTypeFromGroup = () => wrapper.findComponent(GlFormGroup); const findTypeFromDropDown = () => wrapper.findComponent(GlDropdown); const findTypeFromDropDownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findTypeFromDropDownItemAt = (at) => findTypeFromDropDownItems().at(at); + const findTypeFromDropDownItemIconAt = (at) => + findTypeFromDropDownItems().at(at).findComponent(GlIcon); const createComponent = ({ data } = {}) => { fakeApollo = createMockApollo([], mockResolvers); @@ -60,6 +63,15 @@ describe('Issue type field component', () => { wrapper.destroy(); }); + it.each` + at | text | icon + ${0} | ${IssuableTypes[0].text} | ${IssuableTypes[0].icon} + ${1} | ${IssuableTypes[1].text} | ${IssuableTypes[1].icon} + `(`renders the issue type $text with an icon in the dropdown`, ({ at, text, icon }) => { + expect(findTypeFromDropDownItemIconAt(at).attributes('name')).toBe(icon); + expect(findTypeFromDropDownItemAt(at).text()).toBe(text); + }); + it('renders a form group with the correct label', () => { expect(findTypeFromGroup().attributes('label')).toBe(i18n.label); }); diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js index 76c35703106..3ff0bd73581 100644 --- a/spec/frontend/jobs/components/log/mock_data.js +++ b/spec/frontend/jobs/components/log/mock_data.js @@ -123,6 +123,15 @@ export const multipleCollapsibleSectionsMockData = [ }, ]; +export const backwardsCompatibilityTrace = [ + { + offset: 2365, + content: [], + section: 'download-artifacts', + section_duration: '00:01', + }, +]; + export const originalTrace = [ { offset: 1, diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 35ac2945ab5..0c5fa150002 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -19,6 +19,7 @@ import { collapsibleTrace, collapsibleTraceIncremental, multipleCollapsibleSectionsMockData, + backwardsCompatibilityTrace, } from '../components/log/mock_data'; describe('Jobs Store Utils', () => { @@ -297,6 +298,21 @@ describe('Jobs Store Utils', () => { expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection)); }); }); + + describe('backwards compatibility', () => { + beforeEach(() => { + result = logLinesParser(backwardsCompatibilityTrace); + }); + + it('should return an object with a parsedLines prop', () => { + expect(result).toEqual( + expect.objectContaining({ + parsedLines: expect.any(Array), + }), + ); + expect(result.parsedLines).toHaveLength(1); + }); + }); }); describe('findOffsetAndRemove', () => { diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js index be34b207c4b..71c22998b08 100644 --- a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js +++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js @@ -5,19 +5,21 @@ import TerraformNotification from '~/projects/terraform_notification/components/ jest.mock('~/lib/utils/common_utils'); -const bannerDissmisedKey = 'terraform_notification_dismissed_for_project_1'; +const terraformImagePath = '/path/to/image'; +const bannerDismissedKey = 'terraform_notification_dismissed'; describe('TerraformNotificationBanner', () => { let wrapper; - const propsData = { - projectId: 1, + const provideData = { + terraformImagePath, + bannerDismissedKey, }; const findBanner = () => wrapper.findComponent(GlBanner); beforeEach(() => { wrapper = shallowMount(TerraformNotification, { - propsData, + provide: provideData, stubs: { GlBanner }, }); }); @@ -27,19 +29,6 @@ describe('TerraformNotificationBanner', () => { parseBoolean.mockReturnValue(false); }); - describe('when the dismiss cookie is set', () => { - beforeEach(() => { - parseBoolean.mockReturnValue(true); - wrapper = shallowMount(TerraformNotification, { - propsData, - }); - }); - - it('should not render the banner', () => { - expect(findBanner().exists()).toBe(false); - }); - }); - describe('when the dismiss cookie is not set', () => { it('should render the banner', () => { expect(findBanner().exists()).toBe(true); @@ -51,8 +40,8 @@ describe('TerraformNotificationBanner', () => { await findBanner().vm.$emit('close'); }); - it('should set the cookie with the bannerDissmisedKey', () => { - expect(setCookie).toHaveBeenCalledWith(bannerDissmisedKey, true); + it('should set the cookie with the bannerDismissedKey', () => { + expect(setCookie).toHaveBeenCalledWith(bannerDismissedKey, true); }); it('should remove the banner', () => { diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 96aba312ba3..f2222a6affe 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -1,14 +1,26 @@ # frozen_string_literal: true -require "spec_helper" +require 'spec_helper' RSpec.describe IssuesHelper do let(:project) { create(:project) } let(:issue) { create :issue, project: project } let(:ext_project) { create :redmine_project } + describe '#work_item_type_icon' do + it 'returns icon of all standard base types' do + WorkItem::Type.base_types.each do |type| + expect(work_item_type_icon(type[0])).to eq "issue-type-#{type[0].to_s.dasherize}" + end + end + + it 'defaults to issue icon if type is unknown' do + expect(work_item_type_icon('invalid')).to eq 'issue-type-issue' + end + end + describe '#award_user_list' do - it "returns a comma-separated list of the first X users" do + it 'returns a comma-separated list of the first X users' do user = build_stubbed(:user, name: 'Joe') awards = Array.new(3, build_stubbed(:award_emoji, user: user)) @@ -24,7 +36,7 @@ RSpec.describe IssuesHelper do expect(award_user_list([award], nil)).to eq 'Joe' end - it "truncates lists" do + it 'truncates lists' do user = build_stubbed(:user, name: 'Jane') awards = Array.new(5, build_stubbed(:award_emoji, user: user)) @@ -32,14 +44,14 @@ RSpec.describe IssuesHelper do .to eq('Jane, Jane, Jane, and 2 more.') end - it "displays the current user in front of other users" do + it 'displays the current user in front of other users' do current_user = build_stubbed(:user) my_award = build_stubbed(:award_emoji, user: current_user) award = build_stubbed(:award_emoji, user: build_stubbed(:user, name: 'Jane')) awards = Array.new(5, award).push(my_award) expect(award_user_list(awards, current_user, limit: 2)) - .to eq("You, Jane, and 4 more.") + .to eq('You, Jane, and 4 more.') end end @@ -54,19 +66,19 @@ RSpec.describe IssuesHelper do end end - it "returns disabled string for unauthenticated user" do - expect(helper.award_state_class(awardable, AwardEmoji.all, nil)).to eq("disabled") + it 'returns disabled string for unauthenticated user' do + expect(helper.award_state_class(awardable, AwardEmoji.all, nil)).to eq('disabled') end - it "returns disabled for a user that does not have access to the awardable" do - expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq("disabled") + it 'returns disabled for a user that does not have access to the awardable' do + expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq('disabled') end - it "returns active string for author" do - expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq("active") + it 'returns active string for author' do + expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('active') end - it "is blank for a user that has access to the awardable" do + it 'is blank for a user that has access to the awardable' do user = build(:user) expect(helper).to receive(:can?).with(user, :award_emoji, awardable).and_return(true) @@ -74,40 +86,40 @@ RSpec.describe IssuesHelper do end end - describe "awards_sort" do - it "sorts a hash so thumbsup and thumbsdown are always on top" do - data = { "thumbsdown" => "some value", "lifter" => "some value", "thumbsup" => "some value" } + describe 'awards_sort' do + it 'sorts a hash so thumbsup and thumbsdown are always on top' do + data = { 'thumbsdown' => 'some value', 'lifter' => 'some value', 'thumbsup' => 'some value' } expect(awards_sort(data).keys).to eq(%w(thumbsup thumbsdown lifter)) end end - describe "#link_to_discussions_to_resolve" do - describe "passing only a merge request" do + describe '#link_to_discussions_to_resolve' do + describe 'passing only a merge request' do let(:merge_request) { create(:merge_request) } - it "links just the merge request" do + it 'links just the merge request' do expected_path = project_merge_request_path(merge_request.project, merge_request) expect(link_to_discussions_to_resolve(merge_request, nil)).to include(expected_path) end - it "contains the reference to the merge request" do + it 'contains the reference to the merge request' do expect(link_to_discussions_to_resolve(merge_request, nil)).to include(merge_request.to_reference) end end - describe "when passing a discussion" do + describe 'when passing a discussion' do let(:diff_note) { create(:diff_note_on_merge_request) } let(:merge_request) { diff_note.noteable } let(:discussion) { diff_note.to_discussion } - it "links to the merge request with first note if a single discussion was passed" do + it 'links to the merge request with first note if a single discussion was passed' do expected_path = Gitlab::UrlBuilder.build(diff_note) expect(link_to_discussions_to_resolve(merge_request, discussion)).to include(expected_path) end - it "contains both the reference to the merge request and a mention of the discussion" do + it 'contains both the reference to the merge request and a mention of the discussion' do expect(link_to_discussions_to_resolve(merge_request, discussion)).to include("#{merge_request.to_reference} (discussion #{diff_note.id})") end end @@ -235,13 +247,13 @@ RSpec.describe IssuesHelper do end describe '#use_startup_call' do - it "returns false when a query param is present" do + it 'returns false when a query param is present' do allow(controller.request).to receive(:query_parameters).and_return({ foo: 'bar' }) expect(helper.use_startup_call?).to eq(false) end - it "returns false when user has stored sort preference" do + it 'returns false when user has stored sort preference' do controller.instance_variable_set(:@sort, 'updated_asc') expect(helper.use_startup_call?).to eq(false) @@ -265,13 +277,13 @@ RSpec.describe IssuesHelper do it 'returns expected result' do expected = { - can_create_issue: "true", - can_reopen_issue: "true", - can_report_spam: "false", - can_update_issue: "true", + can_create_issue: 'true', + can_reopen_issue: 'true', + can_report_spam: 'false', + can_update_issue: 'true', iid: issue.iid, - is_issue_author: "false", - issue_type: "issue", + is_issue_author: 'false', + issue_type: 'issue', new_issue_path: new_project_issue_path(project), project_path: project.full_path, report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)), @@ -345,7 +357,7 @@ RSpec.describe IssuesHelper do end it 'returns manual ordering class' do - expect(helper.issue_manual_ordering_class).to eq("manual-ordering") + expect(helper.issue_manual_ordering_class).to eq('manual-ordering') end context 'when manual sorting disabled' do diff --git a/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb index f0b305d944a..4685d843ce0 100644 --- a/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/Terraform/base_gitlab_ci_yaml_spec.rb @@ -2,8 +2,8 @@ require 'spec_helper' -RSpec.describe 'Terraform/Base.latest.gitlab-ci.yml' do - subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base.latest') } +RSpec.describe 'Terraform/Base.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base') } describe 'the created pipeline' do let(:default_branch) { 'master' } diff --git a/spec/lib/gitlab/ci/templates/Terraform/base_latest_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/Terraform/base_latest_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..e35f2eabe8e --- /dev/null +++ b/spec/lib/gitlab/ci/templates/Terraform/base_latest_gitlab_ci_yaml_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Terraform/Base.latest.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform/Base.latest') } + + describe 'the created pipeline' do + let(:default_branch) { 'master' } + let(:pipeline_branch) { default_branch } + let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) } + let(:user) { project.owner } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + allow(project).to receive(:default_branch).and_return(default_branch) + end + + it 'does not create any jobs' do + expect(build_names).to be_empty + end + end +end diff --git a/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..936cd6ac8aa --- /dev/null +++ b/spec/lib/gitlab/ci/templates/terraform_gitlab_ci_yaml_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Terraform.gitlab-ci.yml' do + before do + allow(Gitlab::Template::GitlabCiYmlTemplate).to receive(:excluded_patterns).and_return([]) + end + + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('Terraform') } + + describe 'the created pipeline' do + let(:default_branch) { project.default_branch_or_main } + let(:pipeline_branch) { default_branch } + let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) } + let(:user) { project.owner } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + allow(project).to receive(:default_branch).and_return(default_branch) + end + + context 'on master branch' do + it 'creates init, validate and build jobs', :aggregate_failures do + expect(pipeline.errors).to be_empty + expect(build_names).to include('init', 'validate', 'build', 'deploy') + end + end + + context 'outside the master branch' do + let(:pipeline_branch) { 'patch-1' } + + before do + project.repository.create_branch(pipeline_branch, default_branch) + end + + it 'does not creates a deploy and a test job', :aggregate_failures do + expect(pipeline.errors).to be_empty + expect(build_names).not_to include('deploy') + end + end + end +end diff --git a/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb index dec26284b41..3d1306e82a5 100644 --- a/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb +++ b/spec/lib/gitlab/ci/templates/terraform_latest_gitlab_ci_yaml_spec.rb @@ -25,7 +25,8 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do end context 'on master branch' do - it 'creates init, validate and build jobs' do + it 'creates init, validate and build jobs', :aggregate_failures do + expect(pipeline.errors).to be_empty expect(build_names).to include('init', 'validate', 'build', 'deploy') end end @@ -37,7 +38,8 @@ RSpec.describe 'Terraform.latest.gitlab-ci.yml' do project.repository.create_branch(pipeline_branch, default_branch) end - it 'does not creates a deploy and a test job' do + it 'does not creates a deploy and a test job', :aggregate_failures do + expect(pipeline.errors).to be_empty expect(build_names).not_to include('deploy') end end diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb index 23068d5b76b..9544f0fe6ec 100644 --- a/spec/models/instance_configuration_spec.rb +++ b/spec/models/instance_configuration_spec.rb @@ -97,6 +97,41 @@ RSpec.describe InstanceConfiguration do end end + describe '#package_file_size_limits' do + let_it_be(:plan1) { create(:plan, name: 'plan1', title: 'Plan 1') } + let_it_be(:plan2) { create(:plan, name: 'plan2', title: 'Plan 2') } + + before do + create(:plan_limits, + plan: plan1, + conan_max_file_size: 1001, + maven_max_file_size: 1002, + npm_max_file_size: 1003, + nuget_max_file_size: 1004, + pypi_max_file_size: 1005, + terraform_module_max_file_size: 1006, + generic_packages_max_file_size: 1007 + ) + create(:plan_limits, + plan: plan2, + conan_max_file_size: 1101, + maven_max_file_size: 1102, + npm_max_file_size: 1103, + nuget_max_file_size: 1104, + pypi_max_file_size: 1105, + terraform_module_max_file_size: 1106, + generic_packages_max_file_size: 1107 + ) + end + + it 'returns package file size limits' do + file_size_limits = subject.settings[:package_file_size_limits] + + expect(file_size_limits[:Plan1]).to eq({ conan: 1001, maven: 1002, npm: 1003, nuget: 1004, pypi: 1005, terraform_module: 1006, generic: 1007 }) + expect(file_size_limits[:Plan2]).to eq({ conan: 1101, maven: 1102, npm: 1103, nuget: 1104, pypi: 1105, terraform_module: 1106, generic: 1107 }) + end + end + describe '#rate_limits' do before do Gitlab::CurrentSettings.current_application_settings.update!( diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 0e276191af1..5f720f8c4f8 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -8,6 +8,8 @@ RSpec.describe ProjectFeature do let(:project) { create(:project) } let(:user) { create(:user) } + it { is_expected.to belong_to(:project) } + describe 'PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT' do it 'has higher level than that of PRIVATE_FEATURES_MIN_ACCESS_LEVEL' do described_class::PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT.each do |feature, level| diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index cb1baa02e96..ba769e830fd 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -401,12 +401,6 @@ RSpec.describe ProjectStatistics do let(:stat) { :build_artifacts_size } it_behaves_like 'a statistic that increases storage_size asynchronously' - - it_behaves_like 'a statistic that increases storage_size' do - before do - stub_feature_flags(efficient_counter_attribute: false) - end - end end context 'when adjusting :pipeline_artifacts_size' do diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index aef8244c3d3..0f21736eda0 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -156,13 +156,6 @@ RSpec.describe Projects::UpdatePagesService do expect(GenericCommitStatus.last.description).to eq("pages site contains 3 file entries, while limit is set to 2") end - it 'does not limit pages file count if feature is disabled' do - stub_feature_flags(pages_limit_entries_count: false) - create(:plan_limits, :default_plan, pages_file_entries: 2) - - expect(execute).to eq(:success) - end - it 'removes pages after destroy' do expect(PagesWorker).to receive(:perform_in) expect(project.pages_deployed?).to be_falsey diff --git a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb index 99a09993900..f92ed3d7396 100644 --- a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb @@ -62,26 +62,6 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes| .to raise_error(ActiveModel::MissingAttributeError) end end - - context 'when feature flag is disabled' do - before do - stub_feature_flags(efficient_counter_attribute: false) - end - - it 'delegates to ActiveRecord update!' do - expect { subject } - .to change { model.reset.read_attribute(attribute) }.by(increment) - end - - it 'does not increment the counter in Redis' do - subject - - Gitlab::Redis::SharedState.with do |redis| - counter = redis.get(model.counter_key(attribute)) - expect(counter).to be_nil - end - end - end end end end diff --git a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb index 7b591ad84d1..2e01de2ea84 100644 --- a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb +++ b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb @@ -22,116 +22,6 @@ RSpec.shared_examples 'UpdateProjectStatistics' do |with_counter_attribute| it { is_expected.to be_new_record } - context 'when feature flag efficient_counter_attribute is disabled' do - before do - stub_feature_flags(efficient_counter_attribute: false) - end - - context 'when creating' do - it 'updates the project statistics' do - delta0 = reload_stat - - subject.save! - - delta1 = reload_stat - - expect(delta1).to eq(delta0 + read_attribute) - expect(delta1).to be > delta0 - end - - it 'schedules a namespace statistics worker' do - expect(Namespaces::ScheduleAggregationWorker) - .to receive(:perform_async).once - - subject.save! - end - end - - context 'when updating' do - let(:delta) { 42 } - - before do - subject.save! - end - - it 'updates project statistics' do - expect(ProjectStatistics) - .to receive(:increment_statistic) - .and_call_original - - subject.write_attribute(statistic_attribute, read_attribute + delta) - - expect { subject.save! } - .to change { reload_stat } - .by(delta) - end - - it 'schedules a namespace statistics worker' do - expect(Namespaces::ScheduleAggregationWorker) - .to receive(:perform_async).once - - subject.write_attribute(statistic_attribute, read_attribute + delta) - subject.save! - end - - it 'avoids N + 1 queries' do - subject.write_attribute(statistic_attribute, read_attribute + delta) - - control_count = ActiveRecord::QueryRecorder.new do - subject.save! - end - - subject.write_attribute(statistic_attribute, read_attribute + delta) - - expect do - subject.save! - end.not_to exceed_query_limit(control_count) - end - end - - context 'when destroying' do - before do - subject.save! - end - - it 'updates the project statistics' do - delta0 = reload_stat - - subject.destroy! - - delta1 = reload_stat - - expect(delta1).to eq(delta0 - read_attribute) - expect(delta1).to be < delta0 - end - - it 'schedules a namespace statistics worker' do - expect(Namespaces::ScheduleAggregationWorker) - .to receive(:perform_async).once - - subject.destroy! - end - - context 'when it is destroyed from the project level' do - it 'does not update the project statistics' do - expect(ProjectStatistics) - .not_to receive(:increment_statistic) - - project.update!(pending_delete: true) - project.destroy! - end - - it 'does not schedule a namespace statistics worker' do - expect(Namespaces::ScheduleAggregationWorker) - .not_to receive(:perform_async) - - project.update!(pending_delete: true) - project.destroy! - end - end - end - end - def expect_flush_counter_increments_worker_performed expect(FlushCounterIncrementsWorker) .to receive(:perform_in) |