From 0a0e82d1440b06650e5fc524168b1f50a8feec68 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 27 Feb 2020 00:09:19 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- .gitlab/issue_templates/Security Release.md | 24 +- app/assets/javascripts/boards/models/issue.js | 31 +- .../javascripts/boards/stores/boards_store.js | 36 + .../monitoring/components/dashboard.vue | 2 +- .../javascripts/monitoring/components/embed.vue | 4 +- .../javascripts/monitoring/stores/actions.js | 18 +- .../javascripts/monitoring/stores/getters.js | 4 +- .../javascripts/monitoring/stores/mutations.js | 37 +- app/assets/javascripts/monitoring/stores/state.js | 2 +- app/assets/javascripts/monitoring/stores/utils.js | 90 +- app/assets/javascripts/monitoring/utils.js | 2 +- .../groups/registry/repositories_controller.rb | 2 +- .../projects/registry/repositories_controller.rb | 2 +- .../projects/registry/repositories/index.html.haml | 2 +- .../207468-note-confidential-attribute.yml | 5 + ...ve-refreshData-function-logic-from-issue-js.yml | 5 + .../20200222055543_add_confidential_to_note.rb | 12 + db/schema.rb | 1 + doc/development/serializing_data.md | 2 +- doc/development/shell_commands.md | 2 +- doc/user/project/integrations/prometheus.md | 2 +- doc/user/project/issue_board.md | 6 +- doc/user/project/issues/index.md | 2 +- doc/user/project/merge_requests/index.md | 2 +- doc/user/project/milestones/burndown_charts.md | 2 +- .../ssl_tls_concepts.md | 2 +- .../getting_started/new_or_existing_website.md | 2 +- doc/user/project/pipelines/job_artifacts.md | 2 +- doc/user/project/pipelines/schedules.md | 2 +- doc/user/project/releases/index.md | 2 +- doc/user/project/repository/index.md | 2 +- .../project/repository/jupyter_notebooks/index.md | 2 +- .../repository/x509_signed_commits/index.md | 2 +- doc/user/project/settings/index.md | 2 +- doc/user/project/web_ide/index.md | 4 +- doc/user/project/wiki/index.md | 4 +- doc/user/search/index.md | 2 +- doc/user/shortcuts.md | 2 +- .../registry/repositories_controller_spec.rb | 2 +- .../registry/repositories_controller_spec.rb | 4 +- spec/features/projects/container_registry_spec.rb | 2 +- spec/fixtures/api/schemas/environment.json | 1 - .../frontend/helpers/dom_shims/get_client_rects.js | 14 +- spec/frontend/helpers/dom_shims/index.js | 2 + spec/frontend/helpers/dom_shims/scroll_by.js | 7 + spec/frontend/helpers/dom_shims/size_properties.js | 19 + spec/frontend/lib/utils/common_utils_spec.js | 812 +++++++++++++++++ spec/frontend/lib/utils/mock_data.js | 8 + .../components/charts/time_series_spec.js | 53 +- .../monitoring/components/dashboard_spec.js | 7 +- spec/frontend/monitoring/embed/embed_spec.js | 4 +- spec/frontend/monitoring/mock_data.js | 170 ++-- spec/frontend/monitoring/store/actions_spec.js | 48 +- spec/frontend/monitoring/store/getters_spec.js | 36 +- spec/frontend/monitoring/store/mutations_spec.js | 39 +- spec/frontend/monitoring/store/utils_spec.js | 174 +++- spec/javascripts/lib/utils/browser_spec.js | 175 ++++ spec/javascripts/lib/utils/common_utils_spec.js | 981 --------------------- spec/javascripts/lib/utils/mock_data.js | 9 +- .../monitoring/components/dashboard_resize_spec.js | 2 +- .../gitlab/import_export/safe_model_attributes.yml | 1 + 61 files changed, 1546 insertions(+), 1351 deletions(-) create mode 100644 changelogs/unreleased/207468-note-confidential-attribute.yml create mode 100644 changelogs/unreleased/Remove-refreshData-function-logic-from-issue-js.yml create mode 100644 db/migrate/20200222055543_add_confidential_to_note.rb create mode 100644 spec/frontend/helpers/dom_shims/scroll_by.js create mode 100644 spec/frontend/helpers/dom_shims/size_properties.js create mode 100644 spec/frontend/lib/utils/common_utils_spec.js create mode 100644 spec/frontend/lib/utils/mock_data.js create mode 100644 spec/javascripts/lib/utils/browser_spec.js delete mode 100644 spec/javascripts/lib/utils/common_utils_spec.js diff --git a/.gitlab/issue_templates/Security Release.md b/.gitlab/issue_templates/Security Release.md index e5b26bc6fc6..a3689bff450 100644 --- a/.gitlab/issue_templates/Security Release.md +++ b/.gitlab/issue_templates/Security Release.md @@ -12,23 +12,24 @@ Set the title to: `Security Release: 12.2.X, 12.1.X, and 12.0.X` ## Version issues: -* 12.2.X: {release task link} -* 12.1.X: {release task link} -* 12.0.X: {release task link} +12.2.X, 12.1.X, 12.0.X: {release task link} ## Issues in GitLab Security -* {https://gitlab.com/gitlab-org/security/gitlab/issues/ link} +To include your issue and merge requests in this Security Release, please mark +your security issues as related to this release tracking issue. You can do this +in the "Linked issues" section below this issue description. -| Version | MR | -|---------|----| -| 12.2 | {https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests link} | -| 12.1 | {https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests link} | -| 12.0 | {https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests link} | -| master | {https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests link} | +:warning: If your security issues are not marked as related to this release +tracking issue, their merge requests may not be included in the security +release. ## Issues in Omnibus-GitLab +Omnibus security fixes need to be added manually to this issue description +using and below the following template: + +```markdown * {https://gitlab.com/gitlab-org/security/gitlab/issues/ link} | Version | MR | @@ -37,6 +38,7 @@ Set the title to: `Security Release: 12.2.X, 12.1.X, and 12.0.X` | 12.1 | {https://dev.gitlab.org/gitlab/omnibus-gitlab/merge_requests/ link} | | 12.0 | {https://dev.gitlab.org/gitlab/omnibus-gitlab/merge_requests/ link} | | master | {https://dev.gitlab.org/gitlab/omnibus-gitlab/merge_requests/ link} | +``` ## QA {QA issue link} @@ -49,5 +51,5 @@ GitLab.com: {https://gitlab.com/gitlab-com/www-gitlab-com/merge_requests/ link} ## Email notification {https://gitlab.com/gitlab-com/marketing/general/issues/ link} -/label ~security +/label ~security ~"upcoming security release" /confidential diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 0e86359534b..4f5d583e61f 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -26,36 +26,7 @@ class ListIssue { } refreshData(obj, defaultAvatar) { - this.id = obj.id; - this.iid = obj.iid; - this.title = obj.title; - this.confidential = obj.confidential; - this.dueDate = obj.due_date; - this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; - this.referencePath = obj.reference_path; - this.path = obj.real_path; - this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; - this.project_id = obj.project_id; - this.timeEstimate = obj.time_estimate; - this.assignableLabelsEndpoint = obj.assignable_labels_endpoint; - this.blocked = obj.blocked; - - if (obj.project) { - this.project = new IssueProject(obj.project); - } - - if (obj.milestone) { - this.milestone = new ListMilestone(obj.milestone); - this.milestone_id = obj.milestone.id; - } - - if (obj.labels) { - this.labels = obj.labels.map(label => new ListLabel(label)); - } - - if (obj.assignees) { - this.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); - } + boardsStore.refreshIssueData(this, obj, defaultAvatar); } addLabel(label) { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index f233228614f..010eda9b6c5 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -12,6 +12,10 @@ import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import eventHub from '../eventhub'; import { ListType } from '../constants'; +import IssueProject from '../models/project'; +import ListLabel from '../models/label'; +import ListAssignee from '../models/assignee'; +import ListMilestone from '../models/milestone'; const boardsStore = { disabled: false, @@ -593,6 +597,38 @@ const boardsStore = { clearMultiSelect() { this.multiSelect.list = []; }, + refreshIssueData(issue, obj, defaultAvatar) { + issue.id = obj.id; + issue.iid = obj.iid; + issue.title = obj.title; + issue.confidential = obj.confidential; + issue.dueDate = obj.due_date; + issue.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; + issue.referencePath = obj.reference_path; + issue.path = obj.real_path; + issue.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; + issue.project_id = obj.project_id; + issue.timeEstimate = obj.time_estimate; + issue.assignableLabelsEndpoint = obj.assignable_labels_endpoint; + issue.blocked = obj.blocked; + + if (obj.project) { + issue.project = new IssueProject(obj.project); + } + + if (obj.milestone) { + issue.milestone = new ListMilestone(obj.milestone); + issue.milestone_id = obj.milestone.id; + } + + if (obj.labels) { + issue.labels = obj.labels.map(label => new ListLabel(label)); + } + + if (obj.assignees) { + issue.assignees = obj.assignees.map(a => new ListAssignee(a, defaultAvatar)); + } + }, }; BoardsStoreEE.initEESpecific(boardsStore); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 79f32b357fc..a6d10d37103 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -522,7 +522,7 @@ export default {
+ const groupWithMetrics = this.dashboard.panelGroups.find(group => group.panels.find(chart => this.chartHasData(chart)), ) || { panels: [] }; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index daa095d9b3b..aa6c35d97be 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -51,9 +51,11 @@ export const requestMetricsDashboard = ({ commit }) => { commit(types.REQUEST_METRICS_DATA); }; export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { - commit(types.SET_ALL_DASHBOARDS, response.all_dashboards); - commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard); - commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(response.metrics_data)); + const { all_dashboards, dashboard, metrics_data } = response; + + commit(types.SET_ALL_DASHBOARDS, all_dashboards); + commit(types.RECEIVE_METRICS_DATA_SUCCESS, dashboard); + commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data)); return dispatch('fetchPrometheusMetrics', params); }; @@ -149,16 +151,16 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { step, }; - commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metric_id }); + commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); - return fetchPrometheusResult(metric.prometheus_endpoint_path, queryParams) + return fetchPrometheusResult(metric.prometheusEndpointPath, queryParams) .then(result => { - commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metric_id, result }); + commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result }); }) .catch(error => { Sentry.captureException(error); - commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metric_id, error }); + commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metricId, error }); // Continue to throw error so the dashboard can notify using createFlash throw error; }); @@ -168,7 +170,7 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, par commit(types.REQUEST_METRICS_DATA); const promises = []; - state.dashboard.panel_groups.forEach(group => { + state.dashboard.panelGroups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { promises.push(dispatch('fetchPrometheusMetric', { metric, params })); diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 3801149e49d..1affc6f0a76 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -11,7 +11,7 @@ const metricsIdsInPanel = panel => * states in all the metric in the dashboard or group. */ export const getMetricStates = state => groupKey => { - let groups = state.dashboard.panel_groups; + let groups = state.dashboard.panelGroups; if (groupKey) { groups = groups.filter(group => group.key === groupKey); } @@ -43,7 +43,7 @@ export const getMetricStates = state => groupKey => { * filtered by group key. */ export const metricsWithData = state => groupKey => { - let groups = state.dashboard.panel_groups; + let groups = state.dashboard.panelGroups; if (groupKey) { groups = groups.filter(group => group.key === groupKey); } diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 8bd53a24b61..7aac98821c9 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,18 +1,11 @@ import Vue from 'vue'; import pick from 'lodash/pick'; -import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; -import { normalizeMetric, normalizeQueryResult } from './utils'; +import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; import { metricStates } from '../constants'; import httpStatusCodes from '~/lib/utils/http_status'; -const normalizePanelMetrics = (metrics, defaultLabel) => - metrics.map(metric => ({ - ...normalizeMetric(metric), - label: metric.label || defaultLabel, - })); - /** * Locate and return a metric in the dashboard by its id * as generated by `uniqMetricsId()`. @@ -21,10 +14,10 @@ const normalizePanelMetrics = (metrics, defaultLabel) => */ const findMetricInDashboard = (metricId, dashboard) => { let res = null; - dashboard.panel_groups.forEach(group => { + dashboard.panelGroups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { - if (metric.metric_id === metricId) { + if (metric.metricId === metricId) { res = metric; } }); @@ -86,27 +79,9 @@ export default { state.showEmptyState = true; }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, dashboard) { - state.dashboard = { - ...dashboard, - panel_groups: dashboard.panel_groups.map((group, i) => { - const key = `${slugify(group.group || 'default')}-${i}`; - let { panels = [] } = group; - - // each panel has metric information that needs to be normalized - panels = panels.map(panel => ({ - ...panel, - metrics: normalizePanelMetrics(panel.metrics, panel.y_label), - })); - - return { - ...group, - panels, - key, - }; - }), - }; + state.dashboard = mapToDashboardViewModel(dashboard); - if (!state.dashboard.panel_groups.length) { + if (!state.dashboard.panelGroups.length) { state.emptyState = 'noData'; } }, @@ -206,7 +181,7 @@ export default { state.showErrorBanner = enabled; }, [types.SET_PANEL_GROUP_METRICS](state, payload) { - const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); + const panelGroup = state.dashboard.panelGroups.find(pg => payload.key === pg.key); panelGroup.panels = payload.panels; }, [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) { diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index a2050f8e893..2b1907e8df7 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -15,7 +15,7 @@ export default () => ({ showEmptyState: true, showErrorBanner: true, dashboard: { - panel_groups: [], + panelGroups: [], }, allDashboards: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index cd586c6af3e..82deaa7ccfd 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -1,4 +1,4 @@ -import { omit } from 'lodash'; +import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -9,6 +9,13 @@ export const gqClient = createGqClient( }, ); +/** + * Metrics loaded from project-defined dashboards do not have a metric_id. + * This method creates a unique ID combining metric_id and id, if either is present. + * This is hopefully a temporary solution until BE processes metrics before passing to fE + * @param {Object} metric - metric + * @returns {Object} - normalized metric with a uniqueID + */ export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`; /** @@ -41,22 +48,75 @@ export const parseEnvironmentsResponse = (response = [], projectPath) => }); /** - * Metrics loaded from project-defined dashboards do not have a metric_id. - * This method creates a unique ID combining metric_id and id, if either is present. - * This is hopefully a temporary solution until BE processes metrics before passing to fE - * @param {Object} metric - metric - * @returns {Object} - normalized metric with a uniqueID + * Maps metrics to its view model + * + * This function difers from other in that is maps all + * non-define properties as-is to the object. This is not + * advisable as it could lead to unexpected side-effects. + * + * Related issue: + * https://gitlab.com/gitlab-org/gitlab/issues/207198 + * + * @param {Array} metrics - Array of prometheus metrics + * @param {String} defaultLabel - Default label for metrics + * @returns {Object} */ +const mapToMetricsViewModel = (metrics, defaultLabel) => + metrics.map(({ label, id, metric_id, query_range, prometheus_endpoint_path, ...metric }) => ({ + label: label || defaultLabel, + queryRange: query_range, + prometheusEndpointPath: prometheus_endpoint_path, + metricId: uniqMetricsId({ metric_id, id }), -export const normalizeMetric = (metric = {}) => - omit( - { - ...metric, - metric_id: uniqMetricsId(metric), - metricId: uniqMetricsId(metric), - }, - 'id', - ); + // `metric_id` is used by embed.vue, keeping this duplicated. + // https://gitlab.com/gitlab-org/gitlab/issues/37492 + metric_id: uniqMetricsId({ metric_id, id }), + ...metric, + })); + +/** + * Maps a metrics panel to its view model + * + * @param {Object} panel - Metrics panel + * @returns {Object} + */ +const mapToPanelViewModel = ({ title = '', type, y_label, metrics = [] }) => { + return { + title, + type, + y_label, + metrics: mapToMetricsViewModel(metrics, y_label), + }; +}; + +/** + * Maps a metrics panel group to its view model + * + * @param {Object} panelGroup - Panel Group + * @returns {Object} + */ +const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => { + return { + key: `${slugify(group || 'default')}-${i}`, + group, + panels: panels.map(mapToPanelViewModel), + }; +}; + +/** + * Maps a dashboard json object to its view model + * + * @param {Object} dashboard - Dashboard object + * @param {String} dashboard.dashboard - Dashboard name object + * @param {Array} dashboard.panel_groups - Panel groups array + * @returns {Object} + */ +export const mapToDashboardViewModel = ({ dashboard = '', panel_groups = [] }) => { + return { + dashboard, + panelGroups: panel_groups.map(mapToPanelGroupViewModel), + }; +}; export const normalizeQueryResult = timeSeries => { let normalizedResult = {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index b2fa44835e6..6694ae2f157 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -7,7 +7,7 @@ import { /** * This method is used to validate if the graph data format for a chart component - * that needs a time series as a response from a prometheus query (query_range) is + * that needs a time series as a response from a prometheus query (queryRange) is * of a valid format or not. * @param {Object} graphData the graph data response from a prometheus request * @returns {boolean} whether the graphData format is correct diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index 84c25cfb180..ac4ca197d72 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -17,7 +17,7 @@ module Groups serializer = ContainerRepositoriesSerializer .new(current_user: current_user) - if Feature.enabled?(:vue_container_registry_explorer) + if Feature.enabled?(:vue_container_registry_explorer, group) render json: serializer.with_pagination(request, response) .represent_read_only(@images) else diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index e524d1c29a2..d6d993f427d 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -17,7 +17,7 @@ module Projects serializer = ContainerRepositoriesSerializer .new(project: project, current_user: current_user) - if Feature.enabled?(:vue_container_registry_explorer) + if Feature.enabled?(:vue_container_registry_explorer, project.group) render json: serializer.with_pagination(request, response).represent(@images) else render json: serializer.represent(@images) diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index c668c9e8494..247cf021cc7 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -4,7 +4,7 @@ %section .row.registry-placeholder.prepend-bottom-10 .col-12 - - if Feature.enabled?(:vue_container_registry_explorer, @project) + - if Feature.enabled?(:vue_container_registry_explorer, @project.group) #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), project_path: @project.full_path, "help_page_path" => help_page_path('user/packages/container_registry/index'), diff --git a/changelogs/unreleased/207468-note-confidential-attribute.yml b/changelogs/unreleased/207468-note-confidential-attribute.yml new file mode 100644 index 00000000000..f3161aeee74 --- /dev/null +++ b/changelogs/unreleased/207468-note-confidential-attribute.yml @@ -0,0 +1,5 @@ +--- +title: Add confidential attribute to notes table +merge_request: +author: +type: other diff --git a/changelogs/unreleased/Remove-refreshData-function-logic-from-issue-js.yml b/changelogs/unreleased/Remove-refreshData-function-logic-from-issue-js.yml new file mode 100644 index 00000000000..404a231ca00 --- /dev/null +++ b/changelogs/unreleased/Remove-refreshData-function-logic-from-issue-js.yml @@ -0,0 +1,5 @@ +--- +title: Moves refreshData from issue model to board store +merge_request: 21409 +author: nuwe1 +type: other diff --git a/db/migrate/20200222055543_add_confidential_to_note.rb b/db/migrate/20200222055543_add_confidential_to_note.rb new file mode 100644 index 00000000000..03173f63a03 --- /dev/null +++ b/db/migrate/20200222055543_add_confidential_to_note.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class AddConfidentialToNote < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + with_lock_retries do + add_column :notes, :confidential, :boolean + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ac647a9eadf..f49b1af4ea9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2806,6 +2806,7 @@ ActiveRecord::Schema.define(version: 2020_02_24_163804) do t.text "change_position" t.boolean "resolved_by_push" t.bigint "review_id" + t.boolean "confidential" t.index ["author_id"], name: "index_notes_on_author_id" t.index ["commit_id"], name: "index_notes_on_commit_id" t.index ["created_at"], name: "index_notes_on_created_at" diff --git a/doc/development/serializing_data.md b/doc/development/serializing_data.md index 37332c20147..af791f5a4e0 100644 --- a/doc/development/serializing_data.md +++ b/doc/development/serializing_data.md @@ -20,7 +20,7 @@ alternative. ## Serialized Data Is Less Powerful When using a relational database you have the ability to query individual -fields, change the schema, index data and so forth. When you use serialized data +fields, change the schema, index data, and so forth. When you use serialized data all of that becomes either very difficult or downright impossible. While PostgreSQL does offer the ability to query JSON fields it is mostly meant for very specialized use cases, and not for more general use. If you use YAML in diff --git a/doc/development/shell_commands.md b/doc/development/shell_commands.md index b8952cae33e..37d851f8b7e 100644 --- a/doc/development/shell_commands.md +++ b/doc/development/shell_commands.md @@ -215,4 +215,4 @@ When importing, GitLab would execute the following command, passing the `import_ git clone file://git:/tmp/lol ``` -Git would simply ignore the `git:` part, interpret the path as `file:///tmp/lol` and import the repository into the new project, in turn potentially giving the attacker access to any repository in the system, whether private or not. +Git would simply ignore the `git:` part, interpret the path as `file:///tmp/lol`, and import the repository into the new project. This action could potentially give the attacker access to any repository in the system, whether private or not. diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index 2f6c77c8217..624ab0616c4 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -671,7 +671,7 @@ The sharing dialog within Grafana provides the link, as highlighted below. ![Grafana Direct Linked Rendered Image](img/grafana_live_embed.png) NOTE: **Note:** -For this embed to display correctly, the Grafana instance must be available to the target user, either as a public dashboard or on the same network. +For this embed to display correctly, the Grafana instance must be available to the target user, either as a public dashboard, or on the same network. Copy the link and add an image tag as [inline HTML](../../markdown.md#inline-html) in your Markdown. You may tweak the query parameters as required. For instance, removing the `&from=` and `&to=` parameters will give you a live chart. Here is example markup for a live chart from GitLab's public dashboard: diff --git a/doc/user/project/issue_board.md b/doc/user/project/issue_board.md index 0a5d7805e41..dd51d303294 100644 --- a/doc/user/project/issue_board.md +++ b/doc/user/project/issue_board.md @@ -147,7 +147,7 @@ Create lists for each of your team members and quickly drag-and-drop issues onto ## Permissions [Reporters and up](../permissions.md) can use all the functionality of the -Issue Board, that is, create or delete lists and drag issues from one list to another. +Issue Board to create or delete lists, and drag issues from one list to another. ## GitLab Enterprise features for Issue Boards @@ -373,7 +373,7 @@ window where you can see all the issues that do not belong to any list. Select one or more issues by clicking on the cards and then click **Add issues** to add them to the selected list. You can limit the issues you want to add to -the list by filtering by author, assignee, milestone and label. +the list by filtering by author, assignee, milestone, and label. ![Bulk adding issues to lists](img/issue_boards_add_issues_modal.png) @@ -419,7 +419,7 @@ You should be able to use the filters on top of your Issue Board to show only the results you want. This is similar to the filtering used in the issue tracker since the metadata from the issues and labels are re-used in the Issue Board. -You can filter by author, assignee, milestone and label. +You can filter by author, assignee, milestone, and label. ### Creating workflows diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index f28f20cf8b2..615af1d8b84 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -6,7 +6,7 @@ Issues are the fundamental medium for collaborating on ideas and planning work i The GitLab issue tracker is an advanced tool for collaboratively developing ideas, solving problems, and planning work. -Issues can allow you, your team, and your collaborators to share and discuss proposals +Issues can allow you and your team to share and discuss proposals before, and during, their implementation. However, they can be used for a variety of other purposes, customized to your needs and workflow. diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 2c613a29b0d..edf34c666f8 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -48,7 +48,7 @@ about CI/CD pipelines, when present), followed by the discussion threads of the collaborating with that MR. MRs also contain navigation tabs from which you can see the discussion happening on the thread, -the list of commits, the list of pipelines and jobs, the code changes and inline code reviews. +the list of commits, the list of pipelines and jobs, the code changes, and inline code reviews. To get started, read the [introduction to merge requests](getting_started.md). diff --git a/doc/user/project/milestones/burndown_charts.md b/doc/user/project/milestones/burndown_charts.md index 75501ab3c85..e9f23899068 100644 --- a/doc/user/project/milestones/burndown_charts.md +++ b/doc/user/project/milestones/burndown_charts.md @@ -28,7 +28,7 @@ For an overview, check the video demonstration on [Mapping work versus time with ## Use cases -Burndown Charts, in general, are used for tracking and analyzing the completion of +Burndown Charts are generally used for tracking and analyzing the completion of a milestone. Therefore, their use cases are tied to the [use you are assigning your milestone to](index.md). diff --git a/doc/user/project/pages/custom_domains_ssl_tls_certification/ssl_tls_concepts.md b/doc/user/project/pages/custom_domains_ssl_tls_certification/ssl_tls_concepts.md index cf2f0cbd12c..bc51a9c90d2 100644 --- a/doc/user/project/pages/custom_domains_ssl_tls_certification/ssl_tls_concepts.md +++ b/doc/user/project/pages/custom_domains_ssl_tls_certification/ssl_tls_concepts.md @@ -27,7 +27,7 @@ they are static, hence we are not dealing with server-side scripts nor credit card transactions, then why do we need secure connections? Back in the 1990s, where HTTPS came out, [SSL](https://en.wikipedia.org/wiki/Transport_Layer_Security#SSL_1.0.2C_2.0_and_3.0) was considered a "special" -security measure, necessary just for big companies, like banks and shoppings sites +security measure, necessary just for big companies like banks and shopping sites with financial transactions. Now we have a different picture. [According to Josh Aas](https://letsencrypt.org/2015/10/29/phishing-and-malware.html), Executive Director at [ISRG](https://en.wikipedia.org/wiki/Internet_Security_Research_Group): diff --git a/doc/user/project/pages/getting_started/new_or_existing_website.md b/doc/user/project/pages/getting_started/new_or_existing_website.md index 49a330ea202..027a238bf02 100644 --- a/doc/user/project/pages/getting_started/new_or_existing_website.md +++ b/doc/user/project/pages/getting_started/new_or_existing_website.md @@ -16,7 +16,7 @@ To do so, follow the steps below. click **New project**, and name it according to the [Pages domain names](../getting_started_part_one.md#gitlab-pages-default-domain-names). 1. Clone it to your local computer, add your website - files to your project, add, commit and push to GitLab. + files to your project, add, commit, and push to GitLab. Alternatively, you can run `git init` in your local directory, add the remote URL: `git remote add origin git@gitlab.com:namespace/project-name.git`, diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md index 5b611fdd825..cc6450ff907 100644 --- a/doc/user/project/pipelines/job_artifacts.md +++ b/doc/user/project/pipelines/job_artifacts.md @@ -50,7 +50,7 @@ For more examples on artifacts, follow the [artifacts reference in ## Browsing artifacts -> - From GitLab 9.2, PDFs, images, videos and other formats can be previewed directly in the job artifacts browser without the need to download them. +> - From GitLab 9.2, PDFs, images, videos, and other formats can be previewed directly in the job artifacts browser without the need to download them. > - Introduced in [GitLab 10.1][ce-14399], HTML files in a public project can be previewed directly in a new tab without the need to download them when [GitLab Pages](../../../administration/pages/index.md) is enabled. The same applies for textual formats (currently supported extensions: `.txt`, `.json`, and `.log`). > - Introduced in [GitLab 12.4][gitlab-16675], artifacts in private projects can be previewed when [GitLab Pages access control](../../../administration/pages/index.md#access-control) is enabled. diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md index 08928431881..0a86247042e 100644 --- a/doc/user/project/pipelines/schedules.md +++ b/doc/user/project/pipelines/schedules.md @@ -119,7 +119,7 @@ The next time a pipeline is scheduled, your credentials will be used. NOTE: **Note:** If the owner of a pipeline schedule doesn't have the ability to create pipelines on the target branch, the schedule will stop creating new pipelines. This can -happen if, for example, the owner is blocked or removed from the project, or +happen if the owner is blocked or removed from the project, or the target branch or tag is protected. In this case, someone with sufficient privileges must take ownership of the schedule. diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md index 04667e2adfe..8294bb4f6e2 100644 --- a/doc/user/project/releases/index.md +++ b/doc/user/project/releases/index.md @@ -13,7 +13,7 @@ assets output by your CI system to use them, not just the raw source code. GitLab's **Releases** are a way to track deliverables in your project. Consider them -a snapshot in time of the source, build output, and other metadata or artifacts +a snapshot in time of the source, build output, artifacts, and other metadata associated with a released version of your code. There are several ways to create a Release: diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index a081a8f5ae4..6b0c58e60bd 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -104,7 +104,7 @@ Some things to note about precedence: [Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for interactive computing in many fields and contain a complete record of the -user's sessions and include code, narrative text, equations and rich output. +user's sessions and include code, narrative text, equations, and rich output. [Read how to use Jupyter notebooks with GitLab.](jupyter_notebooks/index.md) diff --git a/doc/user/project/repository/jupyter_notebooks/index.md b/doc/user/project/repository/jupyter_notebooks/index.md index 6b93ee05a9b..ca82be280d9 100644 --- a/doc/user/project/repository/jupyter_notebooks/index.md +++ b/doc/user/project/repository/jupyter_notebooks/index.md @@ -4,7 +4,7 @@ [Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for interactive computing in many fields and contain a complete record of the -user's sessions and include code, narrative text, equations and rich output. +user's sessions and include code, narrative text, equations, and rich output. When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be rendered to HTML when viewed. diff --git a/doc/user/project/repository/x509_signed_commits/index.md b/doc/user/project/repository/x509_signed_commits/index.md index 421c2f60350..19238839a5e 100644 --- a/doc/user/project/repository/x509_signed_commits/index.md +++ b/doc/user/project/repository/x509_signed_commits/index.md @@ -7,7 +7,7 @@ type: concepts, howto [x509](https://en.wikipedia.org/wiki/X.509) is a standard format for public key certificates issued by a public or private Public Key Infrastructure (PKI). Personal x509 certificates are used for authentication or signing purposes -such as SMIME, but beside that, Git supports signing of commits and tags +such as SMIME, but Git also supports signing of commits and tags with x509 certificates in a similar way as with [GPG](../gpg_signed_commits/index.md). The main difference is the trust anchor which is the PKI for x509 certificates instead of a web of trust with GPG. diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index 70a1ec64d8e..12cc2fe665d 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -115,7 +115,7 @@ no longer actively maintained. Projects that have been archived can also be unarchived. Only project Owners and Admin users have the [permissions](../../permissions.md#project-members-permissions) to archive a project. -When a project is archived, the repository, issues, merge requests and all +When a project is archived, the repository, issues, merge requests, and all other features are read-only. Archived projects are also hidden in project listings. diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index 25bc7edc2bc..ba4eaf9f9b1 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -30,11 +30,11 @@ the Web IDE will make your direct editing even easier. The Web IDE currently provides: - Basic syntax colorization for a variety of programming, scripting and markup - languages such as XML, PHP, C#, C++, Markdown, Java, VB, Batch, Python, Ruby + languages such as XML, PHP, C#, C++, Markdown, Java, VB, Batch, Python, Ruby, and Objective-C. - IntelliSense and validation support (displaying errors and warnings, providing smart completions, formatting, and outlining) for some languages. For example: -TypeScript, JavaScript, CSS, LESS, SCSS, JSON and HTML. + TypeScript, JavaScript, CSS, LESS, SCSS, JSON, and HTML. Because the Web IDE is based on the [Monaco Editor](https://microsoft.github.io/monaco-editor/), you can find a more complete list of supported languages in the diff --git a/doc/user/project/wiki/index.md b/doc/user/project/wiki/index.md index 42c622e4786..9dc39e328a6 100644 --- a/doc/user/project/wiki/index.md +++ b/doc/user/project/wiki/index.md @@ -38,7 +38,7 @@ automatically. For example, a title of `docs/my-page` will create a wiki page with a path `/wikis/docs/my-page`. Once you enter the page name, it's time to fill in its content. GitLab wikis -support Markdown, RDoc, AsciiDoc and Org. For Markdown based pages, all the +support Markdown, RDoc, AsciiDoc, and Org. For Markdown based pages, all the [Markdown features](../../markdown.md) are supported and for links there is some [wiki specific](../../markdown.md#wiki-specific-markdown) behavior. @@ -121,7 +121,7 @@ The changes of a wiki page over time are recorded in the wiki's Git repository, and you can view them by clicking the **Page history** button. From the history page you can see the revision of the page (Git commit SHA), its -author, the commit message, when it was last updated and the page markup format. +author, the commit message, when it was last updated, and the page markup format. To see how a previous version of the page looked like, click on a revision number. diff --git a/doc/user/search/index.md b/doc/user/search/index.md index 46c9c974c34..70ab9af0bcc 100644 --- a/doc/user/search/index.md +++ b/doc/user/search/index.md @@ -119,7 +119,7 @@ Your [To-Do List](../todos.md#gitlab-to-do-list) can be searched by "to do" and You can [filter](../todos.md#filtering-your-to-do-list) them per project, author, type, and action. Also, you can sort them by [**Label priority**](../../user/project/labels.md#label-priority), -**Last created** and **Oldest created**. +**Last created**, and **Oldest created**. ## Projects diff --git a/doc/user/shortcuts.md b/doc/user/shortcuts.md index 1df1a8a8ba6..dcc4753a794 100644 --- a/doc/user/shortcuts.md +++ b/doc/user/shortcuts.md @@ -34,7 +34,7 @@ These shortcuts are available in most areas of GitLab | p + b | Show/hide the Performance Bar. | Additionally, the following shortcuts are available when editing text in text fields, -for example comments, replies, or issue and merge request descriptions: +for example comments, replies, issue descriptions, and merge request descriptions: | Keyboard Shortcut | Description | | ---------------------------------------------------------------------- | ----------- | diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb index eb702d65325..3a74aa1dac0 100644 --- a/spec/controllers/groups/registry/repositories_controller_spec.rb +++ b/spec/controllers/groups/registry/repositories_controller_spec.rb @@ -93,7 +93,7 @@ describe Groups::Registry::RepositoriesController do context 'with :vue_container_registry_explorer feature flag disabled' do before do - stub_feature_flags(vue_container_registry_explorer: false) + stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: group }) end it 'has the correct response schema' do diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb index 5b9c0211b39..a64673a7f87 100644 --- a/spec/controllers/projects/registry/repositories_controller_spec.rb +++ b/spec/controllers/projects/registry/repositories_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::Registry::RepositoriesController do - let(:user) { create(:user) } + let(:user) { create(:user) } let(:project) { create(:project, :private) } before do @@ -88,7 +88,7 @@ describe Projects::Registry::RepositoriesController do context 'with :vue_container_registry_explorer feature flag disabled' do before do - stub_feature_flags(vue_container_registry_explorer: false) + stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: project.group }) stub_container_registry_tags(repository: project.full_path, tags: %w[rc1 latest]) end diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index 02b2d03a880..b99dab39c34 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -19,7 +19,7 @@ describe 'Container Registry', :js do describe 'Registry explorer is off' do before do - stub_feature_flags(vue_container_registry_explorer: false) + stub_feature_flags(vue_container_registry_explorer: { enabled: false, thing: project.group }) end it 'has a page title set' do diff --git a/spec/fixtures/api/schemas/environment.json b/spec/fixtures/api/schemas/environment.json index 321c495a575..7e7e5ce37e3 100644 --- a/spec/fixtures/api/schemas/environment.json +++ b/spec/fixtures/api/schemas/environment.json @@ -26,7 +26,6 @@ "stop_path": { "type": "string" }, "cancel_auto_stop_path": { "type": "string" }, "folder_path": { "type": "string" }, - "project_path": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "auto_stop_at": { "type": "string", "format": "date-time" }, diff --git a/spec/frontend/helpers/dom_shims/get_client_rects.js b/spec/frontend/helpers/dom_shims/get_client_rects.js index d740c1bf154..7ba60dd7936 100644 --- a/spec/frontend/helpers/dom_shims/get_client_rects.js +++ b/spec/frontend/helpers/dom_shims/get_client_rects.js @@ -8,14 +8,16 @@ function hasHiddenStyle(node) { return false; } -function createDefaultClientRect() { +function createDefaultClientRect(node) { + const { outerWidth: width, outerHeight: height } = node; + return { - bottom: 0, - height: 0, + bottom: height, + height, left: 0, - right: 0, + right: width, top: 0, - width: 0, + width, x: 0, y: 0, }; @@ -46,5 +48,5 @@ window.Element.prototype.getClientRects = function getClientRects() { return []; } - return [createDefaultClientRect()]; + return [createDefaultClientRect(node)]; }; diff --git a/spec/frontend/helpers/dom_shims/index.js b/spec/frontend/helpers/dom_shims/index.js index 63850b62ff7..1b73f0e2ef5 100644 --- a/spec/frontend/helpers/dom_shims/index.js +++ b/spec/frontend/helpers/dom_shims/index.js @@ -2,3 +2,5 @@ import './element_scroll_into_view'; import './get_client_rects'; import './inner_text'; import './window_scroll_to'; +import './scroll_by'; +import './size_properties'; diff --git a/spec/frontend/helpers/dom_shims/scroll_by.js b/spec/frontend/helpers/dom_shims/scroll_by.js new file mode 100644 index 00000000000..90387e51765 --- /dev/null +++ b/spec/frontend/helpers/dom_shims/scroll_by.js @@ -0,0 +1,7 @@ +window.scrollX = 0; +window.scrollY = 0; + +window.scrollBy = (x, y) => { + window.scrollX += x; + window.scrollY += y; +}; diff --git a/spec/frontend/helpers/dom_shims/size_properties.js b/spec/frontend/helpers/dom_shims/size_properties.js new file mode 100644 index 00000000000..a2d5940bd1e --- /dev/null +++ b/spec/frontend/helpers/dom_shims/size_properties.js @@ -0,0 +1,19 @@ +const convertFromStyle = style => { + if (style.match(/[0-9](px|rem)/g)) { + return Number(style.replace(/[^0-9]/g, '')); + } + + return 0; +}; + +Object.defineProperty(global.HTMLElement.prototype, 'offsetWidth', { + get() { + return convertFromStyle(this.style.width || '0px'); + }, +}); + +Object.defineProperty(global.HTMLElement.prototype, 'offsetHeight', { + get() { + return convertFromStyle(this.style.height || '0px'); + }, +}); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js new file mode 100644 index 00000000000..d0d45b153af --- /dev/null +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -0,0 +1,812 @@ +import * as commonUtils from '~/lib/utils/common_utils'; + +describe('common_utils', () => { + describe('parseUrl', () => { + it('returns an anchor tag with url', () => { + expect(commonUtils.parseUrl('/some/absolute/url').pathname).toContain('some/absolute/url'); + }); + + it('url is escaped', () => { + // IE11 will return a relative pathname while other browsers will return a full pathname. + // parseUrl uses an anchor element for parsing an url. With relative urls, the anchor + // element will create an absolute url relative to the current execution context. + // The JavaScript test suite is executed at '/' which will lead to an absolute url + // starting with '/'. + expect(commonUtils.parseUrl('" test="asf"').pathname).toContain('/%22%20test=%22asf%22'); + }); + }); + + describe('parseUrlPathname', () => { + it('returns an absolute url when given an absolute url', () => { + expect(commonUtils.parseUrlPathname('/some/absolute/url')).toEqual('/some/absolute/url'); + }); + + it('returns an absolute url when given a relative url', () => { + expect(commonUtils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url'); + }); + }); + + describe('urlParamsToArray', () => { + it('returns empty array for empty querystring', () => { + expect(commonUtils.urlParamsToArray('')).toEqual([]); + }); + + it('should decode params', () => { + expect(commonUtils.urlParamsToArray('?label_name%5B%5D=test')[0]).toBe('label_name[]=test'); + }); + + it('should remove the question mark from the search params', () => { + const paramsArray = commonUtils.urlParamsToArray('?test=thing'); + + expect(paramsArray[0][0]).not.toBe('?'); + }); + }); + + describe('urlParamsToObject', () => { + it('parses path for label with trailing +', () => { + expect(commonUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({ + label_name: ['label+'], + }); + }); + + it('parses path for milestone with trailing +', () => { + expect(commonUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({ + milestone_title: 'A+', + }); + }); + + it('parses path for search terms with spaces', () => { + expect(commonUtils.urlParamsToObject('search=two+words', {})).toEqual({ + search: 'two words', + }); + }); + }); + + describe('handleLocationHash', () => { + beforeEach(() => { + jest.spyOn(window.document, 'getElementById'); + }); + + afterEach(() => { + window.history.pushState({}, null, ''); + }); + + function expectGetElementIdToHaveBeenCalledWith(elementId) { + expect(window.document.getElementById).toHaveBeenCalledWith(elementId); + } + + it('decodes hash parameter', () => { + window.history.pushState({}, null, '#random-hash'); + commonUtils.handleLocationHash(); + + expectGetElementIdToHaveBeenCalledWith('random-hash'); + expectGetElementIdToHaveBeenCalledWith('user-content-random-hash'); + }); + + it('decodes cyrillic hash parameter', () => { + window.history.pushState({}, null, '#definição'); + commonUtils.handleLocationHash(); + + expectGetElementIdToHaveBeenCalledWith('definição'); + expectGetElementIdToHaveBeenCalledWith('user-content-definição'); + }); + + it('decodes encoded cyrillic hash parameter', () => { + window.history.pushState({}, null, '#defini%C3%A7%C3%A3o'); + commonUtils.handleLocationHash(); + + expectGetElementIdToHaveBeenCalledWith('definição'); + expectGetElementIdToHaveBeenCalledWith('user-content-definição'); + }); + + it('scrolls element into view', () => { + document.body.innerHTML += ` +
+
+
+
+ `; + + window.history.pushState({}, null, '#test'); + commonUtils.handleLocationHash(); + + expectGetElementIdToHaveBeenCalledWith('test'); + + expect(window.scrollY).toBe(document.getElementById('test').offsetTop); + + document.getElementById('parent').remove(); + }); + + it('scrolls user content element into view', () => { + document.body.innerHTML += ` +
+
+
+
+ `; + + window.history.pushState({}, null, '#test'); + commonUtils.handleLocationHash(); + + expectGetElementIdToHaveBeenCalledWith('test'); + expectGetElementIdToHaveBeenCalledWith('user-content-test'); + + expect(window.scrollY).toBe(document.getElementById('user-content-test').offsetTop); + + document.getElementById('parent').remove(); + }); + + it('scrolls to element with offset from navbar', () => { + jest.spyOn(window, 'scrollBy'); + document.body.innerHTML += ` +
+ +
+
+
+ `; + + window.history.pushState({}, null, '#test'); + commonUtils.handleLocationHash(); + jest.advanceTimersByTime(1); + + expectGetElementIdToHaveBeenCalledWith('test'); + expectGetElementIdToHaveBeenCalledWith('user-content-test'); + + expect(window.scrollY).toBe(document.getElementById('user-content-test').offsetTop - 50); + expect(window.scrollBy).toHaveBeenCalledWith(0, -50); + + document.getElementById('parent').remove(); + }); + }); + + describe('historyPushState', () => { + afterEach(() => { + window.history.replaceState({}, null, null); + }); + + it('should call pushState with the correct path', () => { + jest.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + commonUtils.historyPushState('newpath?page=2'); + + expect(window.history.pushState).toHaveBeenCalled(); + expect(window.history.pushState.mock.calls[0][2]).toContain('newpath?page=2'); + }); + }); + + describe('parseQueryStringIntoObject', () => { + it('should return object with query parameters', () => { + expect(commonUtils.parseQueryStringIntoObject('scope=all&page=2')).toEqual({ + scope: 'all', + page: '2', + }); + + expect(commonUtils.parseQueryStringIntoObject('scope=all')).toEqual({ scope: 'all' }); + expect(commonUtils.parseQueryStringIntoObject()).toEqual({}); + }); + }); + + describe('objectToQueryString', () => { + it('returns empty string when `param` is undefined, null or empty string', () => { + expect(commonUtils.objectToQueryString()).toBe(''); + expect(commonUtils.objectToQueryString('')).toBe(''); + }); + + it('returns query string with values of `params`', () => { + const singleQueryParams = { foo: true }; + const multipleQueryParams = { foo: true, bar: true }; + + expect(commonUtils.objectToQueryString(singleQueryParams)).toBe('foo=true'); + expect(commonUtils.objectToQueryString(multipleQueryParams)).toBe('foo=true&bar=true'); + }); + }); + + describe('buildUrlWithCurrentLocation', () => { + it('should build an url with current location and given parameters', () => { + expect(commonUtils.buildUrlWithCurrentLocation()).toEqual(window.location.pathname); + expect(commonUtils.buildUrlWithCurrentLocation('?page=2')).toEqual( + `${window.location.pathname}?page=2`, + ); + }); + }); + + describe('debounceByAnimationFrame', () => { + it('debounces a function to allow a maximum of one call per animation frame', done => { + const spy = jest.fn(); + const debouncedSpy = commonUtils.debounceByAnimationFrame(spy); + window.requestAnimationFrame(() => { + debouncedSpy(); + debouncedSpy(); + window.requestAnimationFrame(() => { + expect(spy).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); + + describe('getParameterByName', () => { + beforeEach(() => { + window.history.pushState({}, null, '?scope=all&p=2'); + }); + + afterEach(() => { + window.history.replaceState({}, null, null); + }); + + it('should return valid parameter', () => { + const value = commonUtils.getParameterByName('scope'); + + expect(commonUtils.getParameterByName('p')).toEqual('2'); + expect(value).toBe('all'); + }); + + it('should return invalid parameter', () => { + const value = commonUtils.getParameterByName('fakeParameter'); + + expect(value).toBe(null); + }); + + it('should return valid paramentes if URL is provided', () => { + let value = commonUtils.getParameterByName('foo', 'http://cocteau.twins/?foo=bar'); + + expect(value).toBe('bar'); + + value = commonUtils.getParameterByName('manan', 'http://cocteau.twins/?foo=bar&manan=canchu'); + + expect(value).toBe('canchu'); + }); + }); + + describe('normalizedHeaders', () => { + it('should upperCase all the header keys to keep them consistent', () => { + const apiHeaders = { + 'X-Something-Workhorse': { workhorse: 'ok' }, + 'x-something-nginx': { nginx: 'ok' }, + }; + + const normalized = commonUtils.normalizeHeaders(apiHeaders); + + const WORKHORSE = 'X-SOMETHING-WORKHORSE'; + const NGINX = 'X-SOMETHING-NGINX'; + + expect(normalized[WORKHORSE].workhorse).toBe('ok'); + expect(normalized[NGINX].nginx).toBe('ok'); + }); + }); + + describe('normalizeCRLFHeaders', () => { + const testContext = {}; + beforeEach(() => { + testContext.CLRFHeaders = + 'a-header: a-value\nAnother-Header: ANOTHER-VALUE\nLaSt-HeAdEr: last-VALUE'; + jest.spyOn(String.prototype, 'split'); + testContext.normalizeCRLFHeaders = commonUtils.normalizeCRLFHeaders(testContext.CLRFHeaders); + }); + + it('should split by newline', () => { + expect(String.prototype.split).toHaveBeenCalledWith('\n'); + }); + + it('should split by colon+space for each header', () => { + expect(String.prototype.split.mock.calls.filter(args => args[0] === ': ').length).toBe(3); + }); + + it('should return a normalized headers object', () => { + expect(testContext.normalizeCRLFHeaders).toEqual({ + 'A-HEADER': 'a-value', + 'ANOTHER-HEADER': 'ANOTHER-VALUE', + 'LAST-HEADER': 'last-VALUE', + }); + }); + }); + + describe('parseIntPagination', () => { + it('should parse to integers all string values and return pagination object', () => { + const pagination = { + 'X-PER-PAGE': 10, + 'X-PAGE': 2, + 'X-TOTAL': 30, + 'X-TOTAL-PAGES': 3, + 'X-NEXT-PAGE': 3, + 'X-PREV-PAGE': 1, + }; + + const expectedPagination = { + perPage: 10, + page: 2, + total: 30, + totalPages: 3, + nextPage: 3, + previousPage: 1, + }; + + expect(commonUtils.parseIntPagination(pagination)).toEqual(expectedPagination); + }); + }); + + describe('isMetaClick', () => { + it('should identify meta click on Windows/Linux', () => { + const e = { + metaKey: false, + ctrlKey: true, + which: 1, + }; + + expect(commonUtils.isMetaClick(e)).toBe(true); + }); + + it('should identify meta click on macOS', () => { + const e = { + metaKey: true, + ctrlKey: false, + which: 1, + }; + + expect(commonUtils.isMetaClick(e)).toBe(true); + }); + + it('should identify as meta click on middle-click or Mouse-wheel click', () => { + const e = { + metaKey: false, + ctrlKey: false, + which: 2, + }; + + expect(commonUtils.isMetaClick(e)).toBe(true); + }); + }); + + describe('parseBoolean', () => { + const { parseBoolean } = commonUtils; + + it('returns true for "true"', () => { + expect(parseBoolean('true')).toEqual(true); + }); + + it('returns false for "false"', () => { + expect(parseBoolean('false')).toEqual(false); + }); + + it('returns false for "something"', () => { + expect(parseBoolean('something')).toEqual(false); + }); + + it('returns false for null', () => { + expect(parseBoolean(null)).toEqual(false); + }); + + it('is idempotent', () => { + const input = ['true', 'false', 'something', null]; + input.forEach(value => { + const result = parseBoolean(value); + + expect(parseBoolean(result)).toBe(result); + }); + }); + }); + + describe('backOff', () => { + beforeEach(() => { + // shortcut our timeouts otherwise these tests will take a long time to finish + jest.spyOn(window, 'setTimeout').mockImplementation(cb => setImmediate(cb, 0)); + }); + + it('solves the promise from the callback', done => { + const expectedResponseValue = 'Success!'; + commonUtils + .backOff((next, stop) => + new Promise(resolve => { + resolve(expectedResponseValue); + }) + .then(resp => { + stop(resp); + }) + .catch(done.fail), + ) + .then(respBackoff => { + expect(respBackoff).toBe(expectedResponseValue); + done(); + }) + .catch(done.fail); + }); + + it('catches the rejected promise from the callback ', done => { + const errorMessage = 'Mistakes were made!'; + commonUtils + .backOff((next, stop) => { + new Promise((resolve, reject) => { + reject(new Error(errorMessage)); + }) + .then(resp => { + stop(resp); + }) + .catch(err => stop(err)); + }) + .catch(errBackoffResp => { + expect(errBackoffResp instanceof Error).toBe(true); + expect(errBackoffResp.message).toBe(errorMessage); + done(); + }); + }); + + it('solves the promise correctly after retrying a third time', done => { + let numberOfCalls = 1; + const expectedResponseValue = 'Success!'; + commonUtils + .backOff((next, stop) => + Promise.resolve(expectedResponseValue) + .then(resp => { + if (numberOfCalls < 3) { + numberOfCalls += 1; + next(); + } else { + stop(resp); + } + }) + .catch(done.fail), + ) + .then(respBackoff => { + const timeouts = window.setTimeout.mock.calls.map(([, timeout]) => timeout); + + expect(timeouts).toEqual([2000, 4000]); + expect(respBackoff).toBe(expectedResponseValue); + done(); + }) + .catch(done.fail); + }); + + it('rejects the backOff promise after timing out', done => { + commonUtils + .backOff(next => next(), 64000) + .catch(errBackoffResp => { + const timeouts = window.setTimeout.mock.calls.map(([, timeout]) => timeout); + + expect(timeouts).toEqual([2000, 4000, 8000, 16000, 32000, 32000]); + expect(errBackoffResp instanceof Error).toBe(true); + expect(errBackoffResp.message).toBe('BACKOFF_TIMEOUT'); + done(); + }); + }); + }); + + describe('setFavicon', () => { + beforeEach(() => { + const favicon = document.createElement('link'); + favicon.setAttribute('id', 'favicon'); + favicon.setAttribute('href', 'default/favicon'); + favicon.setAttribute('data-default-href', 'default/favicon'); + document.body.appendChild(favicon); + }); + + afterEach(() => { + document.body.removeChild(document.getElementById('favicon')); + }); + + it('should set page favicon to provided favicon', () => { + const faviconPath = '//custom_favicon'; + commonUtils.setFavicon(faviconPath); + + expect(document.getElementById('favicon').getAttribute('href')).toEqual(faviconPath); + }); + }); + + describe('resetFavicon', () => { + beforeEach(() => { + const favicon = document.createElement('link'); + favicon.setAttribute('id', 'favicon'); + favicon.setAttribute('data-original-href', 'default/favicon'); + document.body.appendChild(favicon); + }); + + afterEach(() => { + document.body.removeChild(document.getElementById('favicon')); + }); + + it('should reset page favicon to the default icon', () => { + const favicon = document.getElementById('favicon'); + favicon.setAttribute('href', 'new/favicon'); + commonUtils.resetFavicon(); + + expect(document.getElementById('favicon').getAttribute('href')).toEqual('default/favicon'); + }); + }); + + describe('spriteIcon', () => { + let beforeGon; + + beforeEach(() => { + window.gon = window.gon || {}; + beforeGon = Object.assign({}, window.gon); + window.gon.sprite_icons = 'icons.svg'; + }); + + afterEach(() => { + window.gon = beforeGon; + }); + + it('should return the svg for a linked icon', () => { + expect(commonUtils.spriteIcon('test')).toEqual( + '', + ); + }); + + it('should set svg className when passed', () => { + expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual( + '', + ); + }); + }); + + describe('convertObjectPropsToCamelCase', () => { + it('returns new object with camelCase property names by converting object with snake_case names', () => { + const snakeRegEx = /(_\w)/g; + const mockObj = { + id: 1, + group_name: 'GitLab.org', + absolute_web_url: 'https://gitlab.com/gitlab-org/', + }; + const mappings = { + id: 'id', + groupName: 'group_name', + absoluteWebUrl: 'absolute_web_url', + }; + + const convertedObj = commonUtils.convertObjectPropsToCamelCase(mockObj); + + Object.keys(convertedObj).forEach(prop => { + expect(snakeRegEx.test(prop)).toBeFalsy(); + expect(convertedObj[prop]).toBe(mockObj[mappings[prop]]); + }); + }); + + it('return empty object if method is called with null or undefined', () => { + expect(Object.keys(commonUtils.convertObjectPropsToCamelCase(null)).length).toBe(0); + expect(Object.keys(commonUtils.convertObjectPropsToCamelCase()).length).toBe(0); + expect(Object.keys(commonUtils.convertObjectPropsToCamelCase({})).length).toBe(0); + }); + + it('does not deep-convert by default', () => { + const obj = { + snake_key: { + child_snake_key: 'value', + }, + }; + + expect(commonUtils.convertObjectPropsToCamelCase(obj)).toEqual({ + snakeKey: { + child_snake_key: 'value', + }, + }); + }); + + describe('convertObjectPropsToSnakeCase', () => { + it('converts each object key to snake case', () => { + const obj = { + some: 'some', + 'cool object': 'cool object', + likeThisLongOne: 'likeThisLongOne', + }; + + expect(commonUtils.convertObjectPropsToSnakeCase(obj)).toEqual({ + some: 'some', + cool_object: 'cool object', + like_this_long_one: 'likeThisLongOne', + }); + }); + + it('returns an empty object if there are no keys', () => { + ['', {}, [], null].forEach(badObj => { + expect(commonUtils.convertObjectPropsToSnakeCase(badObj)).toEqual({}); + }); + }); + }); + + describe('with options', () => { + const objWithoutChildren = { + project_name: 'GitLab CE', + group_name: 'GitLab.org', + license_type: 'MIT', + }; + + const objWithChildren = { + project_name: 'GitLab CE', + group_name: 'GitLab.org', + license_type: 'MIT', + tech_stack: { + backend: 'Ruby', + frontend_framework: 'Vue', + database: 'PostgreSQL', + }, + }; + + describe('when options.deep is true', () => { + it('converts object with child objects', () => { + const obj = { + snake_key: { + child_snake_key: 'value', + }, + }; + + expect(commonUtils.convertObjectPropsToCamelCase(obj, { deep: true })).toEqual({ + snakeKey: { + childSnakeKey: 'value', + }, + }); + }); + + it('converts array with child objects', () => { + const arr = [ + { + child_snake_key: 'value', + }, + ]; + + expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([ + { + childSnakeKey: 'value', + }, + ]); + }); + + it('converts array with child arrays', () => { + const arr = [ + [ + { + child_snake_key: 'value', + }, + ], + ]; + + expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([ + [ + { + childSnakeKey: 'value', + }, + ], + ]); + }); + }); + + describe('when options.dropKeys is provided', () => { + it('discards properties mentioned in `dropKeys` array', () => { + expect( + commonUtils.convertObjectPropsToCamelCase(objWithoutChildren, { + dropKeys: ['group_name'], + }), + ).toEqual({ + projectName: 'GitLab CE', + licenseType: 'MIT', + }); + }); + + it('discards properties mentioned in `dropKeys` array when `deep` is true', () => { + expect( + commonUtils.convertObjectPropsToCamelCase(objWithChildren, { + deep: true, + dropKeys: ['group_name', 'database'], + }), + ).toEqual({ + projectName: 'GitLab CE', + licenseType: 'MIT', + techStack: { + backend: 'Ruby', + frontendFramework: 'Vue', + }, + }); + }); + }); + + describe('when options.ignoreKeyNames is provided', () => { + it('leaves properties mentioned in `ignoreKeyNames` array intact', () => { + expect( + commonUtils.convertObjectPropsToCamelCase(objWithoutChildren, { + ignoreKeyNames: ['group_name'], + }), + ).toEqual({ + projectName: 'GitLab CE', + licenseType: 'MIT', + group_name: 'GitLab.org', + }); + }); + + it('leaves properties mentioned in `ignoreKeyNames` array intact when `deep` is true', () => { + expect( + commonUtils.convertObjectPropsToCamelCase(objWithChildren, { + deep: true, + ignoreKeyNames: ['group_name', 'frontend_framework'], + }), + ).toEqual({ + projectName: 'GitLab CE', + group_name: 'GitLab.org', + licenseType: 'MIT', + techStack: { + backend: 'Ruby', + frontend_framework: 'Vue', + database: 'PostgreSQL', + }, + }); + }); + }); + }); + }); + + describe('roundOffFloat', () => { + it('Rounds off decimal places of a float number with provided precision', () => { + expect(commonUtils.roundOffFloat(3.141592, 3)).toBeCloseTo(3.142); + }); + + it('Rounds off a float number to a whole number when provided precision is zero', () => { + expect(commonUtils.roundOffFloat(3.141592, 0)).toBeCloseTo(3); + expect(commonUtils.roundOffFloat(3.5, 0)).toBeCloseTo(4); + }); + + it('Rounds off float number to nearest 0, 10, 100, 1000 and so on when provided precision is below 0', () => { + expect(commonUtils.roundOffFloat(34567.14159, -1)).toBeCloseTo(34570); + expect(commonUtils.roundOffFloat(34567.14159, -2)).toBeCloseTo(34600); + expect(commonUtils.roundOffFloat(34567.14159, -3)).toBeCloseTo(35000); + expect(commonUtils.roundOffFloat(34567.14159, -4)).toBeCloseTo(30000); + expect(commonUtils.roundOffFloat(34567.14159, -5)).toBeCloseTo(0); + }); + }); + + describe('searchBy', () => { + const searchSpace = { + iid: 1, + reference: '&1', + title: 'Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate.', + url: '/groups/gitlab-org/-/epics/1', + }; + + it('returns null when `query` or `searchSpace` params are empty/undefined', () => { + expect(commonUtils.searchBy('omnis', null)).toBeNull(); + expect(commonUtils.searchBy('', searchSpace)).toBeNull(); + expect(commonUtils.searchBy()).toBeNull(); + }); + + it('returns object with matching props based on `query` & `searchSpace` params', () => { + // String `omnis` is found only in `title` prop so return just that + expect(commonUtils.searchBy('omnis', searchSpace)).toEqual( + expect.objectContaining({ + title: searchSpace.title, + }), + ); + + // String `1` is found in both `iid` and `reference` props so return both + expect(commonUtils.searchBy('1', searchSpace)).toEqual( + expect.objectContaining({ + iid: searchSpace.iid, + reference: searchSpace.reference, + }), + ); + + // String `/epics/1` is found in `url` prop so return just that + expect(commonUtils.searchBy('/epics/1', searchSpace)).toEqual( + expect.objectContaining({ + url: searchSpace.url, + }), + ); + }); + }); + + describe('isScopedLabel', () => { + it('returns true when `::` is present in title', () => { + expect(commonUtils.isScopedLabel({ title: 'foo::bar' })).toBe(true); + }); + + it('returns false when `::` is not present', () => { + expect(commonUtils.isScopedLabel({ title: 'foobar' })).toBe(false); + }); + }); + + describe('getDashPath', () => { + it('returns the path following /-/', () => { + expect(commonUtils.getDashPath('/some/-/url-with-dashes-/')).toEqual('url-with-dashes-/'); + }); + + it('returns null when no path follows /-/', () => { + expect(commonUtils.getDashPath('/some/url')).toEqual(null); + }); + }); +}); diff --git a/spec/frontend/lib/utils/mock_data.js b/spec/frontend/lib/utils/mock_data.js new file mode 100644 index 00000000000..c466b0cd1ed --- /dev/null +++ b/spec/frontend/lib/utils/mock_data.js @@ -0,0 +1,8 @@ +export const faviconDataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACcFBMVEX////iQyniQyniQyniQyniQyniQyniQyniQynhRiriQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniRCniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQynhQiniQiniQiniQinhQinpUSjqUSjqTyjqTyjqTyjlSCniRCniQynjRCjqTyjsZSjrWyj8oib9kSb8pyb9pib8oyb8fyb3ZSb4Zib8fCb8oyb8oyb8oyb8pCb8cSbiQyn7bCb8cib8oyb8oSb8bSbtVSjpTij8nyb8oyb8oyb8lCb2Yyf3ZCf8mCb8oyb8oyb8oyb8iib8bSbiRCn8gyb8oyb8eCbpTinrUSj8oyb8oyb8oyb8pSb8bib4Zif0YCf8byb8oyb8oyb8oyb7oib8oyb8nCbjRSn9bib8ayb8nib8oyb8oyb8oyb8kSbpTyjpTyj8jib8oyb8oyb8oyb8fib0Xyf2ZSb8gCb8oyb6pSb8oyb8dib+cCbgQCnjRSn8cCb8oib8oyb8oyb8oybqUCjnSyn8bCb8oyb8oyb8oyb8myb2YyfyXyf8oyb8oyb8hibhQSn+bib8iSb8oyb8qCb+fSbmSSnqTyj8oib9pCb1YifxXyf7pSb8oCb8pCb+mCb0fCf8pSb7hSXvcSjiQyniQinqTyj9kCb9bib9byb+cCbqUSjiRCnsVCj+cSb8pib8bCb8bSbgQCn7bCb8bibjRSn8oyb8ayb8oib8aib8pCbjRCn8pybhQinhQSn8pSb7ayb7aSb6aib8eib///8IbM+7AAAAr3RSTlMBA3NtX2vT698HGQcRLwWLiXnv++3V+eEd/R8HE2V/Y5HjyefdFw99YWfJ+/3nwQP78/HvX1VTQ/kdA2HzbQXj9fX79/3DGf379/33T/v99/f7ba33+/f1+9/18/v59V339flzF/H9+fX3/fMhBwOh9/v5/fmvBV/z+fP3Awnp9/f38+UFgff7+/37+4c77/f7/flFz/f59dFr7/v98Wnr+/f3I5/197EDBU1ZAwUD8/kLUwAAAAFiS0dEAIgFHUgAAAAHdElNRQfhBQoLHiBV6/1lAAACHUlEQVQ4y41TZXsTQRCe4FAIUigN7m7FXY+iLRQKBG2x4g7BjhZ3Le7uMoEkFJprwyQk0CC/iZnNhUZaHt4vt6/szO7cHcD/wFKjZrJWq3YMq1M3eVc9rFzXR2yQkuA3RGxkjZLGiEk9miA2tURJs1RsnhhokYYtzaU13WZDbBVnW1sjo43J2vI6tZ0lLtFeAh1M0lECneI7dGYtrUtk3RUVIKaEJR25qw27yT0s3W0qEHuPlB4RradivXo7GX36xnbo51SQ+fWHARmCgYMGDxkaxbD3SssYPmIkwKgPLrfA87EETTg/fVaSa/SYsQDjSsd7DcGEsr+BieVKmaRNBsjUtClTfUI900y/5Mt05c8oJQKYSURZ2UqYFa0w283M588JEM2BuRwI5EqT8nmmXzZf4l8XsGNfCIv4QcHFklhiBpaqAsuC4tghj+ySyOdjeJYrP7RCCuR/E5tWAqxaLcmCNSyujdxjHZdbn8UHoA0bN/GoNm8hjQJb/ZzYpo6w3TB27JRduxxqrA7YzbWCezixN8RD2Oc2/Ptlfx7o5uT1A4XMiwzj4HfEikNe7+Ew0ZGjeuW70eEYaeHjxomTiKd++E4XnKGz8d+HDufOB3Ky3RcwdNF1qZiKLyf/B44r2tWf15wV143cwI2qfi8dbtKtX6Hbd+6G74EDqkTm/QcPH/0ufFyNLXjy9NnzF9Xb8BJevYY38C+8fZcg/AF3QTYemVkCwwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNy0wNS0xMFQxMTozMDozMiswMjowMMzup8UAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTctMDUtMTBUMTE6MzA6MzIrMDI6MDC9sx95AAAAAElFTkSuQmCC'; + +export const overlayDataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAA85JREFUWAntVllIVGEUPv/9b46O41KplYN7PeRkti8TjQlhCUGh3MmeQugpIsGKAi2soIcIooiohxYKK2daqDAlIpIiWwxtQaJcaHE0d5tMrbn37z9XRqfR0TvVW56Hudf//uec72zfEWBCJjIwkYGJDPzvGSD/KgExN3Oi2Q+2DJgSDYQEMwItVGH1iZGmJw/Si1y+/PwVAMYYib22MYc/8hVQFgKDEfYoId0KYzagAQebsos/ewMZoeB9wdffcTYpQSaCTWHKoqSQaDk7zkIt0+aCUR8BelEHrf3dUNv9AcqbnsHtT5UKB/hTASh0SLYjnjb/CIDRJi0XiFAaJOpCD8zLpdb4NB66b1OfelthX815dtdRRfiti2aAXLvVLiMQ6olGyztGDkSo4JGGXk8/QFdGpYzpHG2GBQTDhtgVhPEaVbbVpvI6GJz22rv4TcAfrYI1x7Rj5MWWAppomKFVVb2302SFzUkZHAbkG+0b1+Gh77yNYjrmqnWTrLBLRxdvBWv8qlFujH/kYjJYyvLkj71t78zAUvzMAMnHhpN4zf9UREJhd8omyssxu1IgazQDwDnHUcNuH6vhPIE1fmuBzHt74Hn7W89jWGtcAjoaIDOFrdcMYJBkgOCoaRF0Lj0oglddDbCj6tRvKjphEpgjkzEQs2YAKsNxMzjn3nKurhzK+Ly7xe28ua8TwgMMcHJZnvvT0BPtEEKM4tDJ+C8GvIIk4ylINIXVZ0EUKJxYuh3mhCeokbudl6TtVc88dfBdLwbyaWB6zQCYQJpBYSrDGQxBQ/ZWRM2B+VNmQnVnHWx7elyNuL2/R336co7KyJR8CL9oLgEuFlREevWUkEl6uGwpVEG4FBm0OEf9N10NMgPlvWYAuNVwsWDKvcUNYsHUWTCZ13ysyFEXe6TO6aC8CUr9IiK+A05TQrc8yjwmxARHeeMAPlfQJw+AQRwu0YhL/GDXi9NwufG+S8dYkuYMqIb4SsWthotlNMOUCOM6r+G9cqXxPmd1dqrBav/o1zJy2l5/NUjJA/VORwYuFnOUaTQcPs9wMqwV++Xv8oADxKAcZ8nLPr8AoGW+xR6HSqYk3GodAz2QNj0V+Gr26dT9ASNH5239Pf0gktVNWZca8ZvfAFBprWS6hSu1pqt++Y0PD+WIwDAhIWQGtzvSHDbcodfFUFB9hg1Gjs5LXqIdFL+acFBl+FddqYwdxsWC3I70OvgfUaA65zhq2O2c8VxYcyIGFTVlXegYtvCXANCQZJMobjVcLMjtSK/IcEgyOOe8Ve5w7ryKDefp2P3+C/5ohv8HZmVLAAAAAElFTkSuQmCC'; + +export const faviconWithOverlayDataUrl = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAGtElEQVRYR8WXf3CT9R3H35/nSdIQIktrCf0RStI0FYRjVBAccxTq5MDBKUoz4ZyjbPO87q4yBsPDMdExTjlvIsdQexyI0oMBeuKhdjsNHhwcMgpjIlublLIm/UlJKZSSJs/z/e6+T5v0CQ22wB/7/pPck8/383l9fj6fEOec8H88NAjAS1LwknsFSVLU8WXd1rtm85LUeKnwGQKzjj3s33azvsEAAEIlnn8ByHL4/Pa7BgAQLCm8QOBOh88vDQkQeMxjMkcQEYKqYsyJWWPhgs/80TsFafzROJtkNIXFfYI0pfXqPeennjqlxPUNikBoTuEmEF+lCRBV3G0aQiWFrwH8d30AWJubGdiEfZzdGqDEEwbICnADQGGHry7zTr0X94IlnnMACggwAWh0+PxOvb5EBGqmTTNkj7ySxWS62C+g5Usm1Zn95YXG24UQ+r5n75Li6Ux4LBkyc7/4t5YSLSr6Lgg9UvBLcKocMEYKON/gGB3YoA/bcGFCczzLQdieLE9bHL66FakBSjzCU0cSAHDa4at7aLhG9XLBEk8zAVnxZxyIEhBy+PwFgwAafpxvNzK5NZUhrX28JA07Cl6SmtvcOUwm4ZAouHj7ad+jMrN1dqb3iG7oS4EYPh2etQS+XiesC8TQ3ZD3yZJsHuUPgbMcI+ej5v3ncv5PasNlk1p7JJnzJL+I0/O5h+u0VCdqIDi78AQRHuirft3hYJzQPvawPydVdPI+/OnTnNNKBjYVXHRa8rFFGeb4w1he0wZ7d/84IXTEhxzxUsgitB2LPFGwvgGUfLSeZUpEXqEqrIdz0nr4iHOUfeOccb/tNMtutzWHPeWcJc0aMxm5lkxYDGloj1zB+Sv/RXXTSXzaeBwSY3j+bHNv2bdtMYCbpHtRkNFd36xFQN3tXkZhvgP1fdPi5kMEXL4oIXKVAA58M8aCVQs84BYLXi5aDq+zGJTqYr+i4PV2vHxmJ/7WUoOn2i/jz6yhW7JjrdSV8U4fQFV+I2Q4UIsedMCSSlcsgp72WtnSajOhzDsBNtsYfFD8e+Rbs4fdIG98uw9vnj+AX7FWvk4NHZOXXphF/INx2SpJIU2L8L4GDAoMwlP9kWSg6awcKVs83tyUnY5Dj75+W8bjutae3o5d9X/HTiWAuUtOS6RUOR8Hp48TxjgU/AMSeKJ1Ej/tMWXG1sxwGt98sBxe5+xhe64XVLiK2Z9XwNgdRLXyzQsC4ENwelIHAFxDBOdh1qdCdNLCoon8RnY+HZ6/+TtzPhTZweAxlJ94C5VqoI2U3a7rACzJjQqgBd24CGscos1kxPQZ38fqSU/jhQkDvN9lrKG7FeUnNuPVKcvwYOb4hGgvi2HSx8vwRKyJkVLl+hk43gdBAcfADBD1cA4RXIdZ1EN1Zjqem+DGoUc2oigjMUlvaV8YL/1qPVpuhOG+JwdH5m1Okn3m6Eacaz3V2jeI9uTbVYY6AKOSKw8MX0MBg2lXjh3r3Hk4s7ASdrMtSWxnoBpZIzIwP3e69lxv3Gay4q/F6zDJ5kq6s6amEnsafJ0Db8P9JKkx1w5wPJuY36IToojgNMzb8rLwmsuB2kW7YDWMSCgTg+YXx9+AQZKxdUaFZiju+a2Mi8uvnH0f2/2f9g4AVE4z4LlTilrlehag9xIpEam4jO4DXfdaV97nwtH5byW137VYD5Yc2YAz4YAGIYx2RLq0z1Sex8l//fUWfBI83jh4Kd1PEuAwqVGjWEwSS+nJJmt0sWu86d0frMQCR/LbWQ8hDAxlXMgUV69Q67ubv0q5FUNAlHKmVLnXE/gfREpUiaQHqAizXbO0UN98BMTSo39Cw7UW7E2Rc728qJGHP68ASbQyNYCQTkAUzCSwQ+CwvSjnsQPGLOnI/C0YO3Lwxq5yhhtqb1KNpGqT1TXvigJU0jh33xpAf7NymoGNDJ9sJtPkYuNkqTh7KnY8vGaoeZPy93+GA1joe4kzzv/SVLqvYngA/dFgVfnlb8tjtm6Ux+I39y/Gqone24IQM+GxL15UO3q7WrhsnhJatCs8PAC9md3OrPK0goaDyEj7uXsuXi0qg4HkIUGE52XHNqmXIl0RGOiHoUV7xb+v5K14SC39At79Ximdhc8ekjImuiyjsXryUszLnY40yThIhSi4bbUHsbfBJ6ZKE5dpQdz4HQOgf2a8tLvklY+M6cuvSnJummxSZ46+X+7biMzaRnSu84IauNYsE5HCOX+HDCPWi7DrKW8/BTcVZ2UN8Me57kc5448TaCYR5XJwC0BtHMwPjs/SgAP1pfuCqSL8Pxhr/wunLWAOAAAAAElFTkSuQmCC'; diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 4dd376faac0..e9322d6b5a9 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -12,6 +12,7 @@ import { deploymentData, metricsDashboardPayload, mockedQueryResultPayload, + metricsDashboardViewModel, mockProjectDir, mockHost, } from '../../mock_data'; @@ -65,7 +66,7 @@ describe('Time series component', () => { ); // Pick the second panel group and the first panel in it - [mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels; + [mockGraphData] = store.state.monitoringDashboard.dashboard.panelGroups[0].panels; }); describe('general functions', () => { @@ -188,7 +189,7 @@ describe('Time series component', () => { }); it('formats tooltip content', () => { - const name = 'Pod average'; + const name = 'Total'; const value = '5.556'; const dataIndex = 0; const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel); @@ -439,7 +440,7 @@ describe('Time series component', () => { it('constructs a label for the chart y-axis', () => { const { yAxis } = getChartOptions(); - expect(yAxis[0].name).toBe('Memory Used per Pod'); + expect(yAxis[0].name).toBe('Total Memory Used'); }); }); }); @@ -535,48 +536,24 @@ describe('Time series component', () => { }); describe('with multiple time series', () => { - const mockedResultMultipleSeries = []; - const [, , panelData] = metricsDashboardPayload.panel_groups[1].panels; - - for (let i = 0; i < panelData.metrics.length; i += 1) { - mockedResultMultipleSeries.push(cloneDeep(mockedQueryResultPayload)); - mockedResultMultipleSeries[ - i - ].metricId = `${panelData.metrics[i].metric_id}_${panelData.metrics[i].id}`; - } - - beforeEach(() => { - setTestTimeout(1000); - - store = createStore(); - - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, - metricsDashboardPayload, - ); - - store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); - - // Mock data contains the metric_id for a multiple time series panel - for (let i = 0; i < panelData.metrics.length; i += 1) { - store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - mockedResultMultipleSeries[i], - ); - } - - // Pick the second panel group and the second panel in it - [, , mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels; - }); - describe('General functions', () => { let timeSeriesChart; beforeEach(done => { - timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart'); + store = createStore(); + const graphData = cloneDeep(metricsDashboardViewModel.panelGroups[0].panels[3]); + graphData.metrics.forEach(metric => + Object.assign(metric, { result: mockedQueryResultPayload.result }), + ); + + timeSeriesChart = makeTimeSeriesChart(graphData, 'area-chart'); timeSeriesChart.vm.$nextTick(done); }); + afterEach(() => { + timeSeriesChart.destroy(); + }); + describe('computed', () => { let chartData; diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index fcf70a1af63..6f05207204e 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -17,12 +17,13 @@ import { setupComponentStore, propsData } from '../init_utils'; import { metricsDashboardPayload, mockedQueryResultPayload, + metricsDashboardViewModel, environmentData, dashboardGitResponse, } from '../mock_data'; const localVue = createLocalVue(); -const expectedPanelCount = 3; +const expectedPanelCount = 4; describe('Dashboard', () => { let store; @@ -366,7 +367,7 @@ describe('Dashboard', () => { it('metrics can be swapped', () => { const firstDraggable = findDraggables().at(0); - const mockMetrics = [...metricsDashboardPayload.panel_groups[1].panels]; + const mockMetrics = [...metricsDashboardViewModel.panelGroups[0].panels]; const firstTitle = mockMetrics[0].title; const secondTitle = mockMetrics[1].title; @@ -376,7 +377,7 @@ describe('Dashboard', () => { firstDraggable.vm.$emit('input', mockMetrics); return wrapper.vm.$nextTick(() => { - const { panels } = wrapper.vm.dashboard.panel_groups[1]; + const { panels } = wrapper.vm.dashboard.panelGroups[0]; expect(panels[1].title).toEqual(firstTitle); expect(panels[0].title).toEqual(secondTitle); diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js index 3bb70a02bd9..850092c4a72 100644 --- a/spec/frontend/monitoring/embed/embed_spec.js +++ b/spec/frontend/monitoring/embed/embed_spec.js @@ -69,8 +69,8 @@ describe('Embed', () => { describe('metrics are available', () => { beforeEach(() => { - store.state.monitoringDashboard.dashboard.panel_groups = groups; - store.state.monitoringDashboard.dashboard.panel_groups[0].panels = metricsData; + store.state.monitoringDashboard.dashboard.panelGroups = groups; + store.state.monitoringDashboard.dashboard.panelGroups[0].panels = metricsData; metricsWithDataGetter.mockReturnValue(metricsWithData); diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index bad3962dd8f..32daf990ad3 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -1,3 +1,5 @@ +import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; + // This import path needs to be relative for now because this mock data is used in // Karma specs too, where the helpers/test_constants alias can not be resolved import { TEST_HOST } from '../helpers/test_constants'; @@ -246,7 +248,7 @@ export const mockedEmptyResult = { }; export const mockedQueryResultPayload = { - metricId: '17_system_metrics_kubernetes_container_memory_average', + metricId: '12_system_metrics_kubernetes_container_memory_total', result: [ { metric: {}, @@ -378,122 +380,28 @@ export const environmentData = [ }, ].concat(extraEnvironmentData); -export const metricsDashboardResponse = { - dashboard: { - dashboard: 'Environment metrics', - priority: 1, - panel_groups: [ - { - group: 'System metrics (Kubernetes)', - priority: 5, - panels: [ - { - title: 'Memory Usage (Total)', - type: 'area-chart', - y_label: 'Total Memory Used', - weight: 4, - metrics: [ - { - id: 'system_metrics_kubernetes_container_memory_total', - query_range: - 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', - label: 'Total', - unit: 'GB', - metric_id: 12, - prometheus_endpoint_path: 'http://test', - }, - ], - }, - { - title: 'Core Usage (Total)', - type: 'area-chart', - y_label: 'Total Cores', - weight: 3, - metrics: [ - { - id: 'system_metrics_kubernetes_container_cores_total', - query_range: - 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)', - label: 'Total', - unit: 'cores', - metric_id: 13, - }, - ], - }, - { - title: 'Memory Usage (Pod average)', - type: 'line-chart', - y_label: 'Memory Used per Pod', - weight: 2, - metrics: [ - { - id: 'system_metrics_kubernetes_container_memory_average', - query_range: - 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', - label: 'Pod average', - unit: 'MB', - metric_id: 14, - }, - ], - }, - ], - }, - ], - }, - status: 'success', -}; - export const metricsDashboardPayload = { dashboard: 'Environment metrics', + priority: 1, panel_groups: [ - { - group: 'Response metrics (NGINX Ingress VTS)', - priority: 10, - panels: [ - { - metrics: [ - { - id: 'response_metrics_nginx_ingress_throughput_status_code', - label: 'Status Code', - metric_id: 1, - prometheus_endpoint_path: - '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29', - query_range: - 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)', - unit: 'req / sec', - }, - ], - title: 'Throughput', - type: 'area-chart', - weight: 1, - y_label: 'Requests / Sec', - }, - ], - }, { group: 'System metrics (Kubernetes)', priority: 5, panels: [ { - title: 'Memory Usage (Pod average)', + title: 'Memory Usage (Total)', type: 'area-chart', - y_label: 'Memory Used per Pod', - weight: 2, + y_label: 'Total Memory Used', + weight: 4, metrics: [ { - id: 'system_metrics_kubernetes_container_memory_average', + id: 'system_metrics_kubernetes_container_memory_total', query_range: - 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', - label: 'Pod average', - unit: 'MB', - metric_id: 17, - prometheus_endpoint_path: - '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', - appearance: { - line: { - width: 2, - }, - }, + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', + label: 'Total', + unit: 'GB', + metric_id: 12, + prometheus_endpoint_path: 'http://test', }, ], }, @@ -513,6 +421,22 @@ export const metricsDashboardPayload = { }, ], }, + { + title: 'Memory Usage (Pod average)', + type: 'line-chart', + y_label: 'Memory Used per Pod', + weight: 2, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_average', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', + label: 'Pod average', + unit: 'MB', + metric_id: 14, + }, + ], + }, { title: 'memories', type: 'area-chart', @@ -557,9 +481,45 @@ export const metricsDashboardPayload = { }, ], }, + { + group: 'Response metrics (NGINX Ingress VTS)', + priority: 10, + panels: [ + { + metrics: [ + { + id: 'response_metrics_nginx_ingress_throughput_status_code', + label: 'Status Code', + metric_id: 1, + prometheus_endpoint_path: + '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29', + query_range: + 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)', + unit: 'req / sec', + }, + ], + title: 'Throughput', + type: 'area-chart', + weight: 1, + y_label: 'Requests / Sec', + }, + ], + }, ], }; +/** + * Mock of response of metrics_dashboard.json + */ +export const metricsDashboardResponse = { + all_dashboards: [], + dashboard: metricsDashboardPayload, + metrics_data: {}, + status: 'success', +}; + +export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload); + const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ default: false, display_name: `Custom Dashboard ${idx}`, diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 11d3109fcd1..211950facd7 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -3,7 +3,7 @@ import testAction from 'helpers/vuex_action_helper'; import Tracking from '~/tracking'; import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; -import { backOff } from '~/lib/utils/common_utils'; +import * as commonUtils from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import store from '~/monitoring/stores'; @@ -28,11 +28,10 @@ import { deploymentData, environmentData, metricsDashboardResponse, - metricsDashboardPayload, + metricsDashboardViewModel, dashboardGitResponse, } from '../mock_data'; -jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); const resetStore = str => { @@ -44,14 +43,17 @@ const resetStore = str => { }; describe('Monitoring store actions', () => { + const { convertObjectPropsToCamelCase } = commonUtils; + let mock; + beforeEach(() => { mock = new MockAdapter(axios); // Mock `backOff` function to remove exponential algorithm delay. jest.useFakeTimers(); - backOff.mockImplementation(callback => { + jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => { const q = new Promise((resolve, reject) => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); const next = () => callback(next, stop); @@ -69,7 +71,7 @@ describe('Monitoring store actions', () => { resetStore(store); mock.reset(); - backOff.mockReset(); + commonUtils.backOff.mockReset(); createFlash.mockReset(); }); @@ -115,7 +117,6 @@ describe('Monitoring store actions', () => { afterEach(() => { resetStore(store); - jest.restoreAllMocks(); }); it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { @@ -365,6 +366,7 @@ describe('Monitoring store actions', () => { ); expect(commit).toHaveBeenCalledWith( types.RECEIVE_METRICS_DATA_SUCCESS, + metricsDashboardResponse.dashboard, ); expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params); @@ -443,8 +445,11 @@ describe('Monitoring store actions', () => { .catch(done.fail); }); it('dispatches fetchPrometheusMetric for each panel query', done => { - state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; - const [metric] = state.dashboard.panel_groups[0].panels[0].metrics; + state.dashboard.panelGroups = convertObjectPropsToCamelCase( + metricsDashboardResponse.dashboard.panel_groups, + ); + + const [metric] = state.dashboard.panelGroups[0].panels[0].metrics; const getters = { metricsWithData: () => [metric.id], }; @@ -473,16 +478,16 @@ describe('Monitoring store actions', () => { }); it('dispatches fetchPrometheusMetric for each panel query, handles an error', done => { - state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; - const metric = state.dashboard.panel_groups[0].panels[0].metrics[0]; + state.dashboard.panelGroups = metricsDashboardViewModel.panelGroups; + const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; - // Mock having one out of three metrics failing + // Mock having one out of four metrics failing dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockResolvedValue(); fetchPrometheusMetrics({ state, commit, dispatch }, params) .then(() => { - expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenCalledTimes(9); // one per metric expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, params, @@ -508,7 +513,12 @@ describe('Monitoring store actions', () => { beforeEach(() => { state = storeState(); [metric] = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics; - [data] = metricsDashboardPayload.panel_groups[0].panels[0].metrics; + metric = convertObjectPropsToCamelCase(metric, { deep: true }); + + data = { + metricId: metric.metricId, + result: [1582065167.353, 5, 1582065599.353], + }; }); it('commits result', done => { @@ -522,13 +532,13 @@ describe('Monitoring store actions', () => { { type: types.REQUEST_METRIC_RESULT, payload: { - metricId: metric.metric_id, + metricId: metric.metricId, }, }, { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { - metricId: metric.metric_id, + metricId: metric.metricId, result: data.result, }, }, @@ -556,13 +566,13 @@ describe('Monitoring store actions', () => { { type: types.REQUEST_METRIC_RESULT, payload: { - metricId: metric.metric_id, + metricId: metric.metricId, }, }, { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { - metricId: metric.metric_id, + metricId: metric.metricId, result: data.result, }, }, @@ -592,13 +602,13 @@ describe('Monitoring store actions', () => { { type: types.REQUEST_METRIC_RESULT, payload: { - metricId: metric.metric_id, + metricId: metric.metricId, }, }, { type: types.RECEIVE_METRIC_RESULT_FAILURE, payload: { - metricId: metric.metric_id, + metricId: metric.metricId, error, }, }, diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index 263050b462f..64601e892ad 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -32,7 +32,7 @@ describe('Monitoring store Getters', () => { it('when dashboard has no panel groups, returns empty', () => { setupState({ dashboard: { - panel_groups: [], + panelGroups: [], }, }); @@ -43,10 +43,10 @@ describe('Monitoring store Getters', () => { let groups; beforeEach(() => { setupState({ - dashboard: { panel_groups: [] }, + dashboard: { panelGroups: [] }, }); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsDashboardPayload); - groups = state.dashboard.panel_groups; + groups = state.dashboard.panelGroups; }); it('no loaded metric returns empty', () => { @@ -84,8 +84,8 @@ describe('Monitoring store Getters', () => { expect(getMetricStates()).toEqual([metricStates.OK]); // Filtered by groups - expect(getMetricStates(state.dashboard.panel_groups[0].key)).toEqual([]); - expect(getMetricStates(state.dashboard.panel_groups[1].key)).toEqual([metricStates.OK]); + expect(getMetricStates(state.dashboard.panelGroups[0].key)).toEqual([metricStates.OK]); + expect(getMetricStates(state.dashboard.panelGroups[1].key)).toEqual([]); }); it('on multiple metrics errors', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsDashboardPayload); @@ -94,10 +94,10 @@ describe('Monitoring store Getters', () => { metricId: groups[0].panels[0].metrics[0].metricId, }); mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId: groups[1].panels[0].metrics[0].metricId, + metricId: groups[0].panels[0].metrics[0].metricId, }); mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId: groups[1].panels[1].metrics[0].metricId, + metricId: groups[1].panels[0].metrics[0].metricId, }); // Entire dashboard fails @@ -113,18 +113,18 @@ describe('Monitoring store Getters', () => { mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayload); // An error in 2 groups mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId: groups[0].panels[0].metrics[0].metricId, + metricId: groups[0].panels[1].metrics[0].metricId, }); mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId: groups[1].panels[1].metrics[0].metricId, + metricId: groups[1].panels[0].metrics[0].metricId, }); expect(getMetricStates()).toEqual([metricStates.OK, metricStates.UNKNOWN_ERROR]); - expect(getMetricStates(groups[0].key)).toEqual([metricStates.UNKNOWN_ERROR]); - expect(getMetricStates(groups[1].key)).toEqual([ + expect(getMetricStates(groups[0].key)).toEqual([ metricStates.OK, metricStates.UNKNOWN_ERROR, ]); + expect(getMetricStates(groups[1].key)).toEqual([metricStates.UNKNOWN_ERROR]); }); }); }); @@ -154,7 +154,7 @@ describe('Monitoring store Getters', () => { it('when dashboard has no panel groups, returns empty', () => { setupState({ dashboard: { - panel_groups: [], + panelGroups: [], }, }); @@ -164,7 +164,7 @@ describe('Monitoring store Getters', () => { describe('when the dashboard is set', () => { beforeEach(() => { setupState({ - dashboard: { panel_groups: [] }, + dashboard: { panelGroups: [] }, }); }); @@ -204,14 +204,14 @@ describe('Monitoring store Getters', () => { mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayload); mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayloadCoresTotal); - // First group has no metrics - expect(metricsWithData(state.dashboard.panel_groups[0].key)).toEqual([]); - - // Second group has metrics - expect(metricsWithData(state.dashboard.panel_groups[1].key)).toEqual([ + // First group has metrics + expect(metricsWithData(state.dashboard.panelGroups[0].key)).toEqual([ mockedQueryResultPayload.metricId, mockedQueryResultPayloadCoresTotal.metricId, ]); + + // Second group has no metrics + expect(metricsWithData(state.dashboard.panelGroups[1].key)).toEqual([]); }); }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 3fb7b84fae5..76efc68788d 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -4,12 +4,8 @@ import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import state from '~/monitoring/stores/state'; import { metricStates } from '~/monitoring/constants'; -import { - metricsDashboardPayload, - deploymentData, - metricsDashboardResponse, - dashboardGitResponse, -} from '../mock_data'; + +import { metricsDashboardPayload, deploymentData, dashboardGitResponse } from '../mock_data'; describe('Monitoring mutations', () => { let stateCopy; @@ -17,27 +13,29 @@ describe('Monitoring mutations', () => { beforeEach(() => { stateCopy = state(); }); + describe('RECEIVE_METRICS_DATA_SUCCESS', () => { let payload; - const getGroups = () => stateCopy.dashboard.panel_groups; + const getGroups = () => stateCopy.dashboard.panelGroups; beforeEach(() => { - stateCopy.dashboard.panel_groups = []; + stateCopy.dashboard.panelGroups = []; payload = metricsDashboardPayload; }); it('adds a key to the group', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); const groups = getGroups(); - expect(groups[0].key).toBe('response-metrics-nginx-ingress-vts-0'); - expect(groups[1].key).toBe('system-metrics-kubernetes-1'); + expect(groups[0].key).toBe('system-metrics-kubernetes-0'); + expect(groups[1].key).toBe('response-metrics-nginx-ingress-vts-1'); }); it('normalizes values', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); const expectedLabel = 'Pod average'; - const { label, query_range } = getGroups()[1].panels[0].metrics[0]; + + const { label, queryRange } = getGroups()[0].panels[2].metrics[0]; expect(label).toEqual(expectedLabel); - expect(query_range.length).toBeGreaterThan(0); + expect(queryRange.length).toBeGreaterThan(0); }); it('contains two groups, with panels with a metric each', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); @@ -47,13 +45,14 @@ describe('Monitoring mutations', () => { expect(groups).toBeDefined(); expect(groups).toHaveLength(2); - expect(groups[0].panels).toHaveLength(1); + expect(groups[0].panels).toHaveLength(4); expect(groups[0].panels[0].metrics).toHaveLength(1); + expect(groups[0].panels[1].metrics).toHaveLength(1); + expect(groups[0].panels[2].metrics).toHaveLength(1); + expect(groups[0].panels[3].metrics).toHaveLength(5); - expect(groups[1].panels).toHaveLength(3); + expect(groups[1].panels).toHaveLength(1); expect(groups[1].panels[0].metrics).toHaveLength(1); - expect(groups[1].panels[1].metrics).toHaveLength(1); - expect(groups[1].panels[2].metrics).toHaveLength(5); }); it('assigns metrics a metric id', () => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); @@ -61,10 +60,10 @@ describe('Monitoring mutations', () => { const groups = getGroups(); expect(groups[0].panels[0].metrics[0].metricId).toEqual( - '1_response_metrics_nginx_ingress_throughput_status_code', + '12_system_metrics_kubernetes_container_memory_total', ); expect(groups[1].panels[0].metrics[0].metricId).toEqual( - '17_system_metrics_kubernetes_container_memory_average', + '1_response_metrics_nginx_ingress_throughput_status_code', ); }); }); @@ -130,8 +129,8 @@ describe('Monitoring mutations', () => { values: [[0, 1], [1, 1], [1, 3]], }, ]; - const { dashboard } = metricsDashboardResponse; - const getMetric = () => stateCopy.dashboard.panel_groups[0].panels[0].metrics[0]; + const dashboard = metricsDashboardPayload; + const getMetric = () => stateCopy.dashboard.panelGroups[0].panels[0].metrics[0]; describe('REQUEST_METRIC_RESULT', () => { beforeEach(() => { diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index d322d45457e..57418e90470 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -1,27 +1,169 @@ import { - normalizeMetric, uniqMetricsId, parseEnvironmentsResponse, removeLeadingSlash, + mapToDashboardViewModel, } from '~/monitoring/stores/utils'; const projectPath = 'gitlab-org/gitlab-test'; -describe('normalizeMetric', () => { - [ - { args: [], expected: 'undefined_undefined' }, - { args: [undefined], expected: 'undefined_undefined' }, - { args: [{ id: 'something' }], expected: 'undefined_something' }, - { args: [{ id: 45 }], expected: 'undefined_45' }, - { args: [{ metric_id: 5 }], expected: '5_undefined' }, - { args: [{ metric_id: 'something' }], expected: 'something_undefined' }, - { - args: [{ metric_id: 5, id: 'system_metrics_kubernetes_container_memory_total' }], - expected: '5_system_metrics_kubernetes_container_memory_total', - }, - ].forEach(({ args, expected }) => { - it(`normalizes metric to "${expected}" with args=${JSON.stringify(args)}`, () => { - expect(normalizeMetric(...args)).toEqual({ metric_id: expected, metricId: expected }); +describe('mapToDashboardViewModel', () => { + it('maps an empty dashboard', () => { + expect(mapToDashboardViewModel({})).toEqual({ + dashboard: '', + panelGroups: [], + }); + }); + + it('maps a simple dashboard', () => { + const response = { + dashboard: 'Dashboard Name', + panel_groups: [ + { + group: 'Group 1', + panels: [ + { + title: 'Title A', + type: 'chart-type', + y_label: 'Y Label A', + metrics: [], + }, + ], + }, + ], + }; + + expect(mapToDashboardViewModel(response)).toEqual({ + dashboard: 'Dashboard Name', + panelGroups: [ + { + group: 'Group 1', + key: 'group-1-0', + panels: [ + { + title: 'Title A', + type: 'chart-type', + y_label: 'Y Label A', + metrics: [], + }, + ], + }, + ], + }); + }); + + describe('panel groups mapping', () => { + it('key', () => { + const response = { + dashboard: 'Dashboard Name', + panel_groups: [ + { + group: 'Group A', + }, + { + group: 'Group B', + }, + { + group: '', + unsupported_property: 'This should be removed', + }, + ], + }; + + expect(mapToDashboardViewModel(response).panelGroups).toEqual([ + { + group: 'Group A', + key: 'group-a-0', + panels: [], + }, + { + group: 'Group B', + key: 'group-b-1', + panels: [], + }, + { + group: '', + key: 'default-2', + panels: [], + }, + ]); + }); + }); + + describe('metrics mapping', () => { + const defaultLabel = 'Panel Label'; + const dashboardWithMetric = (metric, label = defaultLabel) => ({ + panel_groups: [ + { + panels: [ + { + y_label: label, + metrics: [metric], + }, + ], + }, + ], + }); + + const getMappedMetric = dashboard => { + return mapToDashboardViewModel(dashboard).panelGroups[0].panels[0].metrics[0]; + }; + + it('creates a metric', () => { + const dashboard = dashboardWithMetric({}); + + expect(getMappedMetric(dashboard)).toEqual({ + label: expect.any(String), + metricId: expect.any(String), + metric_id: expect.any(String), + }); + }); + + it('creates a metric with a correct ids', () => { + const dashboard = dashboardWithMetric({ + id: 'http_responses', + metric_id: 1, + }); + + expect(getMappedMetric(dashboard)).toMatchObject({ + metricId: '1_http_responses', + metric_id: '1_http_responses', + }); + }); + + it('creates a metric with a default label', () => { + const dashboard = dashboardWithMetric({}); + + expect(getMappedMetric(dashboard)).toMatchObject({ + label: defaultLabel, + }); + }); + + it('creates a metric with an endpoint and query', () => { + const dashboard = dashboardWithMetric({ + prometheus_endpoint_path: 'http://test', + query_range: 'http_responses', + }); + + expect(getMappedMetric(dashboard)).toMatchObject({ + prometheusEndpointPath: 'http://test', + queryRange: 'http_responses', + }); + }); + + it('creates a metric with an ad-hoc property', () => { + // This behavior is deprecated and should be removed + // https://gitlab.com/gitlab-org/gitlab/issues/207198 + + const dashboard = dashboardWithMetric({ + x_label: 'Another label', + unkown_option: 'unkown_data', + }); + + expect(getMappedMetric(dashboard)).toMatchObject({ + x_label: 'Another label', + unkown_option: 'unkown_data', + }); }); }); }); diff --git a/spec/javascripts/lib/utils/browser_spec.js b/spec/javascripts/lib/utils/browser_spec.js new file mode 100644 index 00000000000..6b1074a3b4f --- /dev/null +++ b/spec/javascripts/lib/utils/browser_spec.js @@ -0,0 +1,175 @@ +/** + * This file should only contain browser specific specs. + * If you need to add or update a spec, please see spec/frontend/lib/utils/*.js + * https://gitlab.com/gitlab-org/gitlab/issues/194242#note_292137135 + * https://gitlab.com/groups/gitlab-org/-/epics/895#what-if-theres-a-karma-spec-which-is-simply-unmovable-to-jest-ie-it-is-dependent-on-a-running-browser-environment + */ + +import MockAdapter from 'axios-mock-adapter'; +import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; +import axios from '~/lib/utils/axios_utils'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from './mock_data'; + +const PIXEL_TOLERANCE = 0.2; + +/** + * Loads a data URL as the src of an + * {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image|Image} + * and resolves to that Image once loaded. + * + * @param url + * @returns {Promise} + */ +const urlToImage = url => + new Promise(resolve => { + const img = new Image(); + img.onload = function() { + resolve(img); + }; + img.src = url; + }); + +describe('common_utils browser specific specs', () => { + describe('contentTop', () => { + it('does not add height for fileTitle or compareVersionsHeader if screen is too small', () => { + spyOn(breakpointInstance, 'isDesktop').and.returnValue(false); + + setFixtures(` +
+ blah blah blah +
+
+ more blah blah blah +
+ `); + + expect(commonUtils.contentTop()).toBe(0); + }); + + it('adds height for fileTitle and compareVersionsHeader screen is large enough', () => { + spyOn(breakpointInstance, 'isDesktop').and.returnValue(true); + + setFixtures(` +
+ blah blah blah +
+
+ more blah blah blah +
+ `); + + expect(commonUtils.contentTop()).toBe(18); + }); + }); + + describe('createOverlayIcon', () => { + it('should return the favicon with the overlay', done => { + commonUtils + .createOverlayIcon(faviconDataUrl, overlayDataUrl) + .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) + .then(([actual, expected]) => { + expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFaviconOverlay', () => { + beforeEach(() => { + const favicon = document.createElement('link'); + favicon.setAttribute('id', 'favicon'); + favicon.setAttribute('data-original-href', faviconDataUrl); + document.body.appendChild(favicon); + }); + + afterEach(() => { + document.body.removeChild(document.getElementById('favicon')); + }); + + it('should set page favicon to provided favicon overlay', done => { + commonUtils + .setFaviconOverlay(overlayDataUrl) + .then(() => document.getElementById('favicon').getAttribute('href')) + .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) + .then(([actual, expected]) => { + expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); + done(); + }) + .catch(done.fail); + }); + }); + + describe('setCiStatusFavicon', () => { + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`; + let mock; + + beforeEach(() => { + const favicon = document.createElement('link'); + favicon.setAttribute('id', 'favicon'); + favicon.setAttribute('href', 'null'); + favicon.setAttribute('data-original-href', faviconDataUrl); + document.body.appendChild(favicon); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + document.body.removeChild(document.getElementById('favicon')); + }); + + it('should reset favicon in case of error', done => { + mock.onGet(BUILD_URL).replyOnce(500); + + commonUtils.setCiStatusFavicon(BUILD_URL).catch(() => { + const favicon = document.getElementById('favicon'); + + expect(favicon.getAttribute('href')).toEqual(faviconDataUrl); + done(); + }); + }); + + it('should set page favicon to CI status favicon based on provided status', done => { + mock.onGet(BUILD_URL).reply(200, { + favicon: overlayDataUrl, + }); + + commonUtils + .setCiStatusFavicon(BUILD_URL) + .then(() => document.getElementById('favicon').getAttribute('href')) + .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) + .then(([actual, expected]) => { + expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); + done(); + }) + .catch(done.fail); + }); + }); + + describe('isInViewport', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + }); + + afterEach(() => { + document.body.removeChild(el); + }); + + it('returns true when provided `el` is in viewport', () => { + el.setAttribute('style', `position: absolute; right: ${window.innerWidth + 0.2};`); + document.body.appendChild(el); + + expect(commonUtils.isInViewport(el)).toBe(true); + }); + + it('returns false when provided `el` is not in viewport', () => { + el.setAttribute('style', 'position: absolute; top: -1000px; left: -1000px;'); + document.body.appendChild(el); + + expect(commonUtils.isInViewport(el)).toBe(false); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js deleted file mode 100644 index 504d4a3e01a..00000000000 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ /dev/null @@ -1,981 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; -import axios from '~/lib/utils/axios_utils'; -import * as commonUtils from '~/lib/utils/common_utils'; -import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from './mock_data'; - -const PIXEL_TOLERANCE = 0.2; - -/** - * Loads a data URL as the src of an - * {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image|Image} - * and resolves to that Image once loaded. - * - * @param url - * @returns {Promise} - */ -const urlToImage = url => - new Promise(resolve => { - const img = new Image(); - img.onload = function() { - resolve(img); - }; - img.src = url; - }); - -describe('common_utils', () => { - describe('parseUrl', () => { - it('returns an anchor tag with url', () => { - expect(commonUtils.parseUrl('/some/absolute/url').pathname).toContain('some/absolute/url'); - }); - - it('url is escaped', () => { - // IE11 will return a relative pathname while other browsers will return a full pathname. - // parseUrl uses an anchor element for parsing an url. With relative urls, the anchor - // element will create an absolute url relative to the current execution context. - // The JavaScript test suite is executed at '/' which will lead to an absolute url - // starting with '/'. - expect(commonUtils.parseUrl('" test="asf"').pathname).toContain('/%22%20test=%22asf%22'); - }); - }); - - describe('parseUrlPathname', () => { - it('returns an absolute url when given an absolute url', () => { - expect(commonUtils.parseUrlPathname('/some/absolute/url')).toEqual('/some/absolute/url'); - }); - - it('returns an absolute url when given a relative url', () => { - expect(commonUtils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url'); - }); - }); - - describe('urlParamsToArray', () => { - it('returns empty array for empty querystring', () => { - expect(commonUtils.urlParamsToArray('')).toEqual([]); - }); - - it('should decode params', () => { - expect(commonUtils.urlParamsToArray('?label_name%5B%5D=test')[0]).toBe('label_name[]=test'); - }); - - it('should remove the question mark from the search params', () => { - const paramsArray = commonUtils.urlParamsToArray('?test=thing'); - - expect(paramsArray[0][0]).not.toBe('?'); - }); - }); - - describe('urlParamsToObject', () => { - it('parses path for label with trailing +', () => { - expect(commonUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({ - label_name: ['label+'], - }); - }); - - it('parses path for milestone with trailing +', () => { - expect(commonUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({ - milestone_title: 'A+', - }); - }); - - it('parses path for search terms with spaces', () => { - expect(commonUtils.urlParamsToObject('search=two+words', {})).toEqual({ - search: 'two words', - }); - }); - }); - - describe('handleLocationHash', () => { - beforeEach(() => { - spyOn(window.document, 'getElementById').and.callThrough(); - jasmine.clock().install(); - }); - - afterEach(() => { - window.history.pushState({}, null, ''); - jasmine.clock().uninstall(); - }); - - function expectGetElementIdToHaveBeenCalledWith(elementId) { - expect(window.document.getElementById).toHaveBeenCalledWith(elementId); - } - - it('decodes hash parameter', () => { - window.history.pushState({}, null, '#random-hash'); - commonUtils.handleLocationHash(); - - expectGetElementIdToHaveBeenCalledWith('random-hash'); - expectGetElementIdToHaveBeenCalledWith('user-content-random-hash'); - }); - - it('decodes cyrillic hash parameter', () => { - window.history.pushState({}, null, '#definição'); - commonUtils.handleLocationHash(); - - expectGetElementIdToHaveBeenCalledWith('definição'); - expectGetElementIdToHaveBeenCalledWith('user-content-definição'); - }); - - it('decodes encoded cyrillic hash parameter', () => { - window.history.pushState({}, null, '#defini%C3%A7%C3%A3o'); - commonUtils.handleLocationHash(); - - expectGetElementIdToHaveBeenCalledWith('definição'); - expectGetElementIdToHaveBeenCalledWith('user-content-definição'); - }); - - it('scrolls element into view', () => { - document.body.innerHTML += ` -
-
-
-
- `; - - window.history.pushState({}, null, '#test'); - commonUtils.handleLocationHash(); - - expectGetElementIdToHaveBeenCalledWith('test'); - - expect(window.scrollY).toBe(document.getElementById('test').offsetTop); - - document.getElementById('parent').remove(); - }); - - it('scrolls user content element into view', () => { - document.body.innerHTML += ` -
-
-
-
- `; - - window.history.pushState({}, null, '#test'); - commonUtils.handleLocationHash(); - - expectGetElementIdToHaveBeenCalledWith('test'); - expectGetElementIdToHaveBeenCalledWith('user-content-test'); - - expect(window.scrollY).toBe(document.getElementById('user-content-test').offsetTop); - - document.getElementById('parent').remove(); - }); - - it('scrolls to element with offset from navbar', () => { - spyOn(window, 'scrollBy').and.callThrough(); - document.body.innerHTML += ` -
- -
-
-
- `; - - window.history.pushState({}, null, '#test'); - commonUtils.handleLocationHash(); - jasmine.clock().tick(1); - - expectGetElementIdToHaveBeenCalledWith('test'); - expectGetElementIdToHaveBeenCalledWith('user-content-test'); - - expect(window.scrollY).toBe(document.getElementById('user-content-test').offsetTop - 50); - expect(window.scrollBy).toHaveBeenCalledWith(0, -50); - - document.getElementById('parent').remove(); - }); - }); - - describe('historyPushState', () => { - afterEach(() => { - window.history.replaceState({}, null, null); - }); - - it('should call pushState with the correct path', () => { - spyOn(window.history, 'pushState'); - - commonUtils.historyPushState('newpath?page=2'); - - expect(window.history.pushState).toHaveBeenCalled(); - expect(window.history.pushState.calls.allArgs()[0][2]).toContain('newpath?page=2'); - }); - }); - - describe('parseQueryStringIntoObject', () => { - it('should return object with query parameters', () => { - expect(commonUtils.parseQueryStringIntoObject('scope=all&page=2')).toEqual({ - scope: 'all', - page: '2', - }); - - expect(commonUtils.parseQueryStringIntoObject('scope=all')).toEqual({ scope: 'all' }); - expect(commonUtils.parseQueryStringIntoObject()).toEqual({}); - }); - }); - - describe('objectToQueryString', () => { - it('returns empty string when `param` is undefined, null or empty string', () => { - expect(commonUtils.objectToQueryString()).toBe(''); - expect(commonUtils.objectToQueryString('')).toBe(''); - }); - - it('returns query string with values of `params`', () => { - const singleQueryParams = { foo: true }; - const multipleQueryParams = { foo: true, bar: true }; - - expect(commonUtils.objectToQueryString(singleQueryParams)).toBe('foo=true'); - expect(commonUtils.objectToQueryString(multipleQueryParams)).toBe('foo=true&bar=true'); - }); - }); - - describe('buildUrlWithCurrentLocation', () => { - it('should build an url with current location and given parameters', () => { - expect(commonUtils.buildUrlWithCurrentLocation()).toEqual(window.location.pathname); - expect(commonUtils.buildUrlWithCurrentLocation('?page=2')).toEqual( - `${window.location.pathname}?page=2`, - ); - }); - }); - - describe('debounceByAnimationFrame', () => { - it('debounces a function to allow a maximum of one call per animation frame', done => { - const spy = jasmine.createSpy('spy'); - const debouncedSpy = commonUtils.debounceByAnimationFrame(spy); - window.requestAnimationFrame(() => { - debouncedSpy(); - debouncedSpy(); - window.requestAnimationFrame(() => { - expect(spy).toHaveBeenCalledTimes(1); - done(); - }); - }); - }); - }); - - describe('getParameterByName', () => { - beforeEach(() => { - window.history.pushState({}, null, '?scope=all&p=2'); - }); - - afterEach(() => { - window.history.replaceState({}, null, null); - }); - - it('should return valid parameter', () => { - const value = commonUtils.getParameterByName('scope'); - - expect(commonUtils.getParameterByName('p')).toEqual('2'); - expect(value).toBe('all'); - }); - - it('should return invalid parameter', () => { - const value = commonUtils.getParameterByName('fakeParameter'); - - expect(value).toBe(null); - }); - - it('should return valid paramentes if URL is provided', () => { - let value = commonUtils.getParameterByName('foo', 'http://cocteau.twins/?foo=bar'); - - expect(value).toBe('bar'); - - value = commonUtils.getParameterByName('manan', 'http://cocteau.twins/?foo=bar&manan=canchu'); - - expect(value).toBe('canchu'); - }); - }); - - describe('normalizedHeaders', () => { - it('should upperCase all the header keys to keep them consistent', () => { - const apiHeaders = { - 'X-Something-Workhorse': { workhorse: 'ok' }, - 'x-something-nginx': { nginx: 'ok' }, - }; - - const normalized = commonUtils.normalizeHeaders(apiHeaders); - - const WORKHORSE = 'X-SOMETHING-WORKHORSE'; - const NGINX = 'X-SOMETHING-NGINX'; - - expect(normalized[WORKHORSE].workhorse).toBe('ok'); - expect(normalized[NGINX].nginx).toBe('ok'); - }); - }); - - describe('normalizeCRLFHeaders', () => { - beforeEach(function() { - this.CLRFHeaders = - 'a-header: a-value\nAnother-Header: ANOTHER-VALUE\nLaSt-HeAdEr: last-VALUE'; - spyOn(String.prototype, 'split').and.callThrough(); - this.normalizeCRLFHeaders = commonUtils.normalizeCRLFHeaders(this.CLRFHeaders); - }); - - it('should split by newline', function() { - expect(String.prototype.split).toHaveBeenCalledWith('\n'); - }); - - it('should split by colon+space for each header', function() { - expect(String.prototype.split.calls.allArgs().filter(args => args[0] === ': ').length).toBe( - 3, - ); - }); - - it('should return a normalized headers object', function() { - expect(this.normalizeCRLFHeaders).toEqual({ - 'A-HEADER': 'a-value', - 'ANOTHER-HEADER': 'ANOTHER-VALUE', - 'LAST-HEADER': 'last-VALUE', - }); - }); - }); - - describe('parseIntPagination', () => { - it('should parse to integers all string values and return pagination object', () => { - const pagination = { - 'X-PER-PAGE': 10, - 'X-PAGE': 2, - 'X-TOTAL': 30, - 'X-TOTAL-PAGES': 3, - 'X-NEXT-PAGE': 3, - 'X-PREV-PAGE': 1, - }; - - const expectedPagination = { - perPage: 10, - page: 2, - total: 30, - totalPages: 3, - nextPage: 3, - previousPage: 1, - }; - - expect(commonUtils.parseIntPagination(pagination)).toEqual(expectedPagination); - }); - }); - - describe('isMetaClick', () => { - it('should identify meta click on Windows/Linux', () => { - const e = { - metaKey: false, - ctrlKey: true, - which: 1, - }; - - expect(commonUtils.isMetaClick(e)).toBe(true); - }); - - it('should identify meta click on macOS', () => { - const e = { - metaKey: true, - ctrlKey: false, - which: 1, - }; - - expect(commonUtils.isMetaClick(e)).toBe(true); - }); - - it('should identify as meta click on middle-click or Mouse-wheel click', () => { - const e = { - metaKey: false, - ctrlKey: false, - which: 2, - }; - - expect(commonUtils.isMetaClick(e)).toBe(true); - }); - }); - - describe('contentTop', () => { - it('does not add height for fileTitle or compareVersionsHeader if screen is too small', () => { - spyOn(breakpointInstance, 'isDesktop').and.returnValue(false); - - setFixtures(` -
- blah blah blah -
-
- more blah blah blah -
- `); - - expect(commonUtils.contentTop()).toBe(0); - }); - - it('adds height for fileTitle and compareVersionsHeader screen is large enough', () => { - spyOn(breakpointInstance, 'isDesktop').and.returnValue(true); - - setFixtures(` -
- blah blah blah -
-
- more blah blah blah -
- `); - - expect(commonUtils.contentTop()).toBe(18); - }); - }); - - describe('parseBoolean', () => { - const { parseBoolean } = commonUtils; - - it('returns true for "true"', () => { - expect(parseBoolean('true')).toEqual(true); - }); - - it('returns false for "false"', () => { - expect(parseBoolean('false')).toEqual(false); - }); - - it('returns false for "something"', () => { - expect(parseBoolean('something')).toEqual(false); - }); - - it('returns false for null', () => { - expect(parseBoolean(null)).toEqual(false); - }); - - it('is idempotent', () => { - const input = ['true', 'false', 'something', null]; - input.forEach(value => { - const result = parseBoolean(value); - - expect(parseBoolean(result)).toBe(result); - }); - }); - }); - - describe('backOff', () => { - beforeEach(() => { - // shortcut our timeouts otherwise these tests will take a long time to finish - const origSetTimeout = window.setTimeout; - spyOn(window, 'setTimeout').and.callFake(cb => origSetTimeout(cb, 0)); - }); - - it('solves the promise from the callback', done => { - const expectedResponseValue = 'Success!'; - commonUtils - .backOff((next, stop) => - new Promise(resolve => { - resolve(expectedResponseValue); - }) - .then(resp => { - stop(resp); - }) - .catch(done.fail), - ) - .then(respBackoff => { - expect(respBackoff).toBe(expectedResponseValue); - done(); - }) - .catch(done.fail); - }); - - it('catches the rejected promise from the callback ', done => { - const errorMessage = 'Mistakes were made!'; - commonUtils - .backOff((next, stop) => { - new Promise((resolve, reject) => { - reject(new Error(errorMessage)); - }) - .then(resp => { - stop(resp); - }) - .catch(err => stop(err)); - }) - .catch(errBackoffResp => { - expect(errBackoffResp instanceof Error).toBe(true); - expect(errBackoffResp.message).toBe(errorMessage); - done(); - }); - }); - - it('solves the promise correctly after retrying a third time', done => { - let numberOfCalls = 1; - const expectedResponseValue = 'Success!'; - commonUtils - .backOff((next, stop) => - Promise.resolve(expectedResponseValue) - .then(resp => { - if (numberOfCalls < 3) { - numberOfCalls += 1; - next(); - } else { - stop(resp); - } - }) - .catch(done.fail), - ) - .then(respBackoff => { - const timeouts = window.setTimeout.calls.allArgs().map(([, timeout]) => timeout); - - expect(timeouts).toEqual([2000, 4000]); - expect(respBackoff).toBe(expectedResponseValue); - done(); - }) - .catch(done.fail); - }); - - it('rejects the backOff promise after timing out', done => { - commonUtils - .backOff(next => next(), 64000) - .catch(errBackoffResp => { - const timeouts = window.setTimeout.calls.allArgs().map(([, timeout]) => timeout); - - expect(timeouts).toEqual([2000, 4000, 8000, 16000, 32000, 32000]); - expect(errBackoffResp instanceof Error).toBe(true); - expect(errBackoffResp.message).toBe('BACKOFF_TIMEOUT'); - done(); - }); - }); - }); - - describe('setFavicon', () => { - beforeEach(() => { - const favicon = document.createElement('link'); - favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('href', 'default/favicon'); - favicon.setAttribute('data-default-href', 'default/favicon'); - document.body.appendChild(favicon); - }); - - afterEach(() => { - document.body.removeChild(document.getElementById('favicon')); - }); - - it('should set page favicon to provided favicon', () => { - const faviconPath = '//custom_favicon'; - commonUtils.setFavicon(faviconPath); - - expect(document.getElementById('favicon').getAttribute('href')).toEqual(faviconPath); - }); - }); - - describe('resetFavicon', () => { - beforeEach(() => { - const favicon = document.createElement('link'); - favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('data-original-href', 'default/favicon'); - document.body.appendChild(favicon); - }); - - afterEach(() => { - document.body.removeChild(document.getElementById('favicon')); - }); - - it('should reset page favicon to the default icon', () => { - const favicon = document.getElementById('favicon'); - favicon.setAttribute('href', 'new/favicon'); - commonUtils.resetFavicon(); - - expect(document.getElementById('favicon').getAttribute('href')).toEqual('default/favicon'); - }); - }); - - describe('createOverlayIcon', () => { - it('should return the favicon with the overlay', done => { - commonUtils - .createOverlayIcon(faviconDataUrl, overlayDataUrl) - .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) - .then(([actual, expected]) => { - expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); - done(); - }) - .catch(done.fail); - }); - }); - - describe('setFaviconOverlay', () => { - beforeEach(() => { - const favicon = document.createElement('link'); - favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('data-original-href', faviconDataUrl); - document.body.appendChild(favicon); - }); - - afterEach(() => { - document.body.removeChild(document.getElementById('favicon')); - }); - - it('should set page favicon to provided favicon overlay', done => { - commonUtils - .setFaviconOverlay(overlayDataUrl) - .then(() => document.getElementById('favicon').getAttribute('href')) - .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) - .then(([actual, expected]) => { - expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); - done(); - }) - .catch(done.fail); - }); - }); - - describe('setCiStatusFavicon', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`; - let mock; - - beforeEach(() => { - const favicon = document.createElement('link'); - favicon.setAttribute('id', 'favicon'); - favicon.setAttribute('href', 'null'); - favicon.setAttribute('data-original-href', faviconDataUrl); - document.body.appendChild(favicon); - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - document.body.removeChild(document.getElementById('favicon')); - }); - - it('should reset favicon in case of error', done => { - mock.onGet(BUILD_URL).replyOnce(500); - - commonUtils.setCiStatusFavicon(BUILD_URL).catch(() => { - const favicon = document.getElementById('favicon'); - - expect(favicon.getAttribute('href')).toEqual(faviconDataUrl); - done(); - }); - }); - - it('should set page favicon to CI status favicon based on provided status', done => { - mock.onGet(BUILD_URL).reply(200, { - favicon: overlayDataUrl, - }); - - commonUtils - .setCiStatusFavicon(BUILD_URL) - .then(() => document.getElementById('favicon').getAttribute('href')) - .then(url => Promise.all([urlToImage(url), urlToImage(faviconWithOverlayDataUrl)])) - .then(([actual, expected]) => { - expect(actual).toImageDiffEqual(expected, PIXEL_TOLERANCE); - done(); - }) - .catch(done.fail); - }); - }); - - describe('spriteIcon', () => { - let beforeGon; - - beforeEach(() => { - window.gon = window.gon || {}; - beforeGon = Object.assign({}, window.gon); - window.gon.sprite_icons = 'icons.svg'; - }); - - afterEach(() => { - window.gon = beforeGon; - }); - - it('should return the svg for a linked icon', () => { - expect(commonUtils.spriteIcon('test')).toEqual( - '', - ); - }); - - it('should set svg className when passed', () => { - expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual( - '', - ); - }); - }); - - describe('convertObjectPropsToCamelCase', () => { - it('returns new object with camelCase property names by converting object with snake_case names', () => { - const snakeRegEx = /(_\w)/g; - const mockObj = { - id: 1, - group_name: 'GitLab.org', - absolute_web_url: 'https://gitlab.com/gitlab-org/', - }; - const mappings = { - id: 'id', - groupName: 'group_name', - absoluteWebUrl: 'absolute_web_url', - }; - - const convertedObj = commonUtils.convertObjectPropsToCamelCase(mockObj); - - Object.keys(convertedObj).forEach(prop => { - expect(snakeRegEx.test(prop)).toBeFalsy(); - expect(convertedObj[prop]).toBe(mockObj[mappings[prop]]); - }); - }); - - it('return empty object if method is called with null or undefined', () => { - expect(Object.keys(commonUtils.convertObjectPropsToCamelCase(null)).length).toBe(0); - expect(Object.keys(commonUtils.convertObjectPropsToCamelCase()).length).toBe(0); - expect(Object.keys(commonUtils.convertObjectPropsToCamelCase({})).length).toBe(0); - }); - - it('does not deep-convert by default', () => { - const obj = { - snake_key: { - child_snake_key: 'value', - }, - }; - - expect(commonUtils.convertObjectPropsToCamelCase(obj)).toEqual({ - snakeKey: { - child_snake_key: 'value', - }, - }); - }); - - describe('convertObjectPropsToSnakeCase', () => { - it('converts each object key to snake case', () => { - const obj = { - some: 'some', - 'cool object': 'cool object', - likeThisLongOne: 'likeThisLongOne', - }; - - expect(commonUtils.convertObjectPropsToSnakeCase(obj)).toEqual({ - some: 'some', - cool_object: 'cool object', - like_this_long_one: 'likeThisLongOne', - }); - }); - - it('returns an empty object if there are no keys', () => { - ['', {}, [], null].forEach(badObj => { - expect(commonUtils.convertObjectPropsToSnakeCase(badObj)).toEqual({}); - }); - }); - }); - - describe('with options', () => { - const objWithoutChildren = { - project_name: 'GitLab CE', - group_name: 'GitLab.org', - license_type: 'MIT', - }; - - const objWithChildren = { - project_name: 'GitLab CE', - group_name: 'GitLab.org', - license_type: 'MIT', - tech_stack: { - backend: 'Ruby', - frontend_framework: 'Vue', - database: 'PostgreSQL', - }, - }; - - describe('when options.deep is true', () => { - it('converts object with child objects', () => { - const obj = { - snake_key: { - child_snake_key: 'value', - }, - }; - - expect(commonUtils.convertObjectPropsToCamelCase(obj, { deep: true })).toEqual({ - snakeKey: { - childSnakeKey: 'value', - }, - }); - }); - - it('converts array with child objects', () => { - const arr = [ - { - child_snake_key: 'value', - }, - ]; - - expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([ - { - childSnakeKey: 'value', - }, - ]); - }); - - it('converts array with child arrays', () => { - const arr = [ - [ - { - child_snake_key: 'value', - }, - ], - ]; - - expect(commonUtils.convertObjectPropsToCamelCase(arr, { deep: true })).toEqual([ - [ - { - childSnakeKey: 'value', - }, - ], - ]); - }); - }); - - describe('when options.dropKeys is provided', () => { - it('discards properties mentioned in `dropKeys` array', () => { - expect( - commonUtils.convertObjectPropsToCamelCase(objWithoutChildren, { - dropKeys: ['group_name'], - }), - ).toEqual({ - projectName: 'GitLab CE', - licenseType: 'MIT', - }); - }); - - it('discards properties mentioned in `dropKeys` array when `deep` is true', () => { - expect( - commonUtils.convertObjectPropsToCamelCase(objWithChildren, { - deep: true, - dropKeys: ['group_name', 'database'], - }), - ).toEqual({ - projectName: 'GitLab CE', - licenseType: 'MIT', - techStack: { - backend: 'Ruby', - frontendFramework: 'Vue', - }, - }); - }); - }); - - describe('when options.ignoreKeyNames is provided', () => { - it('leaves properties mentioned in `ignoreKeyNames` array intact', () => { - expect( - commonUtils.convertObjectPropsToCamelCase(objWithoutChildren, { - ignoreKeyNames: ['group_name'], - }), - ).toEqual({ - projectName: 'GitLab CE', - licenseType: 'MIT', - group_name: 'GitLab.org', - }); - }); - - it('leaves properties mentioned in `ignoreKeyNames` array intact when `deep` is true', () => { - expect( - commonUtils.convertObjectPropsToCamelCase(objWithChildren, { - deep: true, - ignoreKeyNames: ['group_name', 'frontend_framework'], - }), - ).toEqual({ - projectName: 'GitLab CE', - group_name: 'GitLab.org', - licenseType: 'MIT', - techStack: { - backend: 'Ruby', - frontend_framework: 'Vue', - database: 'PostgreSQL', - }, - }); - }); - }); - }); - }); - - describe('roundOffFloat', () => { - it('Rounds off decimal places of a float number with provided precision', () => { - expect(commonUtils.roundOffFloat(3.141592, 3)).toBeCloseTo(3.142); - }); - - it('Rounds off a float number to a whole number when provided precision is zero', () => { - expect(commonUtils.roundOffFloat(3.141592, 0)).toBeCloseTo(3); - expect(commonUtils.roundOffFloat(3.5, 0)).toBeCloseTo(4); - }); - - it('Rounds off float number to nearest 0, 10, 100, 1000 and so on when provided precision is below 0', () => { - expect(commonUtils.roundOffFloat(34567.14159, -1)).toBeCloseTo(34570); - expect(commonUtils.roundOffFloat(34567.14159, -2)).toBeCloseTo(34600); - expect(commonUtils.roundOffFloat(34567.14159, -3)).toBeCloseTo(35000); - expect(commonUtils.roundOffFloat(34567.14159, -4)).toBeCloseTo(30000); - expect(commonUtils.roundOffFloat(34567.14159, -5)).toBeCloseTo(0); - }); - }); - - describe('isInViewport', () => { - let el; - - beforeEach(() => { - el = document.createElement('div'); - }); - - afterEach(() => { - document.body.removeChild(el); - }); - - it('returns true when provided `el` is in viewport', () => { - el.setAttribute('style', `position: absolute; right: ${window.innerWidth + 0.2};`); - document.body.appendChild(el); - - expect(commonUtils.isInViewport(el)).toBe(true); - }); - - it('returns false when provided `el` is not in viewport', () => { - el.setAttribute('style', 'position: absolute; top: -1000px; left: -1000px;'); - document.body.appendChild(el); - - expect(commonUtils.isInViewport(el)).toBe(false); - }); - }); - - describe('searchBy', () => { - const searchSpace = { - iid: 1, - reference: '&1', - title: 'Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate.', - url: '/groups/gitlab-org/-/epics/1', - }; - - it('returns null when `query` or `searchSpace` params are empty/undefined', () => { - expect(commonUtils.searchBy('omnis', null)).toBeNull(); - expect(commonUtils.searchBy('', searchSpace)).toBeNull(); - expect(commonUtils.searchBy()).toBeNull(); - }); - - it('returns object with matching props based on `query` & `searchSpace` params', () => { - // String `omnis` is found only in `title` prop so return just that - expect(commonUtils.searchBy('omnis', searchSpace)).toEqual( - jasmine.objectContaining({ - title: searchSpace.title, - }), - ); - - // String `1` is found in both `iid` and `reference` props so return both - expect(commonUtils.searchBy('1', searchSpace)).toEqual( - jasmine.objectContaining({ - iid: searchSpace.iid, - reference: searchSpace.reference, - }), - ); - - // String `/epics/1` is found in `url` prop so return just that - expect(commonUtils.searchBy('/epics/1', searchSpace)).toEqual( - jasmine.objectContaining({ - url: searchSpace.url, - }), - ); - }); - }); - - describe('isScopedLabel', () => { - it('returns true when `::` is present in title', () => { - expect(commonUtils.isScopedLabel({ title: 'foo::bar' })).toBe(true); - }); - - it('returns false when `::` is not present', () => { - expect(commonUtils.isScopedLabel({ title: 'foobar' })).toBe(false); - }); - }); - - describe('getDashPath', () => { - it('returns the path following /-/', () => { - expect(commonUtils.getDashPath('/some/-/url-with-dashes-/')).toEqual('url-with-dashes-/'); - }); - - it('returns null when no path follows /-/', () => { - expect(commonUtils.getDashPath('/some/url')).toEqual(null); - }); - }); -}); diff --git a/spec/javascripts/lib/utils/mock_data.js b/spec/javascripts/lib/utils/mock_data.js index c466b0cd1ed..c2f79a32377 100644 --- a/spec/javascripts/lib/utils/mock_data.js +++ b/spec/javascripts/lib/utils/mock_data.js @@ -1,8 +1 @@ -export const faviconDataUrl = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACcFBMVEX////iQyniQyniQyniQyniQyniQyniQyniQynhRiriQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniRCniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQyniQynhQiniQiniQiniQinhQinpUSjqUSjqTyjqTyjqTyjlSCniRCniQynjRCjqTyjsZSjrWyj8oib9kSb8pyb9pib8oyb8fyb3ZSb4Zib8fCb8oyb8oyb8oyb8pCb8cSbiQyn7bCb8cib8oyb8oSb8bSbtVSjpTij8nyb8oyb8oyb8lCb2Yyf3ZCf8mCb8oyb8oyb8oyb8iib8bSbiRCn8gyb8oyb8eCbpTinrUSj8oyb8oyb8oyb8pSb8bib4Zif0YCf8byb8oyb8oyb8oyb7oib8oyb8nCbjRSn9bib8ayb8nib8oyb8oyb8oyb8kSbpTyjpTyj8jib8oyb8oyb8oyb8fib0Xyf2ZSb8gCb8oyb6pSb8oyb8dib+cCbgQCnjRSn8cCb8oib8oyb8oyb8oybqUCjnSyn8bCb8oyb8oyb8oyb8myb2YyfyXyf8oyb8oyb8hibhQSn+bib8iSb8oyb8qCb+fSbmSSnqTyj8oib9pCb1YifxXyf7pSb8oCb8pCb+mCb0fCf8pSb7hSXvcSjiQyniQinqTyj9kCb9bib9byb+cCbqUSjiRCnsVCj+cSb8pib8bCb8bSbgQCn7bCb8bibjRSn8oyb8ayb8oib8aib8pCbjRCn8pybhQinhQSn8pSb7ayb7aSb6aib8eib///8IbM+7AAAAr3RSTlMBA3NtX2vT698HGQcRLwWLiXnv++3V+eEd/R8HE2V/Y5HjyefdFw99YWfJ+/3nwQP78/HvX1VTQ/kdA2HzbQXj9fX79/3DGf379/33T/v99/f7ba33+/f1+9/18/v59V339flzF/H9+fX3/fMhBwOh9/v5/fmvBV/z+fP3Awnp9/f38+UFgff7+/37+4c77/f7/flFz/f59dFr7/v98Wnr+/f3I5/197EDBU1ZAwUD8/kLUwAAAAFiS0dEAIgFHUgAAAAHdElNRQfhBQoLHiBV6/1lAAACHUlEQVQ4y41TZXsTQRCe4FAIUigN7m7FXY+iLRQKBG2x4g7BjhZ3Le7uMoEkFJprwyQk0CC/iZnNhUZaHt4vt6/szO7cHcD/wFKjZrJWq3YMq1M3eVc9rFzXR2yQkuA3RGxkjZLGiEk9miA2tURJs1RsnhhokYYtzaU13WZDbBVnW1sjo43J2vI6tZ0lLtFeAh1M0lECneI7dGYtrUtk3RUVIKaEJR25qw27yT0s3W0qEHuPlB4RradivXo7GX36xnbo51SQ+fWHARmCgYMGDxkaxbD3SssYPmIkwKgPLrfA87EETTg/fVaSa/SYsQDjSsd7DcGEsr+BieVKmaRNBsjUtClTfUI900y/5Mt05c8oJQKYSURZ2UqYFa0w283M588JEM2BuRwI5EqT8nmmXzZf4l8XsGNfCIv4QcHFklhiBpaqAsuC4tghj+ySyOdjeJYrP7RCCuR/E5tWAqxaLcmCNSyujdxjHZdbn8UHoA0bN/GoNm8hjQJb/ZzYpo6w3TB27JRduxxqrA7YzbWCezixN8RD2Oc2/Ptlfx7o5uT1A4XMiwzj4HfEikNe7+Ew0ZGjeuW70eEYaeHjxomTiKd++E4XnKGz8d+HDufOB3Ky3RcwdNF1qZiKLyf/B44r2tWf15wV143cwI2qfi8dbtKtX6Hbd+6G74EDqkTm/QcPH/0ufFyNLXjy9NnzF9Xb8BJevYY38C+8fZcg/AF3QTYemVkCwwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNy0wNS0xMFQxMTozMDozMiswMjowMMzup8UAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTctMDUtMTBUMTE6MzA6MzIrMDI6MDC9sx95AAAAAElFTkSuQmCC'; - -export const overlayDataUrl = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAA85JREFUWAntVllIVGEUPv/9b46O41KplYN7PeRkti8TjQlhCUGh3MmeQugpIsGKAi2soIcIooiohxYKK2daqDAlIpIiWwxtQaJcaHE0d5tMrbn37z9XRqfR0TvVW56Hudf//uec72zfEWBCJjIwkYGJDPzvGSD/KgExN3Oi2Q+2DJgSDYQEMwItVGH1iZGmJw/Si1y+/PwVAMYYib22MYc/8hVQFgKDEfYoId0KYzagAQebsos/ewMZoeB9wdffcTYpQSaCTWHKoqSQaDk7zkIt0+aCUR8BelEHrf3dUNv9AcqbnsHtT5UKB/hTASh0SLYjnjb/CIDRJi0XiFAaJOpCD8zLpdb4NB66b1OfelthX815dtdRRfiti2aAXLvVLiMQ6olGyztGDkSo4JGGXk8/QFdGpYzpHG2GBQTDhtgVhPEaVbbVpvI6GJz22rv4TcAfrYI1x7Rj5MWWAppomKFVVb2302SFzUkZHAbkG+0b1+Gh77yNYjrmqnWTrLBLRxdvBWv8qlFujH/kYjJYyvLkj71t78zAUvzMAMnHhpN4zf9UREJhd8omyssxu1IgazQDwDnHUcNuH6vhPIE1fmuBzHt74Hn7W89jWGtcAjoaIDOFrdcMYJBkgOCoaRF0Lj0oglddDbCj6tRvKjphEpgjkzEQs2YAKsNxMzjn3nKurhzK+Ly7xe28ua8TwgMMcHJZnvvT0BPtEEKM4tDJ+C8GvIIk4ylINIXVZ0EUKJxYuh3mhCeokbudl6TtVc88dfBdLwbyaWB6zQCYQJpBYSrDGQxBQ/ZWRM2B+VNmQnVnHWx7elyNuL2/R336co7KyJR8CL9oLgEuFlREevWUkEl6uGwpVEG4FBm0OEf9N10NMgPlvWYAuNVwsWDKvcUNYsHUWTCZ13ysyFEXe6TO6aC8CUr9IiK+A05TQrc8yjwmxARHeeMAPlfQJw+AQRwu0YhL/GDXi9NwufG+S8dYkuYMqIb4SsWthotlNMOUCOM6r+G9cqXxPmd1dqrBav/o1zJy2l5/NUjJA/VORwYuFnOUaTQcPs9wMqwV++Xv8oADxKAcZ8nLPr8AoGW+xR6HSqYk3GodAz2QNj0V+Gr26dT9ASNH5239Pf0gktVNWZca8ZvfAFBprWS6hSu1pqt++Y0PD+WIwDAhIWQGtzvSHDbcodfFUFB9hg1Gjs5LXqIdFL+acFBl+FddqYwdxsWC3I70OvgfUaA65zhq2O2c8VxYcyIGFTVlXegYtvCXANCQZJMobjVcLMjtSK/IcEgyOOe8Ve5w7ryKDefp2P3+C/5ohv8HZmVLAAAAAElFTkSuQmCC'; - -export const faviconWithOverlayDataUrl = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAGtElEQVRYR8WXf3CT9R3H35/nSdIQIktrCf0RStI0FYRjVBAccxTq5MDBKUoz4ZyjbPO87q4yBsPDMdExTjlvIsdQexyI0oMBeuKhdjsNHhwcMgpjIlublLIm/UlJKZSSJs/z/e6+T5v0CQ22wB/7/pPck8/383l9fj6fEOec8H88NAjAS1LwknsFSVLU8WXd1rtm85LUeKnwGQKzjj3s33azvsEAAEIlnn8ByHL4/Pa7BgAQLCm8QOBOh88vDQkQeMxjMkcQEYKqYsyJWWPhgs/80TsFafzROJtkNIXFfYI0pfXqPeennjqlxPUNikBoTuEmEF+lCRBV3G0aQiWFrwH8d30AWJubGdiEfZzdGqDEEwbICnADQGGHry7zTr0X94IlnnMACggwAWh0+PxOvb5EBGqmTTNkj7ySxWS62C+g5Usm1Zn95YXG24UQ+r5n75Li6Ux4LBkyc7/4t5YSLSr6Lgg9UvBLcKocMEYKON/gGB3YoA/bcGFCczzLQdieLE9bHL66FakBSjzCU0cSAHDa4at7aLhG9XLBEk8zAVnxZxyIEhBy+PwFgwAafpxvNzK5NZUhrX28JA07Cl6SmtvcOUwm4ZAouHj7ad+jMrN1dqb3iG7oS4EYPh2etQS+XiesC8TQ3ZD3yZJsHuUPgbMcI+ej5v3ncv5PasNlk1p7JJnzJL+I0/O5h+u0VCdqIDi78AQRHuirft3hYJzQPvawPydVdPI+/OnTnNNKBjYVXHRa8rFFGeb4w1he0wZ7d/84IXTEhxzxUsgitB2LPFGwvgGUfLSeZUpEXqEqrIdz0nr4iHOUfeOccb/tNMtutzWHPeWcJc0aMxm5lkxYDGloj1zB+Sv/RXXTSXzaeBwSY3j+bHNv2bdtMYCbpHtRkNFd36xFQN3tXkZhvgP1fdPi5kMEXL4oIXKVAA58M8aCVQs84BYLXi5aDq+zGJTqYr+i4PV2vHxmJ/7WUoOn2i/jz6yhW7JjrdSV8U4fQFV+I2Q4UIsedMCSSlcsgp72WtnSajOhzDsBNtsYfFD8e+Rbs4fdIG98uw9vnj+AX7FWvk4NHZOXXphF/INx2SpJIU2L8L4GDAoMwlP9kWSg6awcKVs83tyUnY5Dj75+W8bjutae3o5d9X/HTiWAuUtOS6RUOR8Hp48TxjgU/AMSeKJ1Ej/tMWXG1sxwGt98sBxe5+xhe64XVLiK2Z9XwNgdRLXyzQsC4ENwelIHAFxDBOdh1qdCdNLCoon8RnY+HZ6/+TtzPhTZweAxlJ94C5VqoI2U3a7rACzJjQqgBd24CGscos1kxPQZ38fqSU/jhQkDvN9lrKG7FeUnNuPVKcvwYOb4hGgvi2HSx8vwRKyJkVLl+hk43gdBAcfADBD1cA4RXIdZ1EN1Zjqem+DGoUc2oigjMUlvaV8YL/1qPVpuhOG+JwdH5m1Okn3m6Eacaz3V2jeI9uTbVYY6AKOSKw8MX0MBg2lXjh3r3Hk4s7ASdrMtSWxnoBpZIzIwP3e69lxv3Gay4q/F6zDJ5kq6s6amEnsafJ0Db8P9JKkx1w5wPJuY36IToojgNMzb8rLwmsuB2kW7YDWMSCgTg+YXx9+AQZKxdUaFZiju+a2Mi8uvnH0f2/2f9g4AVE4z4LlTilrlehag9xIpEam4jO4DXfdaV97nwtH5byW137VYD5Yc2YAz4YAGIYx2RLq0z1Sex8l//fUWfBI83jh4Kd1PEuAwqVGjWEwSS+nJJmt0sWu86d0frMQCR/LbWQ8hDAxlXMgUV69Q67ubv0q5FUNAlHKmVLnXE/gfREpUiaQHqAizXbO0UN98BMTSo39Cw7UW7E2Rc728qJGHP68ASbQyNYCQTkAUzCSwQ+CwvSjnsQPGLOnI/C0YO3Lwxq5yhhtqb1KNpGqT1TXvigJU0jh33xpAf7NymoGNDJ9sJtPkYuNkqTh7KnY8vGaoeZPy93+GA1joe4kzzv/SVLqvYngA/dFgVfnlb8tjtm6Ux+I39y/Gqone24IQM+GxL15UO3q7WrhsnhJatCs8PAC9md3OrPK0goaDyEj7uXsuXi0qg4HkIUGE52XHNqmXIl0RGOiHoUV7xb+v5K14SC39At79Ximdhc8ekjImuiyjsXryUszLnY40yThIhSi4bbUHsbfBJ6ZKE5dpQdz4HQOgf2a8tLvklY+M6cuvSnJummxSZ46+X+7biMzaRnSu84IauNYsE5HCOX+HDCPWi7DrKW8/BTcVZ2UN8Me57kc5448TaCYR5XJwC0BtHMwPjs/SgAP1pfuCqSL8Pxhr/wunLWAOAAAAAElFTkSuQmCC'; +export * from '../../../frontend/lib/utils/mock_data.js'; diff --git a/spec/javascripts/monitoring/components/dashboard_resize_spec.js b/spec/javascripts/monitoring/components/dashboard_resize_spec.js index 2422934f4b3..6a35069ccff 100644 --- a/spec/javascripts/monitoring/components/dashboard_resize_spec.js +++ b/spec/javascripts/monitoring/components/dashboard_resize_spec.js @@ -112,7 +112,7 @@ describe('Dashboard', () => { setupComponentStore(component); return Vue.nextTick().then(() => { - [, promPanel] = component.$el.querySelectorAll('.prometheus-panel'); + [promPanel] = component.$el.querySelectorAll('.prometheus-panel'); promGroup = promPanel.querySelector('.prometheus-graph-group'); panelToggle = promPanel.querySelector('.js-graph-group-toggle'); chart = promGroup.querySelector('.position-relative svg'); diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 7d98f8a0c3e..1cd3071ac68 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -72,6 +72,7 @@ Note: - resolved_by_push - discussion_id - original_discussion_id +- confidential LabelLink: - id - target_type -- cgit v1.2.3