Commit 0993bbd2 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents b8290149 bea8acc9
......@@ -6,7 +6,7 @@ import pipelinesMixin from '~/pipelines/mixins/pipelines';
import eventHub from '~/pipelines/event_hub';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { getParameterByName } from '~/lib/utils/common_utils';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesPaginationApiMixin from '~/pipelines/mixins/pipelines_pagination_api_mixin';
export default {
components: {
......@@ -16,7 +16,7 @@ export default {
GlModal,
GlLink,
},
mixins: [pipelinesMixin, CIPaginationMixin],
mixins: [pipelinesMixin, PipelinesPaginationApiMixin],
props: {
endpoint: {
type: String,
......
......@@ -2,9 +2,9 @@
import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { s__ } from '~/locale';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin';
import emptyState from './empty_state.vue';
import EnableReviewAppModal from './enable_review_app_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
......@@ -33,7 +33,7 @@ export default {
directives: {
'gl-modal': GlModalDirective,
},
mixins: [CIPaginationMixin, environmentsMixin],
mixins: [EnvironmentsPaginationApiMixin, environmentsMixin],
props: {
endpoint: {
type: String,
......
<script>
import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
import DeleteEnvironmentModal from '../components/delete_environment_modal.vue';
......@@ -14,7 +14,7 @@ export default {
StopEnvironmentModal,
},
mixins: [environmentsMixin, CIPaginationMixin],
mixins: [environmentsMixin, EnvironmentsPaginationApiMixin],
props: {
endpoint: {
......
/**
* API callbacks for pagination and tabs
* shared between Pipelines and Environments table.
*
* Components need to have `scope`, `page` and `requestData`
*/
......
......@@ -6,7 +6,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import { getParameterByName } from '~/lib/utils/common_utils';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesPaginationApiMixin from '../../mixins/pipelines_pagination_api_mixin';
import pipelinesMixin from '../../mixins/pipelines';
import PipelinesService from '../../services/pipelines_service';
import { validateParams } from '../../utils';
......@@ -22,7 +22,7 @@ export default {
PipelinesFilteredSearch,
GlIcon,
},
mixins: [pipelinesMixin, CIPaginationMixin],
mixins: [pipelinesMixin, PipelinesPaginationApiMixin],
props: {
store: {
type: Object,
......
......@@ -5,6 +5,7 @@ import {
GlTooltipDirective,
GlFriendlyWrap,
GlIcon,
GlLink,
GlButton,
GlPagination,
} from '@gitlab/ui';
......@@ -16,6 +17,7 @@ export default {
components: {
GlIcon,
GlFriendlyWrap,
GlLink,
GlButton,
GlPagination,
TestCaseDetails,
......@@ -97,11 +99,9 @@ export default {
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div>
<div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break">
<gl-friendly-wrap
v-if="testCase.file"
:symbols="$options.wrapSymbols"
:text="testCase.file"
/>
<gl-link v-if="testCase.file" :href="testCase.filePath" target="_blank">
<gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" />
</gl-link>
<gl-button
v-if="testCase.file"
v-gl-tooltip
......
/**
* API callbacks for pagination and tabs
*
* Components need to have `scope`, `page` and `requestData`
*/
import { validateParams } from '~/pipelines/utils';
import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils';
export default {
methods: {
onChangeTab(scope) {
if (this.scope === scope) {
return;
}
let params = {
scope,
page: '1',
};
params = this.onChangeWithFilter(params);
this.updateContent(params);
},
onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */
let params = {
page: Number(page).toString(),
};
if (this.scope) {
params.scope = this.scope;
}
params = this.onChangeWithFilter(params);
this.updateContent(params);
},
onChangeWithFilter(params) {
return { ...params, ...validateParams(this.requestData) };
},
updateInternalState(parameters) {
// stop polling
this.poll.stop();
const queryString = Object.keys(parameters)
.map((parameter) => {
const value = parameters[parameter];
// update internal state for UI
this[parameter] = value;
return `${parameter}=${encodeURIComponent(value)}`;
})
.join('&');
// update polling parameters
this.requestData = parameters;
historyPushState(buildUrlWithCurrentLocation(`?${queryString}`));
this.isLoading = true;
},
},
};
......@@ -58,8 +58,9 @@ const createLegacyPipelinesDetailApp = (mediator) => {
const createTestDetails = () => {
const el = document.querySelector(SELECTORS.PIPELINE_TESTS);
const { summaryEndpoint, suiteEndpoint } = el?.dataset || {};
const { blobPath, summaryEndpoint, suiteEndpoint } = el?.dataset || {};
const testReportsStore = createTestReportsStore({
blobPath,
summaryEndpoint,
suiteEndpoint,
});
......
import { addIconStatus, formattedTime } from './utils';
import { addIconStatus, formatFilePath, formattedTime } from './utils';
export const getTestSuites = (state) => {
const { test_suites: testSuites = [] } = state.testReports;
......@@ -17,7 +17,13 @@ export const getSuiteTests = (state) => {
const { page, perPage } = state.pageInfo;
const start = (page - 1) * perPage;
return testCases.map(addIconStatus).slice(start, start + perPage);
return testCases
.map((testCase) => ({
...testCase,
filePath: testCase.file ? `${state.blobPath}/${formatFilePath(testCase.file)}` : null,
}))
.map(addIconStatus)
.slice(start, start + perPage);
};
export const getSuiteTestCount = (state) => getSelectedSuite(state)?.test_cases?.length || 0;
export default ({ summaryEndpoint = '', suiteEndpoint = '' }) => ({
export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({
blobPath,
summaryEndpoint,
suiteEndpoint,
testReports: {},
......
import { __, sprintf } from '../../../locale';
import { TestStatus } from '../../constants';
/**
* Removes `./` from the beginning of a file path so it can be appended onto a blob path
* @param {String} file
* @returns {String} - formatted value
*/
export function formatFilePath(file) {
return file.replace(/^\.?\/*/, '');
}
export function iconForTestStatus(status) {
switch (status) {
case TestStatus.SUCCESS:
......
......@@ -3,20 +3,37 @@ import { GlSearchBoxByType } from '@gitlab/ui';
import { uniq } from 'lodash';
import { EXCLUDED_NODES, HIDE_CLASS, HIGHLIGHT_CLASS, TYPING_DELAY } from '../constants';
const origExpansions = new Map();
const findSettingsSection = (sectionSelector, node) => {
return node.parentElement.closest(sectionSelector);
};
const resetSections = ({ sectionSelector, expandSection, collapseSection }) => {
document.querySelectorAll(sectionSelector).forEach((section, index) => {
section.classList.remove(HIDE_CLASS);
if (index === 0) {
const restoreExpansionState = ({ expandSection, collapseSection }) => {
origExpansions.forEach((isExpanded, section) => {
if (isExpanded) {
expandSection(section);
} else {
collapseSection(section);
}
});
origExpansions.clear();
};
const saveExpansionState = (sections, { isExpanded }) => {
// If we've saved expansions before, don't override it.
if (origExpansions.size > 0) {
return;
}
sections.forEach((section) => origExpansions.set(section, isExpanded(section)));
};
const resetSections = ({ sectionSelector }) => {
document.querySelectorAll(sectionSelector).forEach((section) => {
section.classList.remove(HIDE_CLASS);
});
};
const clearHighlights = () => {
......@@ -85,6 +102,12 @@ export default {
type: String,
required: true,
},
isExpandedFn: {
type: Function,
required: false,
// default to a function that returns false
default: () => () => false,
},
},
data() {
return {
......@@ -97,6 +120,7 @@ export default {
sectionSelector: this.sectionSelector,
expandSection: this.expandSection,
collapseSection: this.collapseSection,
isExpanded: this.isExpandedFn,
};
this.searchTerm = value;
......@@ -104,7 +128,11 @@ export default {
clearResults(displayOptions);
if (value.length) {
saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions);
displayResults(displayOptions, search(this.searchRoot, value));
} else {
restoreExpansionState(displayOptions);
}
},
expandSection(section) {
......
import Vue from 'vue';
import $ from 'jquery';
import { expandSection, closeSection } from '~/settings_panels';
import { expandSection, closeSection, isExpanded } from '~/settings_panels';
import SearchSettings from '~/search_settings/components/search_settings.vue';
const mountSearch = ({ el }) =>
......@@ -12,10 +11,11 @@ const mountSearch = ({ el }) =>
props: {
searchRoot: document.querySelector('#content-body'),
sectionSelector: '.js-search-settings-section, section.settings',
isExpandedFn: isExpanded,
},
on: {
collapse: (section) => closeSection($(section)),
expand: (section) => expandSection($(section)),
collapse: closeSection,
expand: expandSection,
},
}),
});
......
import $ from 'jquery';
import { __ } from './locale';
export function expandSection($section) {
/**
* Returns true if the given section is expanded or not
*
* For legacy consistency, it supports both jQuery and DOM elements
*
* @param {jQuery | Element} section
*/
export function isExpanded(sectionArg) {
const section = sectionArg instanceof $ ? sectionArg[0] : sectionArg;
return section.classList.contains('expanded');
}
export function expandSection(sectionArg) {
const $section = $(sectionArg);
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse'));
// eslint-disable-next-line @gitlab/no-global-event-off
$section.find('.settings-content').off('scroll.expandSection').scrollTop(0);
......@@ -13,7 +28,9 @@ export function expandSection($section) {
}
}
export function closeSection($section) {
export function closeSection(sectionArg) {
const $section = $(sectionArg);
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Expand'));
$section.find('.settings-content').on('scroll.expandSection', () => expandSection($section));
$section.removeClass('expanded');
......@@ -26,7 +43,7 @@ export function closeSection($section) {
export function toggleSection($section) {
$section.removeClass('no-animate');
if ($section.hasClass('expanded')) {
if (isExpanded($section)) {
closeSection($section);
} else {
expandSection($section);
......@@ -38,7 +55,7 @@ export default function initSettingsPanels() {
const $section = $(elm);
$section.on('click.toggleSection', '.js-settings-toggle', () => toggleSection($section));
if (!$section.hasClass('expanded')) {
if (!isExpanded($section)) {
$section.find('.settings-content').on('scroll.expandSection', () => {
$section.removeClass('no-animate');
expandSection($section);
......
......@@ -326,7 +326,7 @@
color: $gl-text-color-secondary;
}
.badge.badge-pill + span:not(.badge.badge-pill) {
.badge.badge-pill + span:not(.badge):not(.badge-pill) {
// Expects up to 3 digits on the badge
margin-right: 40px;
}
......
......@@ -903,7 +903,7 @@ table a code {
padding: 0;
background-color: #4f4f4f;
}
.dropdown-menu .badge.badge-pill + span:not(.badge.badge-pill) {
.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) {
margin-right: 40px;
}
.dropdown-select {
......
......@@ -902,7 +902,7 @@ table a code {
padding: 0;
background-color: #dbdbdb;
}
.dropdown-menu .badge.badge-pill + span:not(.badge.badge-pill) {
.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) {
margin-right: 40px;
}
.dropdown-select {
......
......@@ -1174,7 +1174,7 @@ table a code {
padding: 0;
background-color: #dbdbdb;
}
.dropdown-menu .badge.badge-pill + span:not(.badge.badge-pill) {
.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) {
margin-right: 40px;
}
.dropdown-select {
......
......@@ -82,5 +82,6 @@
#js-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json) } }
suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json),
blob_path: project_blob_path(@project, @pipeline.sha) } }
= render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project
---
title: Add link to test case file in pipeline test report
merge_request: 53650
author:
type: added
......@@ -43,7 +43,7 @@ receive alert payloads in JSON format. You can always
1. Toggle the **Active** alert setting to display the **URL** and **Authorization Key**
for the webhook configuration.
### HTTP Endpoints **PREMIUM**
### HTTP Endpoints **(PREMIUM)**
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/4442) in GitLab Premium 13.6.
......@@ -72,7 +72,7 @@ side of the integrations list.
### External Prometheus integration
For GitLab versions 13.1 and greater, please read
For GitLab versions 13.1 and greater, read
[External Prometheus Instances](../metrics/alerts.md#external-prometheus-instances)
to configure alerts for this integration.
......
......@@ -144,6 +144,19 @@ To search for users, enter your criteria in the search field. The user search is
insensitive, and applies partial matching to name and username. To search for an email address,
you must provide the complete email address.
#### User impersonation
An administrator can "impersonate" any other user, including other administrator users.
This allows the administrator to "see what the user sees," and take actions on behalf of the user.
You can impersonate a user in the following ways:
- Through the UI, by selecting **Admin Area > Overview > Users > [Select a user] > Impersonate**.
- With the API, using [impersonation tokens](../../api/README.md#impersonation-tokens).
All impersonation activities are [captured with audit events](../../administration/audit_events.md#impersonation-data).
![user impersonation button](img/impersonate_user_button_v13_8.png)
#### Users statistics
The **Users statistics** page provides an overview of user accounts by role. These statistics are
......
......@@ -475,6 +475,16 @@ terminal.
Read the [Release CLI documentation](https://gitlab.com/gitlab-org/release-cli/-/blob/master/docs/index.md)
for details.
## Release Metrics **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/259703) in GitLab Premium 13.9.
Group-level release metrics are available by navigating to **Group > Analytics > CI/CD**.
These metrics include:
- Total number of releases in the group
- Percentage of projects in the group that have at least one release
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
......
# frozen_string_literal: true
module Gitlab
module Database
module Migrations
Observation = Struct.new(
:migration,
:walltime,
:success
)
class Instrumentation
attr_reader :observations
def initialize
@observations = []
end
def observe(migration, &block)
observation = Observation.new(migration)
observation.success = true
exception = nil
observation.walltime = Benchmark.realtime do
yield
rescue => e
exception = e
observation.success = false
end
record_observation(observation)
raise exception if exception
observation
end
private
def record_observation(observation)
@observations << observation
end
end
end
end
end
......@@ -231,5 +231,37 @@ namespace :gitlab do
puts "Found user created projects. Database active"
exit 0
end
desc 'Run migrations with instrumentation'
task :migration_testing, [:result_file] => :environment do |_, args|
result_file = args[:result_file] || raise("Please specify result_file argument")
raise "File exists already, won't overwrite: #{result_file}" if File.exist?(result_file)
verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, true
ctx = ActiveRecord::Base.connection.migration_context
existing_versions = ctx.get_all_versions.to_set
pending_migrations = ctx.migrations.reject do |migration|
existing_versions.include?(migration.version)
end
instrumentation = Gitlab::Database::Migrations::Instrumentation.new
pending_migrations.each do |migration|
instrumentation.observe(migration.version) do
ActiveRecord::Migrator.new(:up, ctx.migrations, ctx.schema_migration, migration.version).run
end
end
ensure
if instrumentation
File.open(result_file, 'wb+') do |io|
io << instrumentation.observations.to_json
end
end
ActiveRecord::Base.clear_cache!
ActiveRecord::Migration.verbose = verbose_was
end
end
end
import { getJSONFixture } from 'helpers/fixtures';
import * as getters from '~/pipelines/stores/test_reports/getters';
import { iconForTestStatus, formattedTime } from '~/pipelines/stores/test_reports/utils';
import {
iconForTestStatus,
formatFilePath,
formattedTime,
} from '~/pipelines/stores/test_reports/utils';
describe('Getters TestReports Store', () => {
let state;
......@@ -8,6 +12,7 @@ describe('Getters TestReports Store', () => {
const testReports = getJSONFixture('pipelines/test_report.json');
const defaultState = {
blobPath: '/test/blob/path',
testReports,
selectedSuiteIndex: 0,
pageInfo: {
......@@ -17,6 +22,7 @@ describe('Getters TestReports Store', () => {
};
const emptyState = {
blobPath: '',
testReports: {},
selectedSuite: null,
pageInfo: {
......@@ -74,6 +80,7 @@ describe('Getters TestReports Store', () => {
const expected = testReports.test_suites[0].test_cases
.map((x) => ({
...x,
filePath: `${state.blobPath}/${formatFilePath(x.file)}`,
formattedTime: formattedTime(x.execution_time),
icon: iconForTestStatus(x.status),
}))
......
import { formattedTime } from '~/pipelines/stores/test_reports/utils';
import { formatFilePath, formattedTime } from '~/pipelines/stores/test_reports/utils';
describe('Test reports utils', () => {
describe('formatFilePath', () => {
it.each`
file | expected
${'./test.js'} | ${'test.js'}
${'/test.js'} | ${'test.js'}
${'.//////////////test.js'} | ${'test.js'}
${'test.js'} | ${'test.js'}
${'mock/path./test.js'} | ${'mock/path./test.js'}
${'./mock/path./test.js'} | ${'mock/path./test.js'}
`('should format $file to be $expected', ({ file, expected }) => {
expect(formatFilePath(file)).toBe(expected);
});
});
describe('formattedTime', () => {
describe('when time is smaller than a second', () => {
it('should return time in milliseconds fixed to 2 decimals', () => {
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton, GlFriendlyWrap, GlPagination } from '@gitlab/ui';
import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui';
import { getJSONFixture } from 'helpers/fixtures';
import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue';
import * as getters from '~/pipelines/stores/test_reports/getters';
import { formatFilePath } from '~/pipelines/stores/test_reports/utils';
import { TestStatus } from '~/pipelines/constants';
import skippedTestCases from './mock_data';
......@@ -20,15 +21,18 @@ describe('Test reports suite table', () => {
testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases];
const testCases = testSuite.test_cases;
const blobPath = '/test/blob/path';
const noCasesMessage = () => wrapper.find('.js-no-test-cases');
const allCaseRows = () => wrapper.findAll('.js-case-row');
const findCaseRowAtIndex = (index) => wrapper.findAll('.js-case-row').at(index);
const findLinkForRow = (row) => row.find(GlLink);
const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`);
const createComponent = (suite = testSuite, perPage = 20) => {
store = new Vuex.Store({
state: {
blobPath,
testReports: {
test_suites: [suite],
},
......@@ -82,9 +86,13 @@ describe('Test reports suite table', () => {
it('renders the file name for the test with a copy button', () => {
const { file } = testCases[0];
const relativeFile = formatFilePath(file);
const filePath = `${blobPath}/${relativeFile}`;
const row = findCaseRowAtIndex(0);
const fileLink = findLinkForRow(row);
const button = row.find(GlButton);
expect(fileLink.attributes('href')).toBe(filePath);
expect(row.text()).toContain(file);
expect(button.exists()).toBe(true);
expect(button.attributes('data-clipboard-text')).toBe(file);
......
......@@ -2,6 +2,7 @@ import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SearchSettings from '~/search_settings/components/search_settings.vue';
import { HIGHLIGHT_CLASS, HIDE_CLASS } from '~/search_settings/constants';
import { isExpanded, expandSection, closeSection } from '~/settings_panels';
describe('search_settings/components/search_settings.vue', () => {
const ROOT_ID = 'content-body';
......@@ -9,6 +10,8 @@ describe('search_settings/components/search_settings.vue', () => {
const SEARCH_TERM = 'Delete project';
const GENERAL_SETTINGS_ID = 'js-general-settings';
const ADVANCED_SETTINGS_ID = 'js-advanced-settings';
const EXTRA_SETTINGS_ID = 'js-extra-settings';
let wrapper;
const buildWrapper = () => {
......@@ -16,10 +19,15 @@ describe('search_settings/components/search_settings.vue', () => {
propsData: {
searchRoot: document.querySelector(`#${ROOT_ID}`),
sectionSelector: SECTION_SELECTOR,
isExpandedFn: isExpanded,
},
// Add real listeners so we can simplify and strengthen some tests.
listeners: {
expand: expandSection,
collapse: closeSection,
},
});
};
const sections = () => Array.from(document.querySelectorAll(SECTION_SELECTOR));
const sectionsCount = () => sections().length;
const visibleSectionsCount = () =>
......@@ -39,7 +47,10 @@ describe('search_settings/components/search_settings.vue', () => {
<section id="${GENERAL_SETTINGS_ID}" class="settings">
<span>General</span>
</section>
<section id="${ADVANCED_SETTINGS_ID}" class="settings">
<section id="${ADVANCED_SETTINGS_ID}" class="settings expanded">
<span>Advanced</span>
</section>
<section id="${EXTRA_SETTINGS_ID}" class="settings">
<span>${SEARCH_TERM}</span>
</section>
</div>
......@@ -52,17 +63,6 @@ describe('search_settings/components/search_settings.vue', () => {
wrapper.destroy();
});
it('expands first section and collapses the rest', () => {
clearSearch();
const [firstSection, ...otherSections] = sections();
expect(wrapper.emitted()).toEqual({
expand: [[firstSection]],
collapse: otherSections.map((x) => [x]),
});
});
it('hides sections that do not match the search term', () => {
const hiddenSection = document.querySelector(`#${GENERAL_SETTINGS_ID}`);
search(SEARCH_TERM);
......@@ -72,12 +72,11 @@ describe('search_settings/components/search_settings.vue', () => {
});
it('expands section that matches the search term', () => {
const section = document.querySelector(`#${ADVANCED_SETTINGS_ID}`);
const section = document.querySelector(`#${EXTRA_SETTINGS_ID}`);
search(SEARCH_TERM);
// Last called because expand is always called once to reset the page state
expect(wrapper.emitted().expand[1][0]).toBe(section);
expect(wrapper.emitted('expand')).toEqual([[section]]);
});
it('highlight elements that match the search term', () => {
......@@ -86,21 +85,64 @@ describe('search_settings/components/search_settings.vue', () => {
expect(highlightedElementsCount()).toBe(1);
});
describe('when search term is cleared', () => {
beforeEach(() => {
search(SEARCH_TERM);
});
it('displays all sections', () => {
expect(visibleSectionsCount()).toBe(1);
clearSearch();
expect(visibleSectionsCount()).toBe(sectionsCount());
describe('default', () => {
it('test setup starts with expansion state', () => {
expect(sections().map(isExpanded)).toEqual([false, true, false]);
});
it('removes the highlight from all elements', () => {
expect(highlightedElementsCount()).toBe(1);
clearSearch();
expect(highlightedElementsCount()).toBe(0);
describe('when searched and cleared', () => {
beforeEach(() => {
search('Test');
clearSearch();
});
it('displays all sections', () => {
expect(visibleSectionsCount()).toBe(sectionsCount());
});
it('removes the highlight from all elements', () => {
expect(highlightedElementsCount()).toBe(0);
});
it('should preserve original expansion state', () => {
expect(sections().map(isExpanded)).toEqual([false, true, false]);
});
it('should preserve state by emitting events', () => {
const [first, mid, last] = sections();
expect(wrapper.emitted()).toEqual({
expand: [[mid]],
collapse: [[first], [last]],
});
});
describe('after multiple searches and clear', () => {
beforeEach(() => {
search('Test');
search(SEARCH_TERM);
clearSearch();
});
it('should preserve last expansion state', () => {
expect(sections().map(isExpanded)).toEqual([false, true, false]);
});
});
describe('after user expands and collapses, search, and clear', () => {
beforeEach(() => {
const [first, mid] = sections();
closeSection(mid);
expandSection(first);
search(SEARCH_TERM);
clearSearch();
});
it('should preserve last expansion state', () => {
expect(sections().map(isExpanded)).toEqual([true, false, false]);
});
});
});
});
});
import $ from 'jquery';
import { setHTMLFixture } from 'helpers/fixtures';
import mount from '~/search_settings/mount';
import { expandSection, closeSection } from '~/settings_panels';
......@@ -24,13 +23,13 @@ describe('search_settings/mount', () => {
const section = { name: 'section' };
app.$refs.searchSettings.$emit('expand', section);
expect(expandSection).toHaveBeenCalledWith($(section));
expect(expandSection).toHaveBeenCalledWith(section);
});
it('calls settings_panel.closeSection when collapse event is emitted', () => {
const section = { name: 'section' };
app.$refs.searchSettings.$emit('collapse', section);
expect(closeSection).toHaveBeenCalledWith($(section));
expect(closeSection).toHaveBeenCalledWith(section);
});
});
import $ from 'jquery';
import initSettingsPanels from '~/settings_panels';
import initSettingsPanels, { isExpanded } from '~/settings_panels';
describe('Settings Panels', () => {
preloadFixtures('groups/edit.html');
......@@ -20,11 +20,11 @@ describe('Settings Panels', () => {
// Our test environment automatically expands everything so we need to clear that out first
panel.classList.remove('expanded');
expect(panel.classList.contains('expanded')).toBe(false);
expect(isExpanded(panel)).toBe(false);
initSettingsPanels();
expect(panel.classList.contains('expanded')).toBe(true);
expect(isExpanded(panel)).toBe(true);
});
});
......@@ -35,11 +35,11 @@ describe('Settings Panels', () => {
initSettingsPanels();
expect(panel.classList.contains('expanded')).toBe(true);
expect(isExpanded(panel)).toBe(true);
$(trigger).click();
expect(panel.classList.contains('expanded')).toBe(false);
expect(isExpanded(panel)).toBe(false);
expect(trigger.textContent).toEqual(originalText);
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Database::Migrations::Instrumentation do
describe '#observe' do
subject { described_class.new }
let(:migration) { 1234 }
it 'executes the given block' do
expect { |b| subject.observe(migration, &b) }.to yield_control
end
context 'on successful execution' do
subject { described_class.new.observe(migration) {} }
it 'records walltime' do
expect(subject.walltime).not_to be_nil
end
it 'records success' do
expect(subject.success).to be_truthy
end
it 'records the migration version' do
expect(subject.migration).to eq(migration)
end
end
context 'upon failure' do
subject { described_class.new.observe(migration) { raise 'something went wrong' } }
it 'raises the exception' do
expect { subject }.to raise_error(/something went wrong/)
end
context 'retrieving observations' do
subject { instance.observations.first }
before do
instance.observe(migration) { raise 'something went wrong' }
rescue
# ignore
end
let(:instance) { described_class.new }
it 'records walltime' do
expect(subject.walltime).not_to be_nil
end
it 'records failure' do
expect(subject.success).to be_falsey
end
it 'records the migration version' do
expect(subject.migration).to eq(migration)
end
end
end
context 'sequence of migrations with failures' do
subject { described_class.new }
let(:migration1) { double('migration1', call: nil) }
let(:migration2) { double('migration2', call: nil) }
it 'records observations for all migrations' do
subject.observe('migration1') {}
subject.observe('migration2') { raise 'something went wrong' } rescue nil
expect(subject.observations.size).to eq(2)
end
end
end
end
......@@ -297,6 +297,57 @@ RSpec.describe 'gitlab:db namespace rake task' do
end
end
describe '#migrate_with_instrumentation' do
subject { run_rake_task('gitlab:db:migration_testing', "[#{filename}]") }
let(:ctx) { double('ctx', migrations: all_migrations, schema_migration: double, get_all_versions: existing_versions) }
let(:instrumentation) { instance_double(Gitlab::Database::Migrations::Instrumentation, observations: observations) }
let(:existing_versions) { [1] }
let(:all_migrations) { [double('migration1', version: 1), pending_migration] }
let(:pending_migration) { double('migration2', version: 2) }
let(:filename) { 'results-file.json'}
let(:buffer) { StringIO.new }
let(:observations) { %w[some data] }
before do
allow(ActiveRecord::Base.connection).to receive(:migration_context).and_return(ctx)
allow(Gitlab::Database::Migrations::Instrumentation).to receive(:new).and_return(instrumentation)
allow(ActiveRecord::Migrator).to receive_message_chain('new.run').with(any_args).with(no_args)
allow(instrumentation).to receive(:observe).and_yield
allow(File).to receive(:open).with(filename, 'wb+').and_yield(buffer)
end
it 'fails when given no filename argument' do
expect { run_rake_task('gitlab:db:migration_testing') }.to raise_error(/specify result_file/)
end
it 'fails when the given file already exists' do
expect(File).to receive(:exist?).with(filename).and_return(true)
expect { subject }.to raise_error(/File exists/)
end
it 'instruments the pending migration' do
expect(instrumentation).to receive(:observe).with(2).and_yield
subject
end
it 'executes the pending migration' do
expect(ActiveRecord::Migrator).to receive_message_chain('new.run').with(:up, ctx.migrations, ctx.schema_migration, pending_migration.version).with(no_args)
subject
end
it 'writes observations out to JSON file' do
subject
expect(buffer.string).to eq(observations.to_json)
end
end
def run_rake_task(task_name, arguments = '')
Rake::Task[task_name].reenable
Rake.application.invoke_task("#{task_name}#{arguments}")
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment