diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-29 12:10:00 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-29 12:10:00 +0000 |
commit | 4233d3aa86fe94e6288279aa55d42ed95bfe753c (patch) | |
tree | 7b97b519371f6df1fa6a0f2ffe69535207a73754 | |
parent | e357d4951c53a3ce4f696cf533ce24a4c6350a7e (diff) | |
download | gitlab-ce-4233d3aa86fe94e6288279aa55d42ed95bfe753c.tar.gz |
Add latest changes from gitlab-org/gitlab@master
51 files changed, 1339 insertions, 363 deletions
diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 293beb60cfd..3e2ccb6fdfe 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -67,15 +67,12 @@ build-assets-image: stage: build-images needs: ["gitlab:assets:compile pull-cache"] variables: - GIT_STRATEGY: none + GIT_DEPTH: "1" script: - - wget -O ./build_assets_image "${CI_PROJECT_URL}/raw/${CI_COMMIT_SHA}/scripts/build_assets_image" - - wget -O ./Dockerfile.assets "${CI_PROJECT_URL}/raw/${CI_COMMIT_SHA}/Dockerfile.assets" - - chmod +x build_assets_image # TODO: Change the image tag to be the MD5 of assets files and skip image building if the image exists # We'll also need to pass GITLAB_ASSETS_TAG to the trigerred omnibus-gitlab pipeline similarly to how we do it for trigerred CNG pipelines # https://gitlab.com/gitlab-org/gitlab/issues/208389 - - ./build_assets_image + - scripts/build_assets_image .compile-assets-metadata: extends: diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index ce6591e85cf..0b401f4d732 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -111,8 +111,8 @@ export default { const filterGroupsBy = getParameterByName('filter') || null; this.isLoading = true; - // eslint-disable-next-line promise/catch-or-return - this.fetchGroups({ + + return this.fetchGroups({ page, filterGroupsBy, sortBy, @@ -126,8 +126,7 @@ export default { fetchPage(page, filterGroupsBy, sortBy, archived) { this.isLoading = true; - // eslint-disable-next-line promise/catch-or-return - this.fetchGroups({ + return this.fetchGroups({ page, filterGroupsBy, sortBy, diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index d78b2d9d962..886e9d76cca 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -108,14 +108,14 @@ export default { return acc.concat({ name, path, - to: `/-/tree/${joinPaths(escapeFileUrl(this.ref), path)}`, + to: `/-/tree/${joinPaths(this.escapedRef, path)}`, }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${escapeFileUrl(this.ref)}/`, + to: `/-/tree/${this.escapedRef}/`, }, ], ); diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 2ba170998e8..c8549180a25 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -81,7 +81,7 @@ export default { <tbody> <parent-row v-show="showParentRow" - :commit-ref="ref" + :commit-ref="escapedRef" :path="path" :loading-path="loadingPath" /> diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index 0a8ee5f2fc5..b4095e00884 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,6 +1,5 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import { escapeFileUrl } from '~/lib/utils/url_utility'; export default { components: { @@ -29,7 +28,7 @@ export default { return splitArray.map(p => encodeURIComponent(p)).join('/'); }, parentRoute() { - return { path: `/-/tree/${escapeFileUrl(this.commitRef)}/${this.parentPath}` }; + return { path: `/-/tree/${this.commitRef}/${this.parentPath}` }; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 6bd1c702a82..f741a6df5d9 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -99,7 +99,7 @@ export default { computed: { routerLinkTo() { return this.isFolder - ? { path: `/-/tree/${escapeFileUrl(this.ref)}/${escapeFileUrl(this.path)}` } + ? { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` } : null; }, isFolder() { diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 05783fc3b5d..6528e283372 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -15,14 +15,15 @@ import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; - const { projectPath, projectShortPath, ref, fullName } = dataset; - const router = createRouter(projectPath, ref); + const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; + const router = createRouter(projectPath, escapedRef); apolloProvider.clients.defaultClient.cache.writeData({ data: { projectPath, projectShortPath, ref, + escapedRef, vueFileListLfsBadge: gon.features?.vueFileListLfsBadge || false, commits: [], }, diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 8cad4a14f31..cef17bf7acb 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -23,13 +23,13 @@ export function fetchLogsTree(client, path, offset, resolver = null) { if (fetchpromise) return fetchpromise; const { projectPath } = client.readQuery({ query: getProjectPath }); - const { ref } = client.readQuery({ query: getRef }); + const { escapedRef } = client.readQuery({ query: getRef }); fetchpromise = axios .get( - `${gon.relative_url_root}/${projectPath}/-/refs/${encodeURIComponent( - ref, - )}/logs_tree/${encodeURIComponent(path.replace(/^\//, ''))}`, + `${gon.relative_url_root}/${projectPath}/-/refs/${escapedRef}/logs_tree/${encodeURIComponent( + path.replace(/^\//, ''), + )}`, { params: { format: 'json', offset }, }, diff --git a/app/assets/javascripts/repository/mixins/get_ref.js b/app/assets/javascripts/repository/mixins/get_ref.js index a43e0e91bcf..99d19b77c35 100644 --- a/app/assets/javascripts/repository/mixins/get_ref.js +++ b/app/assets/javascripts/repository/mixins/get_ref.js @@ -4,11 +4,19 @@ export default { apollo: { ref: { query: getRef, + manual: true, + result({ data, loading }) { + if (!loading) { + this.ref = data.ref; + this.escapedRef = data.escapedRef; + } + }, }, }, data() { return { ref: '', + escapedRef: '', }; }, }; diff --git a/app/assets/javascripts/repository/queries/getRef.query.graphql b/app/assets/javascripts/repository/queries/getRef.query.graphql index 58c09844c3f..91afb751626 100644 --- a/app/assets/javascripts/repository/queries/getRef.query.graphql +++ b/app/assets/javascripts/repository/queries/getRef.query.graphql @@ -1,3 +1,4 @@ query getRef { ref @client + escapedRef @client } diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index b2636f910fe..49e024ca4ff 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -12,7 +12,7 @@ export default function createRouter(base, baseRef) { base: joinPaths(gon.relative_url_root || '', base), routes: [ { - path: `(/-)?/tree/(${encodeURIComponent(baseRef).replace(/%2F/g, '/')}|${baseRef})/:path*`, + path: `(/-)?/tree/${baseRef}/:path*`, name: 'treePath', component: TreePage, props: route => ({ diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index 0b50b8b1130..4dc00581703 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -194,6 +194,7 @@ module TreeHelper project_path: project.full_path, project_short_path: project.path, ref: ref, + escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref), full_name: project.name_with_namespace } end diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb index 99324638300..c94505b2068 100644 --- a/app/services/emails/base_service.rb +++ b/app/services/emails/base_service.rb @@ -11,3 +11,5 @@ module Emails end end end + +Emails::BaseService.prepend_if_ee('::EE::Emails::BaseService') diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index 1ce1ef7a1cd..76c89e85f17 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -10,7 +10,10 @@ module Projects return forbidden unless alerts_service_activated? return unauthorized unless valid_token?(token) - process_incident_issues if process_issues? + alert = create_alert + return bad_request unless alert.persisted? + + process_incident_issues(alert) if process_issues? send_alert_email if send_email? ServiceResponse.success @@ -22,13 +25,21 @@ module Projects delegate :alerts_service, :alerts_service_activated?, to: :project + def am_alert_params + Gitlab::AlertManagement::AlertParams.from_generic_alert(project: project, payload: params.to_h) + end + + def create_alert + AlertManagement::Alert.create(am_alert_params) + end + def send_email? incident_management_setting.send_email? end - def process_incident_issues + def process_incident_issues(alert) IncidentManagement::ProcessAlertWorker - .perform_async(project.id, parsed_payload) + .perform_async(project.id, parsed_payload, alert.id) end def send_alert_email diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb index 8d4294cc231..e63bcc4cb08 100644 --- a/app/workers/incident_management/process_alert_worker.rb +++ b/app/workers/incident_management/process_alert_worker.rb @@ -7,11 +7,14 @@ module IncidentManagement queue_namespace :incident_management feature_category :incident_management - def perform(project_id, alert) + def perform(project_id, alert_payload, am_alert_id = nil) project = find_project(project_id) return unless project - create_issue(project, alert) + new_issue = create_issue(project, alert_payload) + return unless am_alert_id && new_issue.persisted? + + link_issue_with_alert(am_alert_id, new_issue.id) end private @@ -20,10 +23,24 @@ module IncidentManagement Project.find_by_id(project_id) end - def create_issue(project, alert) + def create_issue(project, alert_payload) IncidentManagement::CreateIssueService - .new(project, alert) + .new(project, alert_payload) .execute end + + def link_issue_with_alert(alert_id, issue_id) + alert = AlertManagement::Alert.find_by_id(alert_id) + return unless alert + + return if alert.update(issue_id: issue_id) + + Gitlab::GitLogger.warn( + message: 'Cannot link an Issue with Alert', + issue_id: issue_id, + alert_id: alert_id, + alert_errors: alert.errors.messages + ) + end end end diff --git a/changelogs/unreleased/214547_expose_web_url.yml b/changelogs/unreleased/214547_expose_web_url.yml new file mode 100644 index 00000000000..20bc0aea35e --- /dev/null +++ b/changelogs/unreleased/214547_expose_web_url.yml @@ -0,0 +1,5 @@ +--- +title: Add `web_url` to branch API response +merge_request: 30147 +author: +type: added diff --git a/changelogs/unreleased/ph-215917-escapeRef.yml b/changelogs/unreleased/ph-215917-escapeRef.yml new file mode 100644 index 00000000000..d7c62250aa2 --- /dev/null +++ b/changelogs/unreleased/ph-215917-escapeRef.yml @@ -0,0 +1,5 @@ +--- +title: Fixes branch name not getting escaped correctly on frontend +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/refactor-ee-app-services.yml b/changelogs/unreleased/refactor-ee-app-services.yml new file mode 100644 index 00000000000..ea8dc859b21 --- /dev/null +++ b/changelogs/unreleased/refactor-ee-app-services.yml @@ -0,0 +1,5 @@ +--- +title: Move prepend to last line in ee/services +merge_request: 30425 +author: Rajendra Kadam +type: fixed diff --git a/danger/changelog/Dangerfile b/danger/changelog/Dangerfile index 9bdad75b8e0..7b8a096639d 100644 --- a/danger/changelog/Dangerfile +++ b/danger/changelog/Dangerfile @@ -17,6 +17,7 @@ If you want to create a changelog entry for GitLab EE, run the following instead bin/changelog --ee -m %<mr_iid>s "%<mr_title>s" ``` +If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message. MSG def check_changelog_yaml(path) @@ -65,6 +66,6 @@ if changelog_found check_changelog_yaml(changelog_found) check_changelog_path(changelog_found) elsif changelog.needed? - message "**[CHANGELOG missing](https://docs.gitlab.com/ee/development/changelog.html)**: If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.\n\n" + + message "**[CHANGELOG missing](https://docs.gitlab.com/ee/development/changelog.html)**:\n\n" + format(CREATE_CHANGELOG_MESSAGE, mr_iid: gitlab.mr_json["iid"], mr_title: sanitized_mr_title) end diff --git a/doc/api/branches.md b/doc/api/branches.md index 2f9ca62ced6..f9c87222f9a 100644 --- a/doc/api/branches.md +++ b/doc/api/branches.md @@ -41,6 +41,7 @@ Example response: "developers_can_push": false, "developers_can_merge": false, "can_push": true, + "web_url": "http://gitlab.example.com/my-group/my-project/-/tree/master", "commit": { "author_email": "john@example.com", "author_name": "John Smith", @@ -96,6 +97,7 @@ Example response: "developers_can_push": false, "developers_can_merge": false, "can_push": true, + "web_url": "http://gitlab.example.com/my-group/my-project/-/tree/master", "commit": { "author_email": "john@example.com", "author_name": "John Smith", @@ -171,7 +173,8 @@ Example response: "default": false, "developers_can_push": false, "developers_can_merge": false, - "can_push": true + "can_push": true, + "web_url": "http://gitlab.example.com/my-group/my-project/-/tree/newbranch" } ``` diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 0a56eb9197c..604322bf5a4 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3451,6 +3451,36 @@ type GeoNode { name: String """ + Package file registries of the GeoNode. Available only when feature flag `geo_self_service_framework` is enabled + """ + packageFileRegistries( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Filters registries by their ID + """ + ids: [ID!] + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): PackageFileRegistryConnection + + """ Indicates whether this Geo node is the primary """ primary: Boolean @@ -6330,6 +6360,86 @@ interface Noteable { } """ +Represents the sync and verification state of a package file +""" +type PackageFileRegistry { + """ + Timestamp when the PackageFileRegistry was created + """ + createdAt: Time + + """ + ID of the PackageFileRegistry + """ + id: ID! + + """ + Error message during sync of the PackageFileRegistry + """ + lastSyncFailure: String + + """ + Timestamp of the most recent successful sync of the PackageFileRegistry + """ + lastSyncedAt: Time + + """ + ID of the PackageFile + """ + packageFileId: ID! + + """ + Timestamp after which the PackageFileRegistry should be resynced + """ + retryAt: Time + + """ + Number of consecutive failed sync attempts of the PackageFileRegistry + """ + retryCount: Int + + """ + Sync state of the PackageFileRegistry + """ + state: RegistryState +} + +""" +The connection type for PackageFileRegistry. +""" +type PackageFileRegistryConnection { + """ + A list of edges. + """ + edges: [PackageFileRegistryEdge] + + """ + A list of nodes. + """ + nodes: [PackageFileRegistry] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type PackageFileRegistryEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: PackageFileRegistry +} + +""" Information about pagination in a connection. """ type PageInfo { @@ -7806,6 +7916,31 @@ type Query { } """ +State of a Geo registry. +""" +enum RegistryState { + """ + Registry that failed to sync + """ + FAILED + + """ + Registry waiting to be synced + """ + PENDING + + """ + Registry currently syncing + """ + STARTED + + """ + Registry that is synced + """ + SYNCED +} + +""" Autogenerated input type of RemoveAwardEmoji """ input RemoveAwardEmojiInput { diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index b422258171f..d41088e051d 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -9927,6 +9927,77 @@ "deprecationReason": null }, { + "name": "packageFileRegistries", + "description": "Package file registries of the GeoNode. Available only when feature flag `geo_self_service_framework` is enabled", + "args": [ + { + "name": "ids", + "description": "Filters registries by their ID", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PackageFileRegistryConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "primary", "description": "Indicates whether this Geo node is the primary", "args": [ @@ -19093,6 +19164,251 @@ }, { "kind": "OBJECT", + "name": "PackageFileRegistry", + "description": "Represents the sync and verification state of a package file", + "fields": [ + { + "name": "createdAt", + "description": "Timestamp when the PackageFileRegistry was created", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the PackageFileRegistry", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastSyncFailure", + "description": "Error message during sync of the PackageFileRegistry", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastSyncedAt", + "description": "Timestamp of the most recent successful sync of the PackageFileRegistry", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packageFileId", + "description": "ID of the PackageFile", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryAt", + "description": "Timestamp after which the PackageFileRegistry should be resynced", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryCount", + "description": "Number of consecutive failed sync attempts of the PackageFileRegistry", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": "Sync state of the PackageFileRegistry", + "args": [ + + ], + "type": { + "kind": "ENUM", + "name": "RegistryState", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageFileRegistryConnection", + "description": "The connection type for PackageFileRegistry.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PackageFileRegistryEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PackageFileRegistry", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PackageFileRegistryEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "PackageFileRegistry", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "PageInfo", "description": "Information about pagination in a connection.", "fields": [ @@ -23240,6 +23556,41 @@ "possibleTypes": null }, { + "kind": "ENUM", + "name": "RegistryState", + "description": "State of a Geo registry.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "PENDING", + "description": "Registry waiting to be synced", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "STARTED", + "description": "Registry currently syncing", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SYNCED", + "description": "Registry that is synced", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FAILED", + "description": "Registry that failed to sync", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { "kind": "INPUT_OBJECT", "name": "RemoveAwardEmojiInput", "description": "Autogenerated input type of RemoveAwardEmoji", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a974278f04f..82f5bd1e000 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -962,6 +962,21 @@ Represents a milestone. | `readNote` | Boolean! | Indicates the user can perform `read_note` on this resource | | `resolveNote` | Boolean! | Indicates the user can perform `resolve_note` on this resource | +## PackageFileRegistry + +Represents the sync and verification state of a package file + +| Name | Type | Description | +| --- | ---- | ---------- | +| `createdAt` | Time | Timestamp when the PackageFileRegistry was created | +| `id` | ID! | ID of the PackageFileRegistry | +| `lastSyncFailure` | String | Error message during sync of the PackageFileRegistry | +| `lastSyncedAt` | Time | Timestamp of the most recent successful sync of the PackageFileRegistry | +| `packageFileId` | ID! | ID of the PackageFile | +| `retryAt` | Time | Timestamp after which the PackageFileRegistry should be resynced | +| `retryCount` | Int | Number of consecutive failed sync attempts of the PackageFileRegistry | +| `state` | RegistryState | Sync state of the PackageFileRegistry | + ## PageInfo Information about pagination in a connection. diff --git a/doc/api/projects.md b/doc/api/projects.md index b9513cc767e..aa5e9ef6e43 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -312,8 +312,8 @@ GET /projects?custom_attributes[key]=value&custom_attributes[other_key]=other_va ### Pagination limits -From GitLab 12.10, [offset-based pagination](README.md#offset-based-pagination) will be -[limited to 10,000 records](https://gitlab.com/gitlab-org/gitlab/issues/34565). +From GitLab 13.0, [offset-based pagination](README.md#offset-based-pagination) will be +[limited to 50,000 records](https://gitlab.com/gitlab-org/gitlab/issues/34565). [Keyset pagination](README.md#keyset-based-pagination) will be required to retrieve projects beyond this limit. diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index c8aec5601a2..7e2d4b08767 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -289,3 +289,16 @@ One should apply to be a Vue.js expert by opening an MR when the Merge Request's - Full understanding of testing a Vue and Vuex application - Vuex code follows the [documented pattern](vuex.md#actions-pattern-request-and-receive-namespaces) - Knowledge about the existing Vue and Vuex applications and existing reusable components + +## Vue 2 -> Vue 3 Migration + +> This section is added temporarily to support the efforts to migrate the codebase from Vue 2.x to Vue 3.x + +Currently, we recommend to minimize adding certain features to the codebase to prevent increasing the tech debt for the eventual migration: + +- filters; +- event buses; +- functional templated +- `slot` attributes + +You can find more details on [Migration to Vue 3](vue3_migration.md) diff --git a/doc/development/fe_guide/vue3_migration.md b/doc/development/fe_guide/vue3_migration.md new file mode 100644 index 00000000000..1292926d951 --- /dev/null +++ b/doc/development/fe_guide/vue3_migration.md @@ -0,0 +1,109 @@ +# Migration to Vue 3 + +In order to prepare for the eventual migration to Vue 3.x, we should be wary about adding the following features to the codebase: + +## Vue filters + +**Why?** + +Filters [are removed](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0015-remove-filters.md) from the Vue 3 API completely. + +**What to use instead** + +Component's computed properties / methods or external helpers. + +## Event bus + +**Why?** + +`$on` and `$off` methods [are removed](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0020-events-api-change.md) from the Vue instance, so in Vue 3 it can't be used to create an event bus. + +**What to use instead** + +Vue docs recommend using [mitt](https://github.com/developit/mitt) library. It's relatively small (200 bytes gzipped) and has a simple API: + +```javascript +import mitt from 'mitt' + +const emitter = mitt() + +// listen to an event +emitter.on('foo', e => console.log('foo', e) ) + +// listen to all events +emitter.on('*', (type, e) => console.log(type, e) ) + +// fire an event +emitter.emit('foo', { a: 'b' }) + +// working with handler references: +function onFoo() {} + +emitter.on('foo', onFoo) // listen +emitter.off('foo', onFoo) // unlisten +``` + +## <template functional> + +**Why?** + +In Vue 3, `{ functional: true }` option [is removed](https://github.com/vuejs/rfcs/blob/functional-async-api-change/active-rfcs/0007-functional-async-api-change.md) and `<template functional>` is no longer supported. + +**What to use instead** + +Functional components must be written as plain functions: + +```javascript +import { h } from 'vue' + +const FunctionalComp = (props, slots) => { + return h('div', `Hello! ${props.name}`) +} +``` + +## Old slots syntax with `slot` attribute + +**Why?** + +In Vue 2.6 `slot` attribute was already deprecated in favor of `v-slot` directive but its usage is still allowed and sometimes we prefer using them because it simplifies unit tests (with old syntax, slots are rendered on `shallowMount`). However, in Vue 3 we can't use old syntax anymore. + +**What to use instead** + +The syntax with `v-slot` directive. To fix rendering slots in `shallowMount`, we need to stub a child component with slots explicitly. + +```html +<!-- MyAwesomeComponent.vue --> +<script> +import SomeChildComponent from './some_child_component.vue' + +export default { + components: { + SomeChildComponent + } +} + +</script> + +<template> + <div> + <h1>Hello GitLab!</h1> + <some-child-component> + <template #header> + Header content + </template> + </some-child-component> + </div> +</template> +``` + +```js +// MyAwesomeComponent.spec.js + +import SomeChildComponent from '~/some_child_component.vue' + +shallowMount(MyAwesomeComponent, { + stubs: { + SomeChildComponent + } +}) +``` diff --git a/doc/development/geo/framework.md b/doc/development/geo/framework.md index 83809d1fd3d..a2ee52cbc7c 100644 --- a/doc/development/geo/framework.md +++ b/doc/development/geo/framework.md @@ -161,49 +161,7 @@ state. For example, to add support for files referenced by a `Widget` model with a `widgets` table, you would perform the following steps: -1. Add verification state fields to the `widgets` table so the Geo primary can - track verification state: - - ```ruby - # frozen_string_literal: true - - class AddVerificationStateToWidgets < ActiveRecord::Migration[6.0] - DOWNTIME = false - - def change - add_column :widgets, :verification_retry_at, :datetime_with_timezone - add_column :widgets, :verified_at, :datetime_with_timezone - add_column :widgets, :verification_checksum, :string - add_column :widgets, :verification_failure, :string - add_column :widgets, :verification_retry_count, :integer - end - end - ``` - -1. Add a partial index on `verification_failure` and `verification_checksum` to ensure - re-verification can be performed efficiently: - - ```ruby - # frozen_string_literal: true - - class AddVerificationFailureIndexToWidgets < ActiveRecord::Migration[6.0] - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - disable_ddl_transaction! - - def up - add_concurrent_index :widgets, :verification_failure, where: "(verification_failure IS NOT NULL)", name: "widgets_verification_failure_partial" - add_concurrent_index :widgets, :verification_checksum, where: "(verification_checksum IS NOT NULL)", name: "widgets_verification_checksum_partial" - end - - def down - remove_concurrent_index :widgets, :verification_failure - remove_concurrent_index :widgets, :verification_checksum - end - end - ``` +#### Replication 1. Include `Gitlab::Geo::ReplicableModel` in the `Widget` class, and specify the Replicator class `with_replicator Geo::WidgetReplicator`. @@ -350,11 +308,53 @@ For example, to add support for files referenced by a `Widget` model with a end ``` -Widget files should now be replicated and verified by Geo! +Widgets should now be replicated by Geo! + +#### Verification + +1. Add verification state fields to the `widgets` table so the Geo primary can + track verification state: + + ```ruby + # frozen_string_literal: true + + class AddVerificationStateToWidgets < ActiveRecord::Migration[6.0] + DOWNTIME = false -### Verification statistics with Blob Replicator Strategy + def change + add_column :widgets, :verification_retry_at, :datetime_with_timezone + add_column :widgets, :verified_at, :datetime_with_timezone + add_column :widgets, :verification_checksum, :string + add_column :widgets, :verification_failure, :string + add_column :widgets, :verification_retry_count, :integer + end + end + ``` -GitLab Geo stores statistic data in the `geo_node_statuses` table. +1. Add a partial index on `verification_failure` and `verification_checksum` to ensure + re-verification can be performed efficiently: + + ```ruby + # frozen_string_literal: true + + class AddVerificationFailureIndexToWidgets < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :widgets, :verification_failure, where: "(verification_failure IS NOT NULL)", name: "widgets_verification_failure_partial" + add_concurrent_index :widgets, :verification_checksum, where: "(verification_checksum IS NOT NULL)", name: "widgets_verification_checksum_partial" + end + + def down + remove_concurrent_index :widgets, :verification_failure + remove_concurrent_index :widgets, :verification_checksum + end + end + ``` 1. Add fields `widget_count`, `widget_checksummed_count`, and `widget_checksum_failed_count` to `GeoNodeStatus#RESOURCE_STATUS_FIELDS` array in `ee/app/models/geo_node_status.rb`. @@ -378,3 +378,134 @@ GitLab Geo stores statistic data in the `geo_node_statuses` table. 1. Update `Sidekiq metrics` table in `doc/administration/monitoring/prometheus/gitlab_metrics.md` with new fields. 1. Update `GET /geo_nodes/status` example response in `doc/api/geo_nodes.md` with new fields. 1. Update `ee/spec/models/geo_node_status_spec.rb` and `ee/spec/factories/geo_node_statuses.rb` with new fields. + +To do: Add verification on secondaries. + +Widgets should now be verified by Geo! + +#### GraphQL API + +1. Add a new field to `GeoNodeType` in + `ee/app/graphql/types/geo/geo_node_type.rb`: + + ```ruby + field :widget_registries, ::Types::Geo::WidgetRegistryType.connection_type, + null: true, + resolver: ::Resolvers::Geo::WidgetRegistriesResolver, + description: 'Find widget registries on this Geo node', + feature_flag: :geo_self_service_framework + ``` + +1. Add the new `widget_registries` field name to the `expected_fields` array in + `ee/spec/graphql/types/geo/geo_node_type_spec.rb`. + +1. Create `ee/app/graphql/resolvers/geo/widget_registries_resolver.rb`: + + ```ruby + # frozen_string_literal: true + + module Resolvers + module Geo + class WidgetRegistriesResolver < BaseResolver + include RegistriesResolver + end + end + end + ``` + +1. Create `ee/spec/graphql/resolvers/geo/widget_registries_resolver_spec.rb`: + + ```ruby + # frozen_string_literal: true + + require 'spec_helper' + + describe Resolvers::Geo::WidgetRegistriesResolver do + it_behaves_like 'a Geo registries resolver', :widget_registry + end + ``` + +1. Create `ee/app/finders/geo/widget_registry_finder.rb`: + + ```ruby + # frozen_string_literal: true + + module Geo + class WidgetRegistryFinder + include FrameworkRegistryFinder + end + end + ``` + +1. Create `ee/spec/finders/geo/widget_registry_finder_spec.rb`: + + ```ruby + # frozen_string_literal: true + + require 'spec_helper' + + describe Geo::WidgetRegistryFinder do + it_behaves_like 'a framework registry finder', :widget_registry + end + ``` + +1. Create `ee/app/graphql/types/geo/package_file_registry_type.rb`: + + ```ruby + # frozen_string_literal: true + + module Types + module Geo + # rubocop:disable Graphql/AuthorizeTypes because it is included + class WidgetRegistryType < BaseObject + include ::Types::Geo::RegistryType + + graphql_name 'WidgetRegistry' + description 'Represents the sync and verification state of a widget' + + field :widget_id, GraphQL::ID_TYPE, null: false, description: 'ID of the Widget' + end + end + end + ``` + +1. Create `ee/spec/graphql/types/geo/widget_registry_type_spec.rb`: + + ```ruby + # frozen_string_literal: true + + require 'spec_helper' + + describe GitlabSchema.types['WidgetRegistry'] do + it_behaves_like 'a Geo registry type' + + it 'has the expected fields (other than those included in RegistryType)' do + expected_fields = %i[widget_id] + + expect(described_class).to have_graphql_fields(*expected_fields).at_least + end + end + ``` + +1. Add integration tests for providing Widget registry data to the frontend via + the GraphQL API, by duplicating and modifying the following shared examples + in `ee/spec/requests/api/graphql/geo/registries_spec.rb`: + + ```ruby + it_behaves_like 'gets registries for', { + field_name: 'widgetRegistries', + registry_class_name: 'WidgetRegistry', + registry_factory: :widget_registry, + registry_foreign_key_field_name: 'widgetId' + } + ``` + +Individual widget synchronization and verification data should now be available +via the GraphQL API! + +#### Admin UI + +To do. + +Widget sync and verification data (aggregate and individual) should now be +available in the Admin UI! diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index 6cb97e2e2a8..ae87945c684 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -257,7 +257,7 @@ To do so: 1. Confirm the action by typing the project's path as instructed. NOTE: **Note:** -Only project maintainers have the [permissions](../../permissions.md#project-members-permissions) +Only project owners have the [permissions](../../permissions.md#project-members-permissions) to remove a fork relationship. ## Operations settings diff --git a/lib/api/entities/branch.rb b/lib/api/entities/branch.rb index 1d5017ac702..f9d06082ad6 100644 --- a/lib/api/entities/branch.rb +++ b/lib/api/entities/branch.rb @@ -3,6 +3,8 @@ module API module Entities class Branch < Grape::Entity + include Gitlab::Routing + expose :name expose :commit, using: Entities::Commit do |repo_branch, options| @@ -36,6 +38,10 @@ module API expose :default do |repo_branch, options| options[:project].default_branch == repo_branch.name end + + expose :web_url do |repo_branch| + project_tree_url(options[:project], repo_branch.name) + end end end end diff --git a/lib/gitlab/alert_management/alert_params.rb b/lib/gitlab/alert_management/alert_params.rb new file mode 100644 index 00000000000..014eba6326d --- /dev/null +++ b/lib/gitlab/alert_management/alert_params.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module AlertManagement + class AlertParams + def self.from_generic_alert(project:, payload:) + parsed_payload = Gitlab::Alerting::NotificationPayloadParser.call(payload).with_indifferent_access + annotations = parsed_payload[:annotations] + + { + project_id: project.id, + title: annotations[:title], + description: annotations[:description], + monitoring_tool: annotations[:monitoring_tool], + service: annotations[:service], + hosts: Array(annotations[:hosts]), + payload: payload, + started_at: parsed_payload['startsAt'] + } + end + end + end +end diff --git a/spec/fixtures/api/schemas/public_api/v4/branch.json b/spec/fixtures/api/schemas/public_api/v4/branch.json index 3b0f010bc4f..0073a6d89fc 100644 --- a/spec/fixtures/api/schemas/public_api/v4/branch.json +++ b/spec/fixtures/api/schemas/public_api/v4/branch.json @@ -7,7 +7,8 @@ "protected", "default", "developers_can_push", - "developers_can_merge" + "developers_can_merge", + "web_url" ], "properties" : { "name": { "type": "string" }, @@ -17,7 +18,8 @@ "default": { "type": "boolean" }, "developers_can_push": { "type": "boolean" }, "developers_can_merge": { "type": "boolean" }, - "can_push": { "type": "boolean" } + "can_push": { "type": "boolean" }, + "web_url": { "type": "uri" } }, "additionalProperties": false } diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 23b2564d3f9..6b2a814d721 100644 --- a/spec/javascripts/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -1,13 +1,16 @@ import '~/flash'; import $ from 'jquery'; import Vue from 'vue'; - +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; import appComponent from '~/groups/components/app.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupItemComponent from '~/groups/components/group_item.vue'; import eventHub from '~/groups/event_hub'; import GroupsStore from '~/groups/store/groups_store'; import GroupsService from '~/groups/service/groups_service'; +import * as urlUtilities from '~/lib/utils/url_utility'; import { mockEndpoint, @@ -36,31 +39,20 @@ const createComponent = (hideProjects = false) => { }); }; -const returnServicePromise = (data, failed) => - new Promise((resolve, reject) => { - if (failed) { - reject(data); - } else { - resolve({ - json() { - return data; - }, - }); - } - }); - describe('AppComponent', () => { let vm; + let mock; + let getGroupsSpy; - beforeEach(done => { + beforeEach(() => { + mock = new AxiosMockAdapter(axios); + mock.onGet('/dashboard/groups.json').reply(200, mockGroups); Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); vm = createComponent(); - - Vue.nextTick(() => { - done(); - }); + getGroupsSpy = jest.spyOn(vm.service, 'getGroups'); + return vm.$nextTick(); }); describe('computed', () => { @@ -74,7 +66,7 @@ describe('AppComponent', () => { describe('groups', () => { it('should return list of groups from store', () => { - spyOn(vm.store, 'getGroups'); + jest.spyOn(vm.store, 'getGroups').mockImplementation(() => {}); const { groups } = vm; @@ -85,7 +77,7 @@ describe('AppComponent', () => { describe('pageInfo', () => { it('should return pagination info from store', () => { - spyOn(vm.store, 'getPaginationInfo'); + jest.spyOn(vm.store, 'getPaginationInfo').mockImplementation(() => {}); const { pageInfo } = vm; @@ -105,73 +97,68 @@ describe('AppComponent', () => { }); describe('fetchGroups', () => { - it('should call `getGroups` with all the params provided', done => { - spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups)); - - vm.fetchGroups({ - parentId: 1, - page: 2, - filterGroupsBy: 'git', - sortBy: 'created_desc', - archived: true, + it('should call `getGroups` with all the params provided', () => { + return vm + .fetchGroups({ + parentId: 1, + page: 2, + filterGroupsBy: 'git', + sortBy: 'created_desc', + archived: true, + }) + .then(() => { + expect(getGroupsSpy).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true); + }); + }); + + it('should set headers to store for building pagination info when called with `updatePagination`', () => { + mock.onGet('/dashboard/groups.json').reply(200, { headers: mockRawPageInfo }); + + jest.spyOn(vm, 'updatePagination').mockImplementation(() => {}); + + return vm.fetchGroups({ updatePagination: true }).then(() => { + expect(getGroupsSpy).toHaveBeenCalled(); + expect(vm.updatePagination).toHaveBeenCalled(); }); - setTimeout(() => { - expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc', true); - done(); - }, 0); }); - it('should set headers to store for building pagination info when called with `updatePagination`', done => { - spyOn(vm.service, 'getGroups').and.returnValue( - returnServicePromise({ headers: mockRawPageInfo }), - ); - spyOn(vm, 'updatePagination'); - - vm.fetchGroups({ updatePagination: true }); - setTimeout(() => { - expect(vm.service.getGroups).toHaveBeenCalled(); - expect(vm.updatePagination).toHaveBeenCalled(); - done(); - }, 0); - }); + it('should show flash error when request fails', () => { + mock.onGet('/dashboard/groups.json').reply(400); - it('should show flash error when request fails', done => { - spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true)); - spyOn($, 'scrollTo'); - spyOn(window, 'Flash'); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); - vm.fetchGroups({}); - setTimeout(() => { + return vm.fetchGroups({}).then(() => { expect(vm.isLoading).toBe(false); expect($.scrollTo).toHaveBeenCalledWith(0); expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); - done(); - }, 0); + }); }); }); describe('fetchAllGroups', () => { - it('should fetch default set of groups', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); - spyOn(vm, 'updatePagination').and.callThrough(); - spyOn(vm, 'updateGroups').and.callThrough(); + beforeEach(() => { + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'updateGroups'); + }); - vm.fetchAllGroups(); + it('should fetch default set of groups', () => { + jest.spyOn(vm, 'updatePagination'); + + const fetchPromise = vm.fetchAllGroups(); expect(vm.isLoading).toBe(true); - expect(vm.fetchGroups).toHaveBeenCalled(); - setTimeout(() => { + + return fetchPromise.then(() => { expect(vm.isLoading).toBe(false); expect(vm.updateGroups).toHaveBeenCalled(); - done(); - }, 0); + }); }); - it('should fetch matching set of groups when app is loaded with search query', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups)); - spyOn(vm, 'updateGroups').and.callThrough(); + it('should fetch matching set of groups when app is loaded with search query', () => { + mock.onGet('/dashboard/groups.json').reply(200, mockSearchedGroups); - vm.fetchAllGroups(); + const fetchPromise = vm.fetchAllGroups(); expect(vm.fetchGroups).toHaveBeenCalledWith({ page: null, @@ -180,22 +167,24 @@ describe('AppComponent', () => { updatePagination: true, archived: null, }); - setTimeout(() => { + return fetchPromise.then(() => { expect(vm.updateGroups).toHaveBeenCalled(); - done(); - }, 0); + }); }); }); describe('fetchPage', () => { - it('should fetch groups for provided page details and update window state', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); - spyOn(vm, 'updateGroups').and.callThrough(); - const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough(); - spyOn(window.history, 'replaceState'); - spyOn($, 'scrollTo'); + beforeEach(() => { + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'updateGroups'); + }); - vm.fetchPage(2, null, null, true); + it('should fetch groups for provided page details and update window state', () => { + jest.spyOn(urlUtilities, 'mergeUrlParams'); + jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); + + const fetchPagePromise = vm.fetchPage(2, null, null, true); expect(vm.isLoading).toBe(true); expect(vm.fetchGroups).toHaveBeenCalledWith({ @@ -205,21 +194,21 @@ describe('AppComponent', () => { updatePagination: true, archived: true, }); - setTimeout(() => { + + return fetchPagePromise.then(() => { expect(vm.isLoading).toBe(false); expect($.scrollTo).toHaveBeenCalledWith(0); - expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String)); + expect(urlUtilities.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, expect.any(String)); expect(window.history.replaceState).toHaveBeenCalledWith( { - page: jasmine.any(String), + page: expect.any(String), }, - jasmine.any(String), - jasmine.any(String), + expect.any(String), + expect.any(String), ); expect(vm.updateGroups).toHaveBeenCalled(); - done(); - }, 0); + }); }); }); @@ -232,9 +221,10 @@ describe('AppComponent', () => { groupItem.isChildrenLoading = false; }); - it('should fetch children of given group and expand it if group is collapsed and children are not loaded', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren)); - spyOn(vm.store, 'setGroupChildren'); + it('should fetch children of given group and expand it if group is collapsed and children are not loaded', () => { + mock.onGet('/dashboard/groups.json').reply(200, mockRawChildren); + jest.spyOn(vm, 'fetchGroups'); + jest.spyOn(vm.store, 'setGroupChildren').mockImplementation(() => {}); vm.toggleChildren(groupItem); @@ -242,14 +232,13 @@ describe('AppComponent', () => { expect(vm.fetchGroups).toHaveBeenCalledWith({ parentId: groupItem.id, }); - setTimeout(() => { + return waitForPromises().then(() => { expect(vm.store.setGroupChildren).toHaveBeenCalled(); - done(); - }, 0); + }); }); it('should skip network request while expanding group if children are already loaded', () => { - spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'fetchGroups'); groupItem.children = mockRawChildren; vm.toggleChildren(groupItem); @@ -259,7 +248,7 @@ describe('AppComponent', () => { }); it('should collapse group if it is already expanded', () => { - spyOn(vm, 'fetchGroups'); + jest.spyOn(vm, 'fetchGroups'); groupItem.isOpen = true; vm.toggleChildren(groupItem); @@ -268,16 +257,15 @@ describe('AppComponent', () => { expect(groupItem.isOpen).toBe(false); }); - it('should set `isChildrenLoading` back to `false` if load request fails', done => { - spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true)); + it('should set `isChildrenLoading` back to `false` if load request fails', () => { + mock.onGet('/dashboard/groups.json').reply(400); vm.toggleChildren(groupItem); expect(groupItem.isChildrenLoading).toBe(true); - setTimeout(() => { + return waitForPromises().then(() => { expect(groupItem.isChildrenLoading).toBe(false); - done(); - }, 0); + }); }); }); @@ -332,70 +320,63 @@ describe('AppComponent', () => { vm.targetParentGroup = groupItem; }); - it('hides modal confirmation leave group and remove group item from tree', done => { + it('hides modal confirmation leave group and remove group item from tree', () => { const notice = `You left the "${childGroupItem.fullName}" group.`; - spyOn(vm.service, 'leaveGroup').and.returnValue(Promise.resolve({ data: { notice } })); - spyOn(vm.store, 'removeGroup').and.callThrough(); - spyOn(window, 'Flash'); - spyOn($, 'scrollTo'); + jest.spyOn(vm.service, 'leaveGroup').mockResolvedValue({ data: { notice } }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); + jest.spyOn($, 'scrollTo').mockImplementation(() => {}); vm.leaveGroup(); expect(vm.showModal).toBe(false); expect(vm.targetGroup.isBeingRemoved).toBe(true); expect(vm.service.leaveGroup).toHaveBeenCalledWith(vm.targetGroup.leavePath); - setTimeout(() => { + return waitForPromises().then(() => { expect($.scrollTo).toHaveBeenCalledWith(0); expect(vm.store.removeGroup).toHaveBeenCalledWith(vm.targetGroup, vm.targetParentGroup); expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); - done(); - }, 0); + }); }); - it('should show error flash message if request failed to leave group', done => { + it('should show error flash message if request failed to leave group', () => { const message = 'An error occurred. Please try again.'; - spyOn(vm.service, 'leaveGroup').and.returnValue( - returnServicePromise({ status: 500 }, true), - ); - spyOn(vm.store, 'removeGroup').and.callThrough(); - spyOn(window, 'Flash'); + jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); vm.leaveGroup(); expect(vm.targetGroup.isBeingRemoved).toBe(true); expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); - setTimeout(() => { + return waitForPromises().then(() => { expect(vm.store.removeGroup).not.toHaveBeenCalled(); expect(window.Flash).toHaveBeenCalledWith(message); expect(vm.targetGroup.isBeingRemoved).toBe(false); - done(); - }, 0); + }); }); - it('should show appropriate error flash message if request forbids to leave group', done => { + it('should show appropriate error flash message if request forbids to leave group', () => { const message = 'Failed to leave the group. Please make sure you are not the only owner.'; - spyOn(vm.service, 'leaveGroup').and.returnValue( - returnServicePromise({ status: 403 }, true), - ); - spyOn(vm.store, 'removeGroup').and.callThrough(); - spyOn(window, 'Flash'); + jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 }); + jest.spyOn(vm.store, 'removeGroup'); + jest.spyOn(window, 'Flash').mockImplementation(() => {}); vm.leaveGroup(childGroupItem, groupItem); expect(vm.targetGroup.isBeingRemoved).toBe(true); expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); - setTimeout(() => { + return waitForPromises().then(() => { expect(vm.store.removeGroup).not.toHaveBeenCalled(); expect(window.Flash).toHaveBeenCalledWith(message); expect(vm.targetGroup.isBeingRemoved).toBe(false); - done(); - }, 0); + }); }); }); describe('updatePagination', () => { it('should set pagination info to store from provided headers', () => { - spyOn(vm.store, 'setPaginationInfo'); + jest.spyOn(vm.store, 'setPaginationInfo').mockImplementation(() => {}); vm.updatePagination(mockRawPageInfo); @@ -405,7 +386,7 @@ describe('AppComponent', () => { describe('updateGroups', () => { it('should call setGroups on store if method was called directly', () => { - spyOn(vm.store, 'setGroups'); + jest.spyOn(vm.store, 'setGroups').mockImplementation(() => {}); vm.updateGroups(mockGroups); @@ -413,7 +394,7 @@ describe('AppComponent', () => { }); it('should call setSearchedGroups on store if method was called with fromSearch param', () => { - spyOn(vm.store, 'setSearchedGroups'); + jest.spyOn(vm.store, 'setSearchedGroups').mockImplementation(() => {}); vm.updateGroups(mockGroups, true); @@ -433,59 +414,55 @@ describe('AppComponent', () => { }); describe('created', () => { - it('should bind event listeners on eventHub', done => { - spyOn(eventHub, '$on'); + it('should bind event listeners on eventHub', () => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); const newVm = createComponent(); newVm.$mount(); - Vue.nextTick(() => { - expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); - expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); + return vm.$nextTick().then(() => { + expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', expect.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', expect.any(Function)); newVm.$destroy(); - done(); }); }); - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', done => { + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', () => { const newVm = createComponent(); newVm.$mount(); - Vue.nextTick(() => { + return vm.$nextTick().then(() => { expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search'); newVm.$destroy(); - done(); }); }); - it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', done => { + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => { const newVm = createComponent(true); newVm.$mount(); - Vue.nextTick(() => { + return vm.$nextTick().then(() => { expect(newVm.searchEmptyMessage).toBe('No groups matched your search'); newVm.$destroy(); - done(); }); }); }); describe('beforeDestroy', () => { - it('should unbind event listeners on eventHub', done => { - spyOn(eventHub, '$off'); + it('should unbind event listeners on eventHub', () => { + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); const newVm = createComponent(); newVm.$mount(); newVm.$destroy(); - Vue.nextTick(() => { - expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); - expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); - done(); + return vm.$nextTick().then(() => { + expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('showLeaveGroupModal', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', expect.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', expect.any(Function)); }); }); }); @@ -499,34 +476,31 @@ describe('AppComponent', () => { vm.$destroy(); }); - it('should render loading icon', done => { + it('should render loading icon', () => { vm.isLoading = true; - Vue.nextTick(() => { + return vm.$nextTick().then(() => { expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); expect(vm.$el.querySelector('span').getAttribute('aria-label')).toBe('Loading groups'); - done(); }); }); - it('should render groups tree', done => { + it('should render groups tree', () => { vm.store.state.groups = [mockParentGroupItem]; vm.isLoading = false; - Vue.nextTick(() => { + return vm.$nextTick().then(() => { expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); - done(); }); }); - it('renders modal confirmation dialog', done => { + it('renders modal confirmation dialog', () => { vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?'; vm.showModal = true; - Vue.nextTick(() => { + return vm.$nextTick().then(() => { const modalDialogEl = vm.$el.querySelector('.modal'); expect(modalDialogEl).not.toBe(null); expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); - done(); }); }); }); diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js index fdfd1b82bd8..4b545f05c58 100644 --- a/spec/javascripts/groups/components/group_folder_spec.js +++ b/spec/frontend/groups/components/group_folder_spec.js @@ -18,15 +18,13 @@ const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) describe('GroupFolderComponent', () => { let vm; - beforeEach(done => { + beforeEach(() => { Vue.component('group-item', groupItemComponent); vm = createComponent(); vm.$mount(); - Vue.nextTick(() => { - done(); - }); + return Vue.nextTick(); }); afterEach(() => { diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 2889d7ae4ff..d1f7653923a 100644 --- a/spec/javascripts/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -1,8 +1,9 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import groupItemComponent from '~/groups/components/group_item.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import eventHub from '~/groups/event_hub'; +import * as urlUtilities from '~/lib/utils/url_utility'; import { mockParentGroupItem, mockChildren } from '../mock_data'; const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { @@ -17,14 +18,12 @@ const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren describe('GroupItemComponent', () => { let vm; - beforeEach(done => { + beforeEach(() => { Vue.component('group-folder', groupFolderComponent); vm = createComponent(); - Vue.nextTick(() => { - done(); - }); + return Vue.nextTick(); }); afterEach(() => { @@ -130,26 +129,24 @@ describe('GroupItemComponent', () => { }); it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); vm.onClickRowGroup(event); expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); }); - it('should navigate page to group homepage if group does not have any children present', done => { + it('should navigate page to group homepage if group does not have any children present', () => { + jest.spyOn(urlUtilities, 'visitUrl').mockImplementation(); const group = Object.assign({}, mockParentGroupItem); group.childrenCount = 0; const newVm = createComponent(group); - const visitUrl = spyOnDependency(groupItemComponent, 'visitUrl').and.stub(); - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); newVm.onClickRowGroup(event); - setTimeout(() => { - expect(eventHub.$emit).not.toHaveBeenCalled(); - expect(visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); - done(); - }, 0); + + expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(urlUtilities.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); }); }); }); @@ -167,7 +164,7 @@ describe('GroupItemComponent', () => { const badgeEl = vm.$el.querySelector('.badge-warning'); expect(badgeEl).toBeDefined(); - expect(badgeEl).toContainText('pending removal'); + expect(badgeEl.innerHTML).toContain('pending removal'); }); }); @@ -180,7 +177,7 @@ describe('GroupItemComponent', () => { it('does not render the group pending removal badge', () => { const groupTextContainer = vm.$el.querySelector('.group-text-container'); - expect(groupTextContainer).not.toContainText('pending removal'); + expect(groupTextContainer).not.toContain('pending removal'); }); }); diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 8423467742e..6205400eb03 100644 --- a/spec/javascripts/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import groupsComponent from '~/groups/components/groups.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupItemComponent from '~/groups/components/group_item.vue'; @@ -21,15 +21,13 @@ const createComponent = (searchEmpty = false) => { describe('GroupsComponent', () => { let vm; - beforeEach(done => { + beforeEach(() => { Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); vm = createComponent(); - Vue.nextTick(() => { - done(); - }); + return vm.$nextTick(); }); afterEach(() => { @@ -39,37 +37,35 @@ describe('GroupsComponent', () => { describe('methods', () => { describe('change', () => { it('should emit `fetchPage` event when page is changed via pagination', () => { - spyOn(eventHub, '$emit').and.stub(); + jest.spyOn(eventHub, '$emit').mockImplementation(); vm.change(2); expect(eventHub.$emit).toHaveBeenCalledWith( 'fetchPage', 2, - jasmine.any(Object), - jasmine.any(Object), - jasmine.any(Object), + expect.any(Object), + expect.any(Object), + expect.any(Object), ); }); }); }); describe('template', () => { - it('should render component template correctly', done => { - Vue.nextTick(() => { + it('should render component template correctly', () => { + return vm.$nextTick().then(() => { expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0); - done(); }); }); - it('should render empty search message when `searchEmpty` is `true`', done => { + it('should render empty search message when `searchEmpty` is `true`', () => { vm.searchEmpty = true; - Vue.nextTick(() => { + return vm.$nextTick().then(() => { expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); - done(); }); }); }); diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js index 9a9d6208eac..2e0738bd1b4 100644 --- a/spec/javascripts/groups/components/item_actions_spec.js +++ b/spec/frontend/groups/components/item_actions_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import itemActionsComponent from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { mockParentGroupItem, mockChildren } from '../mock_data'; @@ -28,7 +28,7 @@ describe('ItemActionsComponent', () => { describe('methods', () => { describe('onLeaveGroup', () => { it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => { - spyOn(eventHub, '$emit'); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); vm.onLeaveGroup(); expect(eventHub.$emit).toHaveBeenCalledWith( diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js index 0eb56abbd61..bfe27be9b51 100644 --- a/spec/javascripts/groups/components/item_caret_spec.js +++ b/spec/frontend/groups/components/item_caret_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import itemCaretComponent from '~/groups/components/item_caret.vue'; const createComponent = (isGroupOpen = false) => { @@ -12,27 +12,27 @@ const createComponent = (isGroupOpen = false) => { }; describe('ItemCaretComponent', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + describe('template', () => { it('should render component template correctly', () => { - const vm = createComponent(); - + vm = createComponent(); expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); expect(vm.$el.querySelectorAll('svg').length).toBe(1); - vm.$destroy(); }); it('should render caret down icon if `isGroupOpen` prop is `true`', () => { - const vm = createComponent(true); - + vm = createComponent(true); expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down'); - vm.$destroy(); }); it('should render caret right icon if `isGroupOpen` prop is `false`', () => { - const vm = createComponent(); - + vm = createComponent(); expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right'); - vm.$destroy(); }); }); }); diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js index 13d17b87d76..fb4285a2b04 100644 --- a/spec/javascripts/groups/components/item_stats_spec.js +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import itemStatsComponent from '~/groups/components/item_stats.vue'; import { mockParentGroupItem, diff --git a/spec/javascripts/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js index ff4e781ce1a..9561a329887 100644 --- a/spec/javascripts/groups/components/item_stats_value_spec.js +++ b/spec/frontend/groups/components/item_stats_value_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import itemStatsValueComponent from '~/groups/components/item_stats_value.vue'; const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => { @@ -56,6 +56,10 @@ describe('ItemStatsValueComponent', () => { }); }); + afterEach(() => { + vm.$destroy(); + }); + it('renders component element correctly', () => { expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy(); expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0); @@ -74,9 +78,5 @@ describe('ItemStatsValueComponent', () => { it('renders value count correctly', () => { expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10'); }); - - afterEach(() => { - vm.$destroy(); - }); }); }); diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js index 321712e54a6..251b5b5ff4c 100644 --- a/spec/javascripts/groups/components/item_type_icon_spec.js +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; import { ITEM_TYPE } from '../mock_data'; @@ -17,7 +17,6 @@ describe('ItemTypeIconComponent', () => { describe('template', () => { it('should render component template correctly', () => { const vm = createComponent(); - vm.$mount(); expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); vm.$destroy(); @@ -27,13 +26,11 @@ describe('ItemTypeIconComponent', () => { let vm; vm = createComponent(ITEM_TYPE.GROUP, true); - vm.$mount(); expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open'); vm.$destroy(); vm = createComponent(ITEM_TYPE.GROUP); - vm.$mount(); expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder'); vm.$destroy(); @@ -43,13 +40,11 @@ describe('ItemTypeIconComponent', () => { let vm; vm = createComponent(ITEM_TYPE.PROJECT); - vm.$mount(); expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark'); vm.$destroy(); vm = createComponent(ITEM_TYPE.GROUP); - vm.$mount(); expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark'); vm.$destroy(); diff --git a/spec/javascripts/groups/mock_data.js b/spec/frontend/groups/mock_data.js index 380dda9f7b1..380dda9f7b1 100644 --- a/spec/javascripts/groups/mock_data.js +++ b/spec/frontend/groups/mock_data.js diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/frontend/groups/service/groups_service_spec.js index 45db962a1ef..38a565eba01 100644 --- a/spec/javascripts/groups/service/groups_service_spec.js +++ b/spec/frontend/groups/service/groups_service_spec.js @@ -12,7 +12,7 @@ describe('GroupsService', () => { describe('getGroups', () => { it('should return promise for `GET` request on provided endpoint', () => { - spyOn(axios, 'get').and.stub(); + jest.spyOn(axios, 'get').mockResolvedValue(); const params = { page: 2, filter: 'git', @@ -32,7 +32,7 @@ describe('GroupsService', () => { describe('leaveGroup', () => { it('should return promise for `DELETE` request on provided endpoint', () => { - spyOn(axios, 'delete').and.stub(); + jest.spyOn(axios, 'delete').mockResolvedValue(); service.leaveGroup(mockParentGroupItem.leavePath); diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/frontend/groups/store/groups_store_spec.js index 38de4b89f31..9eefcbe0275 100644 --- a/spec/javascripts/groups/store/groups_store_spec.js +++ b/spec/frontend/groups/store/groups_store_spec.js @@ -28,12 +28,12 @@ describe('ProjectsStore', () => { describe('setGroups', () => { it('should set groups to state', () => { const store = new GroupsStore(); - spyOn(store, 'formatGroupItem').and.callThrough(); + jest.spyOn(store, 'formatGroupItem'); store.setGroups(mockGroups); expect(store.state.groups.length).toBe(mockGroups.length); - expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); }); }); @@ -41,12 +41,12 @@ describe('ProjectsStore', () => { describe('setSearchedGroups', () => { it('should set searched groups to state', () => { const store = new GroupsStore(); - spyOn(store, 'formatGroupItem').and.callThrough(); + jest.spyOn(store, 'formatGroupItem'); store.setSearchedGroups(mockSearchedGroups); expect(store.state.groups.length).toBe(mockSearchedGroups.length); - expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); expect(Object.keys(store.state.groups[0]).indexOf('fullName')).toBeGreaterThan(-1); expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName')).toBeGreaterThan( -1, @@ -57,11 +57,11 @@ describe('ProjectsStore', () => { describe('setGroupChildren', () => { it('should set children to group item in state', () => { const store = new GroupsStore(); - spyOn(store, 'formatGroupItem').and.callThrough(); + jest.spyOn(store, 'formatGroupItem'); store.setGroupChildren(mockParentGroupItem, mockRawChildren); - expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(store.formatGroupItem).toHaveBeenCalledWith(expect.any(Object)); expect(mockParentGroupItem.children.length).toBe(1); expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName')).toBeGreaterThan(-1); expect(mockParentGroupItem.isOpen).toBeTruthy(); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index cb2193e1d9a..800a7e586a8 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -26,7 +26,7 @@ function factory(propsData = {}) { }, }); - vm.setData({ ref: 'master' }); + vm.setData({ escapedRef: 'master' }); } describe('Repository table row component', () => { diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index 8da2f39f71f..5637d0be957 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -53,7 +53,7 @@ describe('fetchLogsTree', () => { client = { readQuery: () => ({ projectPath: 'gitlab-org/gitlab-foss', - ref: 'master', + escapedRef: 'master', commits: [], }), writeQuery: jest.fn(), @@ -86,16 +86,18 @@ describe('fetchLogsTree', () => { it('calls entry resolver', () => fetchLogsTree(client, '', '0', resolver).then(() => { - expect(resolver.resolve).toHaveBeenCalledWith({ - __typename: 'LogTreeCommit', - commitPath: 'https://test.com', - committedDate: '2019-01-01', - fileName: 'index.js', - filePath: '/index.js', - message: 'testing message', - sha: '123', - type: 'blob', - }); + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ + __typename: 'LogTreeCommit', + commitPath: 'https://test.com', + committedDate: '2019-01-01', + fileName: 'index.js', + filePath: '/index.js', + message: 'testing message', + sha: '123', + type: 'blob', + }), + ); })); it('writes query to client', () => @@ -104,7 +106,7 @@ describe('fetchLogsTree', () => { query: expect.anything(), data: { commits: [ - { + expect.objectContaining({ __typename: 'LogTreeCommit', commitPath: 'https://test.com', committedDate: '2019-01-01', @@ -113,7 +115,7 @@ describe('fetchLogsTree', () => { message: 'testing message', sha: '123', type: 'blob', - }, + }), ], }, }); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index 6944b23558a..f2f3dda41d9 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -4,13 +4,12 @@ import createRouter from '~/repository/router'; describe('Repository router spec', () => { it.each` - path | branch | component | componentName - ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} - ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/feature/test-%23/app/assets'} | ${'feature/test-#'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} + path | branch | component | componentName + ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} + ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} `('sets component as $componentName for path "$path"', ({ path, component, branch }) => { const router = createRouter('', branch); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js index 76827cde093..cda5ca68d9b 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -1,24 +1,29 @@ -import { mount, createLocalVue } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; import { mockStore } from '../mock_data'; - -const localVue = createLocalVue(); +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; describe('MrWidgetPipelineContainer', () => { let wrapper; + let mock; const factory = (props = {}) => { - wrapper = mount(localVue.extend(MrWidgetPipelineContainer), { + wrapper = mount(MrWidgetPipelineContainer, { propsData: { mr: Object.assign({}, mockStore), ...props, }, - localVue, }); }; + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet().reply(200, {}); + }); + afterEach(() => { wrapper.destroy(); }); @@ -30,21 +35,19 @@ describe('MrWidgetPipelineContainer', () => { it('renders pipeline', () => { expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); - expect(wrapper.find(MrWidgetPipeline).props()).toEqual( - jasmine.objectContaining({ - pipeline: mockStore.pipeline, - pipelineCoverageDelta: mockStore.pipelineCoverageDelta, - ciStatus: mockStore.ciStatus, - hasCi: mockStore.hasCI, - sourceBranch: mockStore.sourceBranch, - sourceBranchLink: mockStore.sourceBranchLink, - }), - ); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.pipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.sourceBranch, + sourceBranchLink: mockStore.sourceBranchLink, + }); }); it('renders deployments', () => { const expectedProps = mockStore.deployments.map(dep => - jasmine.objectContaining({ + expect.objectContaining({ deployment: dep, showMetrics: false, }), @@ -65,21 +68,19 @@ describe('MrWidgetPipelineContainer', () => { it('renders pipeline', () => { expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); - expect(wrapper.find(MrWidgetPipeline).props()).toEqual( - jasmine.objectContaining({ - pipeline: mockStore.mergePipeline, - pipelineCoverageDelta: mockStore.pipelineCoverageDelta, - ciStatus: mockStore.ciStatus, - hasCi: mockStore.hasCI, - sourceBranch: mockStore.targetBranch, - sourceBranchLink: mockStore.targetBranch, - }), - ); + expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ + pipeline: mockStore.mergePipeline, + pipelineCoverageDelta: mockStore.pipelineCoverageDelta, + ciStatus: mockStore.ciStatus, + hasCi: mockStore.hasCI, + sourceBranch: mockStore.targetBranch, + sourceBranchLink: mockStore.targetBranch, + }); }); it('renders deployments', () => { const expectedProps = mockStore.postMergeDeployments.map(dep => - jasmine.objectContaining({ + expect.objectContaining({ deployment: dep, showMetrics: true, }), diff --git a/spec/lib/api/entities/branch_spec.rb b/spec/lib/api/entities/branch_spec.rb new file mode 100644 index 00000000000..604f56c0cb2 --- /dev/null +++ b/spec/lib/api/entities/branch_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Entities::Branch do + describe '#as_json' do + subject { entity.as_json } + + let(:project) { create(:project, :public, :repository) } + let(:repository) { project.repository } + let(:branch) { repository.find_branch('master') } + let(:entity) { described_class.new(branch, project: project) } + + it 'includes basic fields', :aggregate_failures do + is_expected.to include( + name: 'master', + commit: a_kind_of(Hash), + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: false, + default: true, + web_url: Gitlab::Routing.url_helpers.project_tree_url(project, 'master') + ) + end + end +end diff --git a/spec/lib/gitlab/alert_management/alert_params_spec.rb b/spec/lib/gitlab/alert_management/alert_params_spec.rb new file mode 100644 index 00000000000..8c60b502417 --- /dev/null +++ b/spec/lib/gitlab/alert_management/alert_params_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::AlertManagement::AlertParams do + let_it_be(:project) { create(:project, :repository, :private) } + + describe '.from_generic_alert' do + let(:started_at) { Time.current.change(usec: 0).rfc3339 } + let(:payload) do + { + 'title' => 'Alert title', + 'description' => 'Description', + 'monitoring_tool' => 'Monitoring tool name', + 'service' => 'Service', + 'hosts' => ['gitlab.com'], + 'start_time' => started_at, + 'some' => { 'extra' => { 'payload' => 'here' } } + } + end + + subject { described_class.from_generic_alert(project: project, payload: payload) } + + it 'returns Alert compatible parameters' do + is_expected.to eq( + project_id: project.id, + title: 'Alert title', + description: 'Description', + monitoring_tool: 'Monitoring tool name', + service: 'Service', + hosts: ['gitlab.com'], + payload: payload, + started_at: started_at + ) + end + + context 'when there are no hosts in the payload' do + let(:payload) { {} } + + it 'hosts param is an empty array' do + expect(subject[:hosts]).to be_empty + end + end + end +end diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb index f08ecd397ec..8315d2292a0 100644 --- a/spec/services/projects/alerting/notify_service_spec.rb +++ b/spec/services/projects/alerting/notify_service_spec.rb @@ -12,11 +12,16 @@ describe Projects::Alerting::NotifyService do shared_examples 'processes incident issues' do |amount| let(:create_incident_service) { spy } + let(:new_alert) { instance_double(AlertManagement::Alert, id: 503, persisted?: true) } it 'processes issues' do + expect(AlertManagement::Alert) + .to receive(:create) + .and_return(new_alert) + expect(IncidentManagement::ProcessAlertWorker) .to receive(:perform_async) - .with(project.id, kind_of(Hash)) + .with(project.id, kind_of(Hash), new_alert.id) .exactly(amount).times Sidekiq::Testing.inline! do @@ -59,6 +64,12 @@ describe Projects::Alerting::NotifyService do end end + shared_examples 'NotifyService does not create alert' do + it 'does not create alert' do + expect { subject }.not_to change(AlertManagement::Alert, :count) + end + end + describe '#execute' do let(:token) { 'invalid-token' } let(:starts_at) { Time.now.change(usec: 0) } @@ -88,6 +99,36 @@ describe Projects::Alerting::NotifyService do .and_return(incident_management_setting) end + context 'with valid payload' do + it 'creates AlertManagement::Alert' do + expect { subject }.to change(AlertManagement::Alert, :count).by(1) + end + + it 'created alert has all data properly assigned' do + subject + + alert = AlertManagement::Alert.last + alert_attributes = alert.attributes.except('id', 'iid', 'created_at', 'updated_at') + + expect(alert_attributes).to eq( + 'project_id' => project.id, + 'issue_id' => nil, + 'fingerprint' => nil, + 'title' => 'alert title', + 'description' => nil, + 'monitoring_tool' => nil, + 'service' => nil, + 'hosts' => [], + 'payload' => payload_raw, + 'severity' => 'critical', + 'status' => 'triggered', + 'events' => 1, + 'started_at' => alert.started_at, + 'ended_at' => nil + ) + end + end + it_behaves_like 'does not process incident issues' context 'issue enabled' do @@ -103,6 +144,7 @@ describe Projects::Alerting::NotifyService do end it_behaves_like 'does not process incident issues due to error', http_status: :bad_request + it_behaves_like 'NotifyService does not create alert' end end @@ -115,12 +157,14 @@ describe Projects::Alerting::NotifyService do context 'with invalid token' do it_behaves_like 'does not process incident issues due to error', http_status: :unauthorized + it_behaves_like 'NotifyService does not create alert' end context 'with deactivated Alerts Service' do let!(:alerts_service) { create(:alerts_service, :inactive, project: project) } it_behaves_like 'does not process incident issues due to error', http_status: :forbidden + it_behaves_like 'NotifyService does not create alert' end end end diff --git a/spec/workers/incident_management/process_alert_worker_spec.rb b/spec/workers/incident_management/process_alert_worker_spec.rb index 9f40833dfd7..2a0c12b010d 100644 --- a/spec/workers/incident_management/process_alert_worker_spec.rb +++ b/spec/workers/incident_management/process_alert_worker_spec.rb @@ -6,16 +6,24 @@ describe IncidentManagement::ProcessAlertWorker do let_it_be(:project) { create(:project) } describe '#perform' do - let(:alert) { :alert } - let(:create_issue_service) { spy(:create_issue_service) } + let(:alert_management_alert_id) { nil } + let(:alert_payload) { { alert: 'payload' } } + let(:new_issue) { create(:issue, project: project) } + let(:create_issue_service) { instance_double(IncidentManagement::CreateIssueService, execute: new_issue) } - subject { described_class.new.perform(project.id, alert) } + subject { described_class.new.perform(project.id, alert_payload, alert_management_alert_id) } + + before do + allow(IncidentManagement::CreateIssueService) + .to receive(:new).with(project, alert_payload) + .and_return(create_issue_service) + end it 'calls create issue service' do expect(Project).to receive(:find_by_id).and_call_original expect(IncidentManagement::CreateIssueService) - .to receive(:new).with(project, :alert) + .to receive(:new).with(project, alert_payload) .and_return(create_issue_service) expect(create_issue_service).to receive(:execute) @@ -26,7 +34,7 @@ describe IncidentManagement::ProcessAlertWorker do context 'with invalid project' do let(:invalid_project_id) { 0 } - subject { described_class.new.perform(invalid_project_id, alert) } + subject { described_class.new.perform(invalid_project_id, alert_payload) } it 'does not create issues' do expect(Project).to receive(:find_by_id).and_call_original @@ -35,5 +43,54 @@ describe IncidentManagement::ProcessAlertWorker do subject end end + + context 'when alert_management_alert_id is present' do + let!(:alert) { create(:alert_management_alert, project: project) } + let(:alert_management_alert_id) { alert.id } + + before do + allow(AlertManagement::Alert) + .to receive(:find_by_id) + .with(alert_management_alert_id) + .and_return(alert) + + allow(Gitlab::GitLogger).to receive(:warn).and_call_original + end + + context 'when alert can be updated' do + it 'updates AlertManagement::Alert#issue_id' do + expect { subject }.to change { alert.reload.issue_id }.to(new_issue.id) + end + + it 'does not write a warning to log' do + subject + + expect(Gitlab::GitLogger).not_to have_received(:warn) + end + end + + context 'when alert cannot be updated' do + before do + # invalidate alert + too_many_hosts = Array.new(AlertManagement::Alert::HOSTS_MAX_LENGTH + 1) { |_| 'host' } + alert.update_columns(hosts: too_many_hosts) + end + + it 'updates AlertManagement::Alert#issue_id' do + expect { subject }.not_to change { alert.reload.issue_id } + end + + it 'writes a worning to log' do + subject + + expect(Gitlab::GitLogger).to have_received(:warn).with( + message: 'Cannot link an Issue with Alert', + issue_id: new_issue.id, + alert_id: alert_management_alert_id, + alert_errors: { hosts: ['hosts array is over 255 chars'] } + ) + end + end + end end end |