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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.rubocop_todo/layout/line_length.yml1
-rw-r--r--.rubocop_todo/rails/skips_model_validations.yml1
-rw-r--r--.rubocop_todo/rspec/context_wording.yml1
-rw-r--r--.rubocop_todo/style/if_unless_modifier.yml1
-rw-r--r--.rubocop_todo/style/percent_literal_delimiters.yml1
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile.lock2
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue155
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue12
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue95
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue6
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue43
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue16
-rw-r--r--app/assets/javascripts/jobs/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue136
-rw-r--r--app/assets/javascripts/runner/components/runner_type_tabs.vue46
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_count.vue103
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue56
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue122
-rw-r--r--app/assets/stylesheets/pages/settings.scss3
-rw-r--r--app/controllers/projects/jobs_controller.rb5
-rw-r--r--app/controllers/projects/pipelines_controller.rb2
-rw-r--r--app/models/ci/legacy_stage.rb73
-rw-r--r--app/models/ci/pipeline.rb28
-rw-r--r--app/presenters/ci/legacy_stage_presenter.rb23
-rw-r--r--app/services/ci/generate_coverage_reports_service.rb2
-rw-r--r--app/services/ci/pipeline_artifacts/coverage_report_service.rb5
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml10
-rw-r--r--app/workers/ci/pipeline_artifacts/coverage_report_worker.rb12
-rw-r--r--config/feature_flags/development/job_log_search.yml (renamed from config/feature_flags/development/ci_child_pipeline_coverage_reports.yml)10
-rw-r--r--db/migrate/20220708100532_add_unique_index_on_ci_runner_versions_on_status_and_version.rb15
-rw-r--r--db/post_migrate/20220705114635_drop_index_on_ci_runner_versions_on_version.rb15
-rw-r--r--db/post_migrate/20220708100508_drop_index_on_ci_runner_versions_on_status.rb15
-rw-r--r--db/schema_migrations/202207051146351
-rw-r--r--db/schema_migrations/202207081005081
-rw-r--r--db/schema_migrations/202207081005321
-rw-r--r--db/structure.sql4
-rw-r--r--doc/ci/testing/test_coverage_visualization.md16
-rw-r--r--doc/user/clusters/agent/install/index.md5
-rw-r--r--lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata.rb2
-rw-r--r--lib/gitlab/ci/reports/coverage_report_generator.rb12
-rw-r--r--lib/gitlab/diff/rendered/notebook/diff_file.rb2
-rw-r--r--lib/gitlab/diff/rendered/notebook/diff_file_helper.rb2
-rw-r--r--lib/gitlab/event_store.rb1
-rw-r--r--lib/gitlab/json.rb2
-rw-r--r--locale/gitlab.pot32
-rw-r--r--qa/qa/flow/login.rb10
-rw-r--r--qa/qa/page/main/menu.rb10
-rw-r--r--qa/qa/resource/personal_access_token.rb2
-rw-r--r--spec/controllers/projects/settings/ci_cd_controller_spec.rb469
-rw-r--r--spec/factories/ci/stages.rb17
-rw-r--r--spec/features/projects/ci/secure_files_spec.rb4
-rw-r--r--spec/features/projects/jobs/user_browses_job_spec.rb23
-rw-r--r--spec/features/projects/settings/secure_files_settings_spec.rb46
-rw-r--r--spec/features/projects/settings/secure_files_spec.rb101
-rw-r--r--spec/frontend/ci_secure_files/components/secure_files_list_spec.js8
-rw-r--r--spec/frontend/jobs/components/job_log_controllers_spec.js61
-rw-r--r--spec/frontend/jobs/components/log/line_spec.js42
-rw-r--r--spec/frontend/jobs/mock_data.js164
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js181
-rw-r--r--spec/frontend/runner/components/runner_type_tabs_spec.js137
-rw-r--r--spec/frontend/runner/components/stat/runner_count_spec.js148
-rw-r--r--spec/frontend/runner/components/stat/runner_stats_spec.js61
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js175
-rw-r--r--spec/lib/gitlab/ci/reports/coverage_report_generator_spec.rb10
-rw-r--r--spec/lib/gitlab/ci/status/stage/common_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/status/stage/factory_spec.rb20
-rw-r--r--spec/lib/gitlab/diff/rendered/notebook/diff_file_helper_spec.rb6
-rw-r--r--spec/models/ci/legacy_stage_spec.rb268
-rw-r--r--spec/models/ci/pipeline_spec.rb106
-rw-r--r--spec/presenters/ci/legacy_stage_presenter_spec.rb47
-rw-r--r--spec/presenters/ci/stage_presenter_spec.rb2
-rw-r--r--spec/serializers/ci/dag_job_group_entity_spec.rb8
-rw-r--r--spec/serializers/ci/dag_stage_entity_spec.rb4
-rw-r--r--spec/serializers/stage_entity_spec.rb4
-rw-r--r--spec/services/ci/generate_coverage_reports_service_spec.rb14
-rw-r--r--spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb10
-rw-r--r--spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb14
-rw-r--r--spec/workers/pages/invalidate_domain_cache_worker_spec.rb45
-rw-r--r--vendor/gems/ipynbdiff/Gemfile.lock10
-rw-r--r--vendor/gems/ipynbdiff/ipynbdiff.gemspec3
-rw-r--r--vendor/gems/ipynbdiff/lib/ipynb_symbol_map.rb218
-rw-r--r--vendor/gems/ipynbdiff/lib/output_transformer.rb2
-rw-r--r--vendor/gems/ipynbdiff/lib/symbol_map.rb107
-rw-r--r--vendor/gems/ipynbdiff/lib/transformer.rb13
-rw-r--r--vendor/gems/ipynbdiff/spec/benchmark.rb64
-rw-r--r--vendor/gems/ipynbdiff/spec/ipynb_symbol_map_spec.rb165
-rw-r--r--vendor/gems/ipynbdiff/spec/symbol_map_spec.rb58
-rw-r--r--vendor/gems/ipynbdiff/spec/test_helper.rb23
-rw-r--r--vendor/gems/ipynbdiff/spec/testdata/from.ipynb3
-rw-r--r--vendor/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt14
-rw-r--r--vendor/gems/ipynbdiff/spec/transformer_spec.rb23
93 files changed, 2012 insertions, 2013 deletions
diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml
index 887f888567d..5b0c46e9d6e 100644
--- a/.rubocop_todo/layout/line_length.yml
+++ b/.rubocop_todo/layout/line_length.yml
@@ -5512,7 +5512,6 @@ Layout/LineLength:
- 'spec/presenters/alert_management/alert_presenter_spec.rb'
- 'spec/presenters/blob_presenter_spec.rb'
- 'spec/presenters/blobs/notebook_presenter_spec.rb'
- - 'spec/presenters/ci/legacy_stage_presenter_spec.rb'
- 'spec/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter_spec.rb'
- 'spec/presenters/ci/pipeline_presenter_spec.rb'
- 'spec/presenters/clusters/cluster_presenter_spec.rb'
diff --git a/.rubocop_todo/rails/skips_model_validations.yml b/.rubocop_todo/rails/skips_model_validations.yml
index af38961e597..3b13d010e0c 100644
--- a/.rubocop_todo/rails/skips_model_validations.yml
+++ b/.rubocop_todo/rails/skips_model_validations.yml
@@ -554,7 +554,6 @@ Rails/SkipsModelValidations:
- 'spec/models/ci/build_dependencies_spec.rb'
- 'spec/models/ci/build_spec.rb'
- 'spec/models/ci/group_spec.rb'
- - 'spec/models/ci/legacy_stage_spec.rb'
- 'spec/models/ci/pipeline_schedule_spec.rb'
- 'spec/models/ci/pipeline_spec.rb'
- 'spec/models/ci/processable_spec.rb'
diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml
index df12f49c0fe..bc0aa3a98d6 100644
--- a/.rubocop_todo/rspec/context_wording.yml
+++ b/.rubocop_todo/rspec/context_wording.yml
@@ -2620,7 +2620,6 @@ RSpec/ContextWording:
- 'spec/models/ci/deleted_object_spec.rb'
- 'spec/models/ci/job_artifact_spec.rb'
- 'spec/models/ci/job_token/project_scope_link_spec.rb'
- - 'spec/models/ci/legacy_stage_spec.rb'
- 'spec/models/ci/namespace_mirror_spec.rb'
- 'spec/models/ci/pending_build_spec.rb'
- 'spec/models/ci/pipeline_artifact_spec.rb'
diff --git a/.rubocop_todo/style/if_unless_modifier.yml b/.rubocop_todo/style/if_unless_modifier.yml
index a264be5abeb..72b0eed397b 100644
--- a/.rubocop_todo/style/if_unless_modifier.yml
+++ b/.rubocop_todo/style/if_unless_modifier.yml
@@ -153,7 +153,6 @@ Style/IfUnlessModifier:
- 'app/models/ci/build.rb'
- 'app/models/ci/build_trace_chunk.rb'
- 'app/models/ci/job_artifact.rb'
- - 'app/models/ci/legacy_stage.rb'
- 'app/models/ci/pipeline.rb'
- 'app/models/ci/runner.rb'
- 'app/models/ci/running_build.rb'
diff --git a/.rubocop_todo/style/percent_literal_delimiters.yml b/.rubocop_todo/style/percent_literal_delimiters.yml
index ff6bf1eb015..3cafeba93fd 100644
--- a/.rubocop_todo/style/percent_literal_delimiters.yml
+++ b/.rubocop_todo/style/percent_literal_delimiters.yml
@@ -79,7 +79,6 @@ Style/PercentLiteralDelimiters:
- 'app/models/bulk_imports/file_transfer/project_config.rb'
- 'app/models/ci/build.rb'
- 'app/models/ci/build_runner_session.rb'
- - 'app/models/ci/legacy_stage.rb'
- 'app/models/ci/pipeline.rb'
- 'app/models/clusters/applications/cert_manager.rb'
- 'app/models/clusters/platforms/kubernetes.rb'
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index b9f5af4b2b2..fb8d337ddee 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-a46121713a40b8c30794009eb4c40864a089e5a6
+43cb85d43809733551d9ad682987d89a2f4afb36
diff --git a/Gemfile.lock b/Gemfile.lock
index efd43a58e51..c0bedd9bb40 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -15,7 +15,7 @@ PATH
specs:
ipynbdiff (0.4.7)
diffy (~> 3.3)
- json (~> 2.5, >= 2.5.1)
+ oj (~> 3.13.16)
PATH
remote: vendor/gems/mail-smtp_pool
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
index 0fb8539a48a..9d8cb40b60a 100644
--- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -3,7 +3,6 @@ import {
GlAlert,
GlButton,
GlIcon,
- GlLink,
GlLoadingIcon,
GlModal,
GlModalDirective,
@@ -14,7 +13,6 @@ import {
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Api, { DEFAULT_PER_PAGE } from '~/api';
-import { helpPagePath } from '~/helpers/help_page_helper';
import httpStatusCodes from '~/lib/utils/http_status';
import { __, s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
@@ -25,7 +23,6 @@ export default {
GlAlert,
GlButton,
GlIcon,
- GlLink,
GlLoadingIcon,
GlModal,
GlPagination,
@@ -39,21 +36,16 @@ export default {
},
mixins: [Tracking.mixin()],
inject: ['projectId', 'admin', 'fileSizeLimit'],
- docsLink: helpPagePath('ci/secure_files/index'),
DEFAULT_PER_PAGE,
i18n: {
deleteLabel: __('Delete File'),
uploadLabel: __('Upload File'),
uploadingLabel: __('Uploading...'),
+ noFilesMessage: __('There are no secure files yet.'),
pagination: {
next: __('Next'),
prev: __('Prev'),
},
- title: __('Secure Files'),
- overviewMessage: __(
- 'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
- ),
- moreInformation: __('More information'),
uploadErrorMessages: {
duplicate: __('A file with this name already exists.'),
tooLarge: __('File too large. Secure Files must be less than %{limit} MB.'),
@@ -81,12 +73,12 @@ export default {
fields: [
{
key: 'name',
- label: __('Filename'),
+ label: __('File name'),
tdClass: 'gl-vertical-align-middle!',
},
{
key: 'created_at',
- label: __('Uploaded'),
+ label: __('Uploaded date'),
tdClass: 'gl-vertical-align-middle!',
},
{
@@ -163,7 +155,7 @@ export default {
}
return message;
},
- loadFileSelctor() {
+ loadFileSelector() {
this.$refs.fileUpload.click();
},
setDeleteModalData(secureFile) {
@@ -183,91 +175,74 @@ export default {
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
- {{ errorMessage }}
- </gl-alert>
- <div class="row">
- <div class="col-md-12 col-lg-6 gl-display-flex">
- <div class="gl-flex-direction-column gl-flex-wrap">
- <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-0">
- {{ $options.i18n.title }}
- </h1>
- </div>
- </div>
+ <div class="ci-secure-files-table">
+ <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
+ {{ errorMessage }}
+ </gl-alert>
+
+ <gl-table
+ :busy="loading"
+ :fields="fields"
+ :items="projectSecureFiles"
+ tbody-tr-class="js-ci-secure-files-row"
+ data-qa-selector="ci_secure_files_table_content"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="lg"
+ table-class="text-secondary"
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ :empty-text="$options.i18n.noFilesMessage"
+ >
+ <template #table-busy>
+ <gl-loading-icon size="lg" class="gl-my-5" />
+ </template>
+
+ <template #cell(name)="{ item }">
+ {{ item.name }}
+ </template>
+
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
- <div class="col-md-12 col-lg-6">
- <div class="gl-display-flex gl-flex-wrap gl-justify-content-end">
- <gl-button v-if="admin" class="gl-mt-3" variant="confirm" @click="loadFileSelctor">
- <span v-if="uploading">
- <gl-loading-icon size="sm" class="gl-my-5" inline />
- {{ $options.i18n.uploadingLabel }}
- </span>
- <span v-else>
- <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
- </span>
- </gl-button>
- <input
- id="file-upload"
- ref="fileUpload"
- type="file"
- class="hidden"
- data-qa-selector="file_upload_field"
- @change="uploadSecureFile"
+ <template #cell(actions)="{ item }">
+ <gl-button
+ v-if="admin"
+ v-gl-modal="$options.deleteModalId"
+ v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ :aria-label="$options.i18n.deleteLabel"
+ data-testid="delete-button"
+ @click="setDeleteModalData(item)"
/>
- </div>
- </div>
+ </template>
+ </gl-table>
</div>
- <div class="row">
- <div class="col-md-12 col-lg-12 gl-my-4">
- <span data-testid="info-message">
- {{ $options.i18n.overviewMessage }}
- <gl-link :href="$options.docsLink" target="_blank">{{
- $options.i18n.moreInformation
- }}</gl-link>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-button v-if="admin" variant="confirm" @click="loadFileSelector">
+ <span v-if="uploading">
+ <gl-loading-icon class="gl-my-5" inline />
+ {{ $options.i18n.uploadingLabel }}
</span>
- </div>
+ <span v-else>
+ <gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
+ </span>
+ </gl-button>
+ <input
+ id="file-upload"
+ ref="fileUpload"
+ type="file"
+ class="hidden"
+ data-qa-selector="file_upload_field"
+ @change="uploadSecureFile"
+ />
</div>
- <gl-table
- :busy="loading"
- :fields="fields"
- :items="projectSecureFiles"
- tbody-tr-class="js-ci-secure-files-row"
- data-qa-selector="ci_secure_files_table_content"
- sort-by="key"
- sort-direction="asc"
- stacked="lg"
- table-class="text-secondary"
- show-empty
- sort-icon-left
- no-sort-reset
- >
- <template #table-busy>
- <gl-loading-icon size="lg" class="gl-my-5" />
- </template>
-
- <template #cell(name)="{ item }">
- {{ item.name }}
- </template>
-
- <template #cell(created_at)="{ item }">
- <timeago-tooltip :time="item.created_at" />
- </template>
-
- <template #cell(actions)="{ item }">
- <gl-button
- v-if="admin"
- v-gl-modal="$options.deleteModalId"
- v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
- variant="danger"
- icon="remove"
- :aria-label="$options.i18n.deleteLabel"
- @click="setDeleteModalData(item)"
- />
- </template>
- </gl-table>
-
<gl-pagination
v-if="!loading"
v-model="page"
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 396b015ad83..f9e6c64aad1 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -68,6 +68,11 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ searchResults: [],
+ };
+ },
computed: {
...mapState([
'isLoading',
@@ -184,6 +189,9 @@ export default {
this.throttled();
},
+ setSearchResults(searchResults) {
+ this.searchResults = searchResults;
+ },
},
};
</script>
@@ -279,10 +287,12 @@ export default {
:is-scroll-top-disabled="isScrollTopDisabled"
:is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
+ :job-log="jobLog"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
+ @searchResults="setSearchResults"
/>
- <log :job-log="jobLog" :is-complete="isJobLogComplete" />
+ <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" />
</div>
<!-- EO job log -->
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index eb6a284dfaf..5e89dd5acc2 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,21 +1,34 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__, sprintf } from '~/locale';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
i18n: {
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
+ searchPlaceholder: s__('Job|Search job log'),
+ noResults: s__('Job|No search results found'),
+ searchPopoverTitle: s__('Job|Job log search'),
+ searchPopoverDescription: s__(
+ 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.',
+ ),
+ logLineNumberNotFound: s__('Job|We could not find this element'),
},
components: {
GlLink,
GlButton,
+ GlSearchBoxByClick,
+ HelpPopover,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
size: {
type: Number,
@@ -42,6 +55,16 @@ export default {
type: Boolean,
required: true,
},
+ jobLog: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ searchResults: [],
+ };
},
computed: {
jobLogSize() {
@@ -49,6 +72,9 @@ export default {
size: numberToHumanSize(this.size),
});
},
+ showJobLogSearch() {
+ return this.glFeatures.jobLogSearch;
+ },
},
methods: {
handleScrollToTop() {
@@ -57,6 +83,54 @@ export default {
handleScrollToBottom() {
this.$emit('scrollJobLogBottom');
},
+ searchJobLog() {
+ this.searchResults = [];
+
+ if (!this.searchTerm) return;
+
+ const compactedLog = [];
+
+ this.jobLog.forEach((obj) => {
+ if (obj.lines && obj.lines.length > 0) {
+ compactedLog.push(...obj.lines);
+ }
+
+ if (!obj.lines && obj.content.length > 0) {
+ compactedLog.push(obj);
+ }
+ });
+
+ compactedLog.forEach((line) => {
+ const lineText = line.content[0].text;
+
+ if (lineText.toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase())) {
+ this.searchResults.push(line);
+ }
+ });
+
+ if (this.searchResults.length > 0) {
+ this.$emit('searchResults', this.searchResults);
+
+ // BE returns zero based index, we need to add one to match the line numbers in the DOM
+ const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`;
+ const logLine = document.querySelector(`.js-line ${firstSearchResult}`);
+
+ if (logLine) {
+ setTimeout(() => scrollToElement(logLine));
+
+ const message = sprintf(s__('Job|%{searchLength} results found for %{searchTerm}'), {
+ searchLength: this.searchResults.length,
+ searchTerm: this.searchTerm,
+ });
+
+ this.$toast.show(message);
+ } else {
+ this.$toast.show(this.$options.i18n.logLineNumberNotFound);
+ }
+ } else {
+ this.$toast.show(this.$options.i18n.noResults);
+ }
+ },
},
};
</script>
@@ -81,6 +155,25 @@ export default {
<!-- eo truncate information -->
<div class="controllers gl-float-right">
+ <template v-if="showJobLogSearch">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ class="gl-mr-3"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-testid="job-log-search-box"
+ @clear="$emit('searchResults', [])"
+ @submit="searchJobLog"
+ />
+
+ <help-popover class="gl-mr-3">
+ <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
+
+ <p class="gl-mb-0">
+ {{ $options.i18n.searchPopoverDescription }}
+ </p>
+ </help-popover>
+ </template>
+
<!-- links -->
<gl-button
v-if="rawPath"
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index 2cd7c26ef74..13716b4d391 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -17,6 +17,11 @@ export default {
type: String,
required: true,
},
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
badgeDuration() {
@@ -45,6 +50,7 @@ export default {
:key="line.offset"
:line="line"
:path="jobLogEndpoint"
+ :search-results="searchResults"
/>
</template>
</div>
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index 2d9714cd06b..36b350f4d64 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -14,9 +14,14 @@ export default {
type: String,
required: true,
},
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
render(h, { props }) {
- const { line, path } = props;
+ const { line, path, searchResults } = props;
const chars = line.content.map((content) => {
return h(
@@ -46,15 +51,33 @@ export default {
);
});
- return h('div', { class: 'js-line log-line' }, [
- h(LineNumber, {
- props: {
- lineNumber: line.lineNumber,
- path,
- },
- }),
- ...chars,
- ]);
+ let applyHighlight = false;
+
+ if (searchResults.length > 0) {
+ const linesToHighlight = searchResults.map((searchResultLine) => searchResultLine.lineNumber);
+
+ linesToHighlight.forEach((num) => {
+ if (num === line.lineNumber) {
+ applyHighlight = true;
+ }
+ });
+ }
+
+ return h(
+ 'div',
+ {
+ class: ['js-line', 'log-line', applyHighlight ? 'gl-bg-gray-500' : ''],
+ },
+ [
+ h(LineNumber, {
+ props: {
+ lineNumber: line.lineNumber,
+ path,
+ },
+ }),
+ ...chars,
+ ],
+ );
},
};
</script>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index ef95d79b8ab..9647582b81d 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -8,6 +8,13 @@ export default {
CollapsibleLogSection,
LogLine,
},
+ props: {
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
computed: {
...mapState([
'jobLogEndpoint',
@@ -56,9 +63,16 @@ export default {
:key="`collapsible-${index}`"
:section="section"
:job-log-endpoint="jobLogEndpoint"
+ :search-results="searchResults"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" />
+ <log-line
+ v-else
+ :key="section.offset"
+ :line="section"
+ :path="jobLogEndpoint"
+ :search-results="searchResults"
+ />
</template>
<div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3">
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 8fb4c480ef9..5c63ad96ad0 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,7 +1,10 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import JobApp from './components/job_app.vue';
import createStore from './store';
+Vue.use(GlToast);
+
const initializeJobPage = (element) => {
const store = createStore();
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 43ab829f5f9..6a9bd34db22 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -9,6 +9,7 @@ import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initSettingsPanels from '~/settings_panels';
import { initTokenAccess } from '~/token_access';
+import { initCiSecureFiles } from '~/ci_secure_files';
// Initialize expandable settings panels
initSettingsPanels();
@@ -41,3 +42,4 @@ initSharedRunnersToggle();
initInstallRunner();
initRunnerAwsDeployments();
initTokenAccess();
+initCiSecureFiles();
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index a90ef2d3530..fb0035e906e 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,8 +1,7 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -20,18 +19,8 @@ import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
-import {
- ADMIN_FILTERED_SEARCH_NAMESPACE,
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
- I18N_FETCH_ERROR,
-} from '../constants';
+import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import runnersAdminQuery from '../graphql/list/admin_runners.query.graphql';
-import runnersAdminCountQuery from '../graphql/list/admin_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -40,54 +29,9 @@ import {
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
-const countSmartQuery = () => ({
- query: runnersAdminCountQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- update(data) {
- return data?.runners?.count;
- },
- error(error) {
- this.reportToSentry(error);
- },
-});
-
-const tabCountSmartQuery = ({ type }) => {
- return {
- ...countSmartQuery(),
- variables() {
- return {
- ...this.countVariables,
- type,
- };
- },
- };
-};
-
-const statusCountSmartQuery = ({ status, name }) => {
- return {
- ...countSmartQuery(),
- skip() {
- // skip if filtering by status and not using _this_ status as filter
- if (this.countVariables.status && this.countVariables.status !== status) {
- // reset count for given status
- this[name] = null;
- return true;
- }
- return false;
- },
- variables() {
- return {
- ...this.countVariables,
- status,
- };
- },
- };
-};
-
export default {
name: 'AdminRunnersApp',
components: {
- GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -137,31 +81,6 @@ export default {
this.reportToSentry(error);
},
},
-
- // Tabs counts
- allRunnersCount: {
- ...tabCountSmartQuery({ type: null }),
- },
- instanceRunnersCount: {
- ...tabCountSmartQuery({ type: INSTANCE_TYPE }),
- },
- groupRunnersCount: {
- ...tabCountSmartQuery({ type: GROUP_TYPE }),
- },
- projectRunnersCount: {
- ...tabCountSmartQuery({ type: PROJECT_TYPE }),
- },
-
- // Runner stats
- onlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }),
- },
- offlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }),
- },
- staleRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }),
- },
},
computed: {
variables() {
@@ -214,39 +133,10 @@ export default {
this.reportToSentry(error);
},
methods: {
- tabCount({ runnerType }) {
- let count;
- switch (runnerType) {
- case null:
- count = this.allRunnersCount;
- break;
- case INSTANCE_TYPE:
- count = this.instanceRunnersCount;
- break;
- case GROUP_TYPE:
- count = this.groupRunnersCount;
- break;
- case PROJECT_TYPE:
- count = this.projectRunnersCount;
- break;
- default:
- return null;
- }
- if (typeof count === 'number') {
- return formatNumber(count);
- }
- return '';
- },
- refetchFilteredCounts() {
- this.$apollo.queries.allRunnersCount.refetch();
- this.$apollo.queries.instanceRunnersCount.refetch();
- this.$apollo.queries.groupRunnersCount.refetch();
- this.$apollo.queries.projectRunnersCount.refetch();
- },
onToggledPaused() {
- // When a runner is Paused, the tab count can
+ // When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.refetchFilteredCounts();
+ this.$refs['runner-type-tabs'].refetch();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
@@ -271,18 +161,14 @@ export default {
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
<runner-type-tabs
+ ref="runner-type-tabs"
v-model="search"
+ :count-scope="$options.INSTANCE_TYPE"
+ :count-variables="countVariables"
class="gl-w-full"
content-class="gl-display-none"
nav-class="gl-border-none!"
- >
- <template #title="{ tab }">
- {{ tab.title }}
- <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
- {{ tabCount(tab) }}
- </gl-badge>
- </template>
- </runner-type-tabs>
+ />
<registration-dropdown
class="gl-w-full gl-sm-w-auto gl-mr-auto"
@@ -298,11 +184,7 @@ export default {
:namespace="$options.filteredSearchNamespace"
/>
- <runner-stats
- :online-runners-count="onlineRunnersTotal"
- :offline-runners-count="offlineRunnersTotal"
- :stale-runners-count="staleRunnersTotal"
- />
+ <runner-stats :scope="$options.INSTANCE_TYPE" :variables="countVariables" />
<runner-list-empty-state
v-if="noRunnersFound"
diff --git a/app/assets/javascripts/runner/components/runner_type_tabs.vue b/app/assets/javascripts/runner/components/runner_type_tabs.vue
index 25ed6600dc9..6b9e3bf91ad 100644
--- a/app/assets/javascripts/runner/components/runner_type_tabs.vue
+++ b/app/assets/javascripts/runner/components/runner_type_tabs.vue
@@ -1,6 +1,7 @@
<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
import { searchValidator } from '~/runner/runner_search_utils';
+import { formatNumber } from '~/locale';
import {
INSTANCE_TYPE,
GROUP_TYPE,
@@ -10,6 +11,7 @@ import {
I18N_GROUP_TYPE,
I18N_PROJECT_TYPE,
} from '../constants';
+import RunnerCount from './stat/runner_count.vue';
const I18N_TAB_TITLES = {
[INSTANCE_TYPE]: I18N_INSTANCE_TYPE,
@@ -17,10 +19,14 @@ const I18N_TAB_TITLES = {
[PROJECT_TYPE]: I18N_PROJECT_TYPE,
};
+const TAB_COUNT_REF = 'tab-count';
+
export default {
components: {
+ GlBadge,
GlTabs,
GlTab,
+ RunnerCount,
},
props: {
runnerTypes: {
@@ -33,6 +39,14 @@ export default {
required: true,
validator: searchValidator,
},
+ countScope: {
+ type: String,
+ required: true,
+ },
+ countVariables: {
+ type: Object,
+ required: true,
+ },
},
computed: {
tabs() {
@@ -62,7 +76,25 @@ export default {
isTabActive({ runnerType }) {
return runnerType === this.value.runnerType;
},
+ tabBadgeCountVariables(runnerType) {
+ return { ...this.countVariables, type: runnerType };
+ },
+ tabCount(count) {
+ if (typeof count === 'number') {
+ return formatNumber(count);
+ }
+ return '';
+ },
+
+ // Component API
+ refetch() {
+ // Refresh all of the counts here, can be called by parent component
+ this.$refs[TAB_COUNT_REF].forEach((countComponent) => {
+ countComponent.refetch();
+ });
+ },
},
+ TAB_COUNT_REF,
};
</script>
<template>
@@ -74,7 +106,17 @@ export default {
@click="onTabSelected(tab)"
>
<template #title>
- <slot name="title" :tab="tab">{{ tab.title }}</slot>
+ {{ tab.title }}
+ <runner-count
+ #default="{ count }"
+ :ref="$options.TAB_COUNT_REF"
+ :scope="countScope"
+ :variables="tabBadgeCountVariables(tab.runnerType)"
+ >
+ <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm">
+ {{ tabCount(count) }}
+ </gl-badge>
+ </runner-count>
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue
new file mode 100644
index 00000000000..c56d6e1d478
--- /dev/null
+++ b/app/assets/javascripts/runner/components/stat/runner_count.vue
@@ -0,0 +1,103 @@
+<script>
+import { fetchPolicies } from '~/lib/graphql';
+import { captureException } from '../../sentry_utils';
+import runnersAdminCountQuery from '../../graphql/list/admin_runners_count.query.graphql';
+import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql';
+import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
+
+/**
+ * Renderless component that wraps a "count" query for the
+ * number of runners that follow a filter criteria.
+ *
+ * Example usage:
+ *
+ * Render the count of "online" runners in the instance in a
+ * <strong/> tag.
+ *
+ * ```vue
+ * <runner-count-stat
+ * #default="{ count }"
+ * :scope="INSTANCE_TYPE"
+ * :variables="{ status: 'ONLINE' }"
+ * >
+ * <strong>{{ count }}</strong>
+ * </runner-count-stat>
+ * ```
+ *
+ * Use `:skip="true"` to prevent data from being fetched and
+ * even rendered.
+ */
+export default {
+ name: 'RunnerCount',
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ validator: (val) => [INSTANCE_TYPE, GROUP_TYPE].includes(val),
+ },
+ variables: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ skip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return { count: null };
+ },
+ apollo: {
+ count: {
+ query() {
+ if (this.scope === INSTANCE_TYPE) {
+ return runnersAdminCountQuery;
+ } else if (this.scope === GROUP_TYPE) {
+ return groupRunnersCountQuery;
+ }
+ return null;
+ },
+ fetchPolicy: fetchPolicies.NETWORK_ONLY,
+ variables() {
+ return this.variables;
+ },
+ skip() {
+ if (this.skip) {
+ // Don't show data for skipped stats
+ this.count = null;
+ }
+ return this.skip;
+ },
+ update(data) {
+ if (this.scope === INSTANCE_TYPE) {
+ return data?.runners?.count;
+ } else if (this.scope === GROUP_TYPE) {
+ return data?.group?.runners?.count;
+ }
+ return null;
+ },
+ error(error) {
+ this.reportToSentry(error);
+ },
+ },
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+
+ // Component API
+ refetch() {
+ // Parent components can use this method to refresh the count
+ this.$apollo.queries.count.refetch();
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({
+ count: this.count,
+ });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index d3693ee593e..9e1ca9ba4ee 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,49 +1,47 @@
<script>
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
+import RunnerCount from './runner_count.vue';
import RunnerStatusStat from './runner_status_stat.vue';
export default {
components: {
+ RunnerCount,
RunnerStatusStat,
},
props: {
- onlineRunnersCount: {
- type: Number,
- required: false,
- default: null,
+ scope: {
+ type: String,
+ required: true,
},
- offlineRunnersCount: {
- type: Number,
+ variables: {
+ type: Object,
required: false,
- default: null,
+ default: () => {},
},
- staleRunnersCount: {
- type: Number,
- required: false,
- default: null,
+ },
+ methods: {
+ countVariables(vars) {
+ return { ...this.variables, ...vars };
+ },
+ statusCountSkip(status) {
+ // Show an empty result when we already filter by another status
+ return this.variables.status && this.variables.status !== status;
},
},
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
+ STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE],
};
</script>
<template>
<div class="gl-display-flex gl-py-6">
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_ONLINE"
- :value="onlineRunnersCount"
- />
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_OFFLINE"
- :value="offlineRunnersCount"
- />
- <runner-status-stat
- class="gl-px-5"
- :status="$options.STATUS_STALE"
- :value="staleRunnersCount"
- />
+ <runner-count
+ v-for="status in $options.STATUS_LIST"
+ #default="{ count }"
+ :key="status"
+ :scope="scope"
+ :variables="countVariables({ status })"
+ :skip="statusCountSkip(status)"
+ >
+ <runner-status-stat class="gl-px-5" :status="status" :value="count" />
+ </runner-count>
</div>
</template>
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index 641b3a8f560..e8446dbe345 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,8 +1,7 @@
<script>
-import { GlBadge, GlLink } from '@gitlab/ui';
+import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
-import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
@@ -21,13 +20,9 @@ import {
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
PROJECT_TYPE,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_STALE,
I18N_FETCH_ERROR,
} from '../constants';
import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
-import groupRunnersCountQuery from '../graphql/list/group_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@@ -36,54 +31,9 @@ import {
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
-const countSmartQuery = () => ({
- query: groupRunnersCountQuery,
- fetchPolicy: fetchPolicies.NETWORK_ONLY,
- update(data) {
- return data?.group?.runners?.count;
- },
- error(error) {
- this.reportToSentry(error);
- },
-});
-
-const tabCountSmartQuery = ({ type }) => {
- return {
- ...countSmartQuery(),
- variables() {
- return {
- ...this.countVariables,
- type,
- };
- },
- };
-};
-
-const statusCountSmartQuery = ({ status, name }) => {
- return {
- ...countSmartQuery(),
- skip() {
- // skip if filtering by status and not using _this_ status as filter
- if (this.countVariables.status && this.countVariables.status !== status) {
- // reset count for given status
- this[name] = null;
- return true;
- }
- return false;
- },
- variables() {
- return {
- ...this.countVariables,
- status,
- };
- },
- };
-};
-
export default {
name: 'GroupRunnersApp',
components: {
- GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -153,28 +103,6 @@ export default {
this.reportToSentry(error);
},
},
-
- // Tabs counts
- allRunnersCount: {
- ...tabCountSmartQuery({ type: null }),
- },
- groupRunnersCount: {
- ...tabCountSmartQuery({ type: GROUP_TYPE }),
- },
- projectRunnersCount: {
- ...tabCountSmartQuery({ type: PROJECT_TYPE }),
- },
-
- // Runner status summary
- onlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_ONLINE, name: 'onlineRunnersTotal' }),
- },
- offlineRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_OFFLINE, name: 'offlineRunnersTotal' }),
- },
- staleRunnersTotal: {
- ...statusCountSmartQuery({ status: STATUS_STALE, name: 'staleRunnersTotal' }),
- },
},
computed: {
variables() {
@@ -221,41 +149,16 @@ export default {
this.reportToSentry(error);
},
methods: {
- tabCount({ runnerType }) {
- let count;
- switch (runnerType) {
- case null:
- count = this.allRunnersCount;
- break;
- case GROUP_TYPE:
- count = this.groupRunnersCount;
- break;
- case PROJECT_TYPE:
- count = this.projectRunnersCount;
- break;
- default:
- return null;
- }
- if (typeof count === 'number') {
- return formatNumber(count);
- }
- return null;
- },
webUrl(runner) {
return this.runners.urlsById[runner.id]?.web;
},
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
- refetchFilteredCounts() {
- this.$apollo.queries.allRunnersCount.refetch();
- this.$apollo.queries.groupRunnersCount.refetch();
- this.$apollo.queries.projectRunnersCount.refetch();
- },
onToggledPaused() {
- // When a runner is Paused, the tab count can
+ // When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.refetchFilteredCounts();
+ this.$refs['runner-type-tabs'].refetch();
},
onDeleted({ message }) {
this.$root.$toast?.show(message);
@@ -273,18 +176,15 @@ export default {
<div>
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
+ ref="runner-type-tabs"
v-model="search"
+ :count-scope="$options.GROUP_TYPE"
+ :count-variables="countVariables"
:runner-types="$options.TABS_RUNNER_TYPES"
+ class="gl-w-full"
content-class="gl-display-none"
nav-class="gl-border-none!"
- >
- <template #title="{ tab }">
- {{ tab.title }}
- <gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
- {{ tabCount(tab) }}
- </gl-badge>
- </template>
- </runner-type-tabs>
+ />
<registration-dropdown
class="gl-ml-auto"
@@ -300,11 +200,7 @@ export default {
:namespace="filteredSearchNamespace"
/>
- <runner-stats
- :online-runners-count="onlineRunnersTotal"
- :offline-runners-count="offlineRunnersTotal"
- :stale-runners-count="staleRunnersTotal"
- />
+ <runner-stats :scope="$options.GROUP_TYPE" :variables="countVariables" />
<runner-list-empty-state
v-if="noRunnersFound"
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index ef6fac8863e..935595d1b3b 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -315,7 +315,8 @@
}
.ci-variable-table,
-.deploy-freeze-table {
+.deploy-freeze-table,
+.ci-secure-files-table {
table {
thead {
border-bottom: 1px solid $white-normal;
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 1031bf3e60a..24685d26fc9 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -20,6 +20,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
before_action :push_jobs_table_vue, only: [:index]
before_action :push_jobs_table_vue_search, only: [:index]
+ before_action :push_job_log_search, only: [:show]
before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase]
layout 'project'
@@ -257,4 +258,8 @@ class Projects::JobsController < Projects::ApplicationController
def push_jobs_table_vue_search
push_frontend_feature_flag(:jobs_table_vue_search, @project)
end
+
+ def push_job_log_search
+ push_frontend_feature_flag(:job_log_search, @project)
+ end
end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 976241a5149..5fbd6c6a482 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -175,7 +175,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def stage
- @stage = pipeline.legacy_stage(params[:stage])
+ @stage = pipeline.stage(params[:stage])
return not_found unless @stage
render json: StageSerializer
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
deleted file mode 100644
index ffd3d3fcd88..00000000000
--- a/app/models/ci/legacy_stage.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- # Currently this is artificial object, constructed dynamically
- # We should migrate this object to actual database record in the future
- class LegacyStage
- include StaticModel
- include Presentable
-
- attr_reader :pipeline, :name
-
- delegate :project, to: :pipeline
-
- def initialize(pipeline, name:, status: nil, warnings: nil)
- @pipeline = pipeline
- @name = name
- @status = status
- # support ints and booleans
- @has_warnings = ActiveRecord::Type::Boolean.new.cast(warnings)
- end
-
- def groups
- @groups ||= Ci::Group.fabricate(project, self)
- end
-
- def to_param
- name
- end
-
- def statuses_count
- @statuses_count ||= statuses.count
- end
-
- def status
- @status ||= statuses.latest.composite_status(project: project)
- end
-
- def detailed_status(current_user)
- Gitlab::Ci::Status::Stage::Factory
- .new(self, current_user)
- .fabricate!
- end
-
- def latest_statuses
- statuses.ordered.latest
- end
-
- def statuses
- @statuses ||= pipeline.statuses.where(stage: name)
- end
-
- def builds
- @builds ||= pipeline.builds.where(stage: name)
- end
-
- def success?
- status.to_s == 'success'
- end
-
- def has_warnings?
- # lazilly calculate the warnings
- if @has_warnings.nil?
- @has_warnings = statuses.latest.failed_but_allowed.any?
- end
-
- @has_warnings
- end
-
- def manual_playable?
- %[manual scheduled skipped].include?(status.to_s)
- end
- end
-end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 1f93460eca1..2c5dc0c72b7 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -496,40 +496,16 @@ module Ci
.pluck(:stage, :stage_idx).map(&:first)
end
- def legacy_stage(name)
- stage = Ci::LegacyStage.new(self, name: name)
- stage unless stage.statuses_count == 0
- end
-
def ref_exists?
project.repository.ref_exists?(git_ref)
rescue Gitlab::Git::Repository::NoRepository
false
end
- def legacy_stages_using_composite_status
- stages = latest_statuses_ordered_by_stage.group_by(&:stage)
-
- stages.map do |stage_name, jobs|
- composite_status = Gitlab::Ci::Status::Composite
- .new(jobs)
-
- Ci::LegacyStage.new(self,
- name: stage_name,
- status: composite_status.status,
- warnings: composite_status.warnings?)
- end
- end
-
def triggered_pipelines_with_preloads
triggered_pipelines.preload(:source_job)
end
- # TODO: Remove usage of this method in templates
- def legacy_stages
- legacy_stages_using_composite_status
- end
-
def valid_commit_sha
if self.sha == Gitlab::Git::BLANK_SHA
self.errors.add(:sha, " cant be 00000000 (branch removal)")
@@ -1232,6 +1208,10 @@ module Ci
Gitlab::Utils.slugify(source_ref.to_s)
end
+ def stage(name)
+ stages.find_by(name: name)
+ end
+
def find_stage_by_name!(name)
stages.find_by!(name: name)
end
diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb
deleted file mode 100644
index c803abfab6a..00000000000
--- a/app/presenters/ci/legacy_stage_presenter.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class LegacyStagePresenter < Gitlab::View::Presenter::Delegated
- presents ::Ci::LegacyStage, as: :legacy_stage
-
- def latest_ordered_statuses
- preload_statuses(legacy_stage.statuses.latest_ordered)
- end
-
- def retried_ordered_statuses
- preload_statuses(legacy_stage.statuses.retried_ordered)
- end
-
- private
-
- def preload_statuses(statuses)
- Preloaders::CommitStatusPreloader.new(statuses).execute(Ci::StagePresenter::PRELOADED_RELATIONS)
-
- statuses
- end
- end
-end
diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb
index f8702b5536b..81f26e84ef8 100644
--- a/app/services/ci/generate_coverage_reports_service.rb
+++ b/app/services/ci/generate_coverage_reports_service.rb
@@ -36,8 +36,6 @@ module Ci
private
def key(base_pipeline, head_pipeline)
- return super unless Feature.enabled?(:ci_child_pipeline_coverage_reports, head_pipeline.project)
-
[
base_pipeline&.id, last_update_timestamp(base_pipeline),
head_pipeline&.id, last_update_timestamp(head_pipeline)
diff --git a/app/services/ci/pipeline_artifacts/coverage_report_service.rb b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
index c69cb721bb4..c11a8f7a0fd 100644
--- a/app/services/ci/pipeline_artifacts/coverage_report_service.rb
+++ b/app/services/ci/pipeline_artifacts/coverage_report_service.rb
@@ -9,11 +9,6 @@ module Ci
end
def execute
- unless Feature.enabled?(:ci_child_pipeline_coverage_reports, pipeline.project) ||
- !pipeline.has_coverage_reports?
- return
- end
-
return if report.empty?
Ci::PipelineArtifact.create_or_replace_for_pipeline!(**pipeline_artifact_params).tap do |pipeline_artifact|
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 5da3d2b891c..09f9ca60b3e 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -109,13 +109,15 @@
= render 'ci/token_access/index'
- if show_secure_files_setting(@project, current_user)
- %section.settings
+ %section.settings.no-animate#js-secure-files{ class: ('expanded' if expanded) }
.settings-header
- %h4.settings-title
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("Secure Files")
- = button_to project_ci_secure_files_path(@project), method: :get, class: 'btn gl-button btn-default' do
- = _('Manage')
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
+ = expanded ? _('Collapse') : _('Expand')
%p
= _("Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.")
= link_to _('Learn more'), help_page_path('ci/secure_files/index'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ #js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } }
diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
index 5be40f64930..127eb3b6f44 100644
--- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb
@@ -19,14 +19,10 @@ module Ci
return unless pipeline
- if Feature.enabled?(:ci_child_pipeline_coverage_reports, pipeline.project)
- pipeline.root_ancestor.try do |root_ancestor_pipeline|
- next unless root_ancestor_pipeline.self_and_descendants_complete?
-
- Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute
- end
- else
- Ci::PipelineArtifacts::CoverageReportService.new(pipeline).execute
+ pipeline.root_ancestor.try do |root_ancestor_pipeline|
+ next unless root_ancestor_pipeline.self_and_descendants_complete?
+
+ Ci::PipelineArtifacts::CoverageReportService.new(root_ancestor_pipeline).execute
end
end
end
diff --git a/config/feature_flags/development/ci_child_pipeline_coverage_reports.yml b/config/feature_flags/development/job_log_search.yml
index c77bd223eff..b6f1cec26f6 100644
--- a/config/feature_flags/development/ci_child_pipeline_coverage_reports.yml
+++ b/config/feature_flags/development/job_log_search.yml
@@ -1,8 +1,8 @@
---
-name: ci_child_pipeline_coverage_reports
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88626
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/363557
-milestone: '15.1'
+name: job_log_search
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91293
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/366455
+milestone: '15.2'
type: development
-group: group::pipeline insights
+group: group::pipeline execution
default_enabled: false
diff --git a/db/migrate/20220708100532_add_unique_index_on_ci_runner_versions_on_status_and_version.rb b/db/migrate/20220708100532_add_unique_index_on_ci_runner_versions_on_status_and_version.rb
new file mode 100644
index 00000000000..663614a321b
--- /dev/null
+++ b/db/migrate/20220708100532_add_unique_index_on_ci_runner_versions_on_status_and_version.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddUniqueIndexOnCiRunnerVersionsOnStatusAndVersion < Gitlab::Database::Migration[2.0]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_ci_runner_versions_on_unique_status_and_version'
+
+ def up
+ add_concurrent_index :ci_runner_versions, [:status, :version], name: INDEX_NAME, unique: true
+ end
+
+ def down
+ remove_concurrent_index_by_name :ci_runner_versions, INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20220705114635_drop_index_on_ci_runner_versions_on_version.rb b/db/post_migrate/20220705114635_drop_index_on_ci_runner_versions_on_version.rb
new file mode 100644
index 00000000000..22ff65f6fc3
--- /dev/null
+++ b/db/post_migrate/20220705114635_drop_index_on_ci_runner_versions_on_version.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DropIndexOnCiRunnerVersionsOnVersion < Gitlab::Database::Migration[2.0]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_ci_runner_versions_on_version'
+
+ def up
+ remove_concurrent_index_by_name :ci_runner_versions, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :ci_runner_versions, :version, name: INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20220708100508_drop_index_on_ci_runner_versions_on_status.rb b/db/post_migrate/20220708100508_drop_index_on_ci_runner_versions_on_status.rb
new file mode 100644
index 00000000000..71eb5a0867e
--- /dev/null
+++ b/db/post_migrate/20220708100508_drop_index_on_ci_runner_versions_on_status.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DropIndexOnCiRunnerVersionsOnStatus < Gitlab::Database::Migration[2.0]
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_ci_runner_versions_on_status'
+
+ def up
+ remove_concurrent_index_by_name :ci_runner_versions, INDEX_NAME
+ end
+
+ def down
+ add_concurrent_index :ci_runner_versions, :version, name: INDEX_NAME
+ end
+end
diff --git a/db/schema_migrations/20220705114635 b/db/schema_migrations/20220705114635
new file mode 100644
index 00000000000..1ab54b47282
--- /dev/null
+++ b/db/schema_migrations/20220705114635
@@ -0,0 +1 @@
+b9d37f6b3f59c4d2a08533fd1e2dc91403081fdf5691c86a1874079cb7937588 \ No newline at end of file
diff --git a/db/schema_migrations/20220708100508 b/db/schema_migrations/20220708100508
new file mode 100644
index 00000000000..73de59b95ab
--- /dev/null
+++ b/db/schema_migrations/20220708100508
@@ -0,0 +1 @@
+041c729542e7bf418ee805d6c1878aa62fd274a97583cc11dfebae9e7bdac896 \ No newline at end of file
diff --git a/db/schema_migrations/20220708100532 b/db/schema_migrations/20220708100532
new file mode 100644
index 00000000000..8f4f3876515
--- /dev/null
+++ b/db/schema_migrations/20220708100532
@@ -0,0 +1 @@
+28cf54895ada6e5d501bd5dcb9e7e161fd44ce51494b984dde7beadd0895c952 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index ba95d458fe0..d958a5b0e2b 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -27674,9 +27674,7 @@ CREATE UNIQUE INDEX index_ci_runner_namespaces_on_runner_id_and_namespace_id ON
CREATE INDEX index_ci_runner_projects_on_project_id ON ci_runner_projects USING btree (project_id);
-CREATE INDEX index_ci_runner_versions_on_status ON ci_runner_versions USING btree (status);
-
-CREATE INDEX index_ci_runner_versions_on_version ON ci_runner_versions USING btree (version);
+CREATE UNIQUE INDEX index_ci_runner_versions_on_unique_status_and_version ON ci_runner_versions USING btree (status, version);
CREATE INDEX index_ci_runners_on_active ON ci_runners USING btree (active, id);
diff --git a/doc/ci/testing/test_coverage_visualization.md b/doc/ci/testing/test_coverage_visualization.md
index 93a2b721d2f..472cfca99be 100644
--- a/doc/ci/testing/test_coverage_visualization.md
+++ b/doc/ci/testing/test_coverage_visualization.md
@@ -80,23 +80,17 @@ to draw the visualization on the merge request expires **one week** after creati
### Coverage report from child pipeline
-> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363301) in GitLab 15.1 [with a flag](../../administration/feature_flags.md). Disabled by default.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363301) in GitLab 15.1 [with a flag](../../administration/feature_flags.md) named `ci_child_pipeline_coverage_reports`. Disabled by default.
+> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/363557) and feature flag `ci_child_pipeline_coverage_reports` removed in GitLab 15.2.
-FLAG:
-On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `ci_child_pipeline_coverage_reports`.
-On GitLab.com, this feature is not available.
-The feature is not ready for production use.
-
-If the test coverage is created in jobs that are in a child pipeline, the parent pipeline must use
-`strategy: depend`.
+If a job in a child pipeline creates a coverage report, the report is included in
+the parent pipeline's coverage report.
```yaml
child_test_pipeline:
trigger:
include:
- - local: path/to/child_pipeline.yml
- - template: Security/SAST.gitlab-ci.yml
- strategy: depend
+ - local: path/to/child_pipeline_with_coverage.yml
```
### Automatic class path correction
diff --git a/doc/user/clusters/agent/install/index.md b/doc/user/clusters/agent/install/index.md
index 6c839f5ffc6..e039ae83693 100644
--- a/doc/user/clusters/agent/install/index.md
+++ b/doc/user/clusters/agent/install/index.md
@@ -154,8 +154,9 @@ GitLab also provides a [KPT package for the agent](https://gitlab.com/gitlab-org
To configure your agent, add content to the `config.yaml` file:
-- [View the configuration reference](../gitops.md#gitops-configuration-reference) for a GitOps workflow.
-- [View the configuration reference](../ci_cd_workflow.md) for a GitLab CI/CD workflow.
+- For a GitOps workflow, [view the configuration reference](../gitops.md#gitops-configuration-reference).
+- For a GitLab CI/CD workflow, [authorize the agent to access your projects](../ci_cd_workflow.md#authorize-the-agent). Then
+ [add `kubectl` commands to your `.gitlab-ci.yml` file](../ci_cd_workflow.md#update-your-gitlab-ciyml-file-to-run-kubectl-commands).
## Install multiple agents in your cluster
diff --git a/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata.rb b/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata.rb
index 8aad795b2e3..914ababa5c2 100644
--- a/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata.rb
+++ b/lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata.rb
@@ -68,7 +68,7 @@ module Gitlab
def valid_json?(metadata)
Oj.load(metadata)
true
- rescue Oj::ParseError, EncodingError, Json::ParseError, Encoding::UndefinedConversionError
+ rescue Oj::ParseError, EncodingError, JSON::ParserError, Encoding::UndefinedConversionError
false
end
diff --git a/lib/gitlab/ci/reports/coverage_report_generator.rb b/lib/gitlab/ci/reports/coverage_report_generator.rb
index fd73ed6fd25..76992a48b0a 100644
--- a/lib/gitlab/ci/reports/coverage_report_generator.rb
+++ b/lib/gitlab/ci/reports/coverage_report_generator.rb
@@ -35,17 +35,7 @@ module Gitlab
private
def report_builds
- if child_pipeline_feature_enabled?
- @pipeline.latest_report_builds_in_self_and_descendants(::Ci::JobArtifact.coverage_reports)
- else
- @pipeline.latest_report_builds(::Ci::JobArtifact.coverage_reports)
- end
- end
-
- def child_pipeline_feature_enabled?
- strong_memoize(:feature_enabled) do
- Feature.enabled?(:ci_child_pipeline_coverage_reports, @pipeline.project)
- end
+ @pipeline.latest_report_builds_in_self_and_descendants(::Ci::JobArtifact.coverage_reports)
end
end
end
diff --git a/lib/gitlab/diff/rendered/notebook/diff_file.rb b/lib/gitlab/diff/rendered/notebook/diff_file.rb
index 0a5b2ec3890..3e1652bd318 100644
--- a/lib/gitlab/diff/rendered/notebook/diff_file.rb
+++ b/lib/gitlab/diff/rendered/notebook/diff_file.rb
@@ -79,7 +79,7 @@ module Gitlab
rescue Timeout::Error => e
rendered_timeout.increment(source: Gitlab::Runtime.sidekiq? ? BACKGROUND_EXECUTION : FOREGROUND_EXECUTION)
log_event(LOG_IPYNBDIFF_TIMEOUT, e)
- rescue IpynbDiff::InvalidNotebookError, IpynbDiff::InvalidTokenError => e
+ rescue IpynbDiff::InvalidNotebookError => e
log_event(LOG_IPYNBDIFF_INVALID, e)
end
end
diff --git a/lib/gitlab/diff/rendered/notebook/diff_file_helper.rb b/lib/gitlab/diff/rendered/notebook/diff_file_helper.rb
index 2e1b5ea301d..f381792953e 100644
--- a/lib/gitlab/diff/rendered/notebook/diff_file_helper.rb
+++ b/lib/gitlab/diff/rendered/notebook/diff_file_helper.rb
@@ -91,7 +91,7 @@ module Gitlab
return 0 unless line_in_source.present?
- line_in_source + 1
+ line_in_source
end
def image_as_rich_text(line_text)
diff --git a/lib/gitlab/event_store.rb b/lib/gitlab/event_store.rb
index ee0e9e195a7..fd74aefc43c 100644
--- a/lib/gitlab/event_store.rb
+++ b/lib/gitlab/event_store.rb
@@ -38,6 +38,7 @@ module Gitlab
store.subscribe ::Pages::InvalidateDomainCacheWorker, to: ::Pages::PageDeployedEvent
store.subscribe ::Pages::InvalidateDomainCacheWorker, to: ::Pages::PageDeletedEvent
+ store.subscribe ::Pages::InvalidateDomainCacheWorker, to: ::Projects::ProjectDeletedEvent
end
private_class_method :configure!
end
diff --git a/lib/gitlab/json.rb b/lib/gitlab/json.rb
index 968b48b240f..ce07752f88c 100644
--- a/lib/gitlab/json.rb
+++ b/lib/gitlab/json.rb
@@ -95,7 +95,7 @@ module Gitlab
opts = standardize_opts(opts)
Oj.load(string, opts)
- rescue Oj::ParseError, EncodingError, JSON::ParseError, Encoding::UndefinedConversionError => ex
+ rescue Oj::ParseError, EncodingError, Encoding::UndefinedConversionError => ex
raise parser_error, ex
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3687df309cc..2e724f48e71 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -22355,6 +22355,9 @@ msgstr ""
msgid "Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}"
msgstr ""
+msgid "Job|%{searchLength} results found for %{searchTerm}"
+msgstr ""
+
msgid "Job|Are you sure you want to erase this job log and artifacts?"
msgstr ""
@@ -22394,12 +22397,18 @@ msgstr ""
msgid "Job|Job has been erased by %{userLink}"
msgstr ""
+msgid "Job|Job log search"
+msgstr ""
+
msgid "Job|Keep"
msgstr ""
msgid "Job|Manual"
msgstr ""
+msgid "Job|No search results found"
+msgstr ""
+
msgid "Job|Passed"
msgstr ""
@@ -22424,6 +22433,12 @@ msgstr ""
msgid "Job|Scroll to top"
msgstr ""
+msgid "Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size."
+msgstr ""
+
+msgid "Job|Search job log"
+msgstr ""
+
msgid "Job|Show complete raw"
msgstr ""
@@ -22460,6 +22475,9 @@ msgstr ""
msgid "Job|Waiting for resource"
msgstr ""
+msgid "Job|We could not find this element"
+msgstr ""
+
msgid "Job|allowed to fail"
msgstr ""
@@ -23643,9 +23661,6 @@ msgstr ""
msgid "Makes this issue confidential."
msgstr ""
-msgid "Manage"
-msgstr ""
-
msgid "Manage %{workspace} labels"
msgstr ""
@@ -39027,6 +39042,9 @@ msgstr ""
msgid "There are no projects shared with this group yet"
msgstr ""
+msgid "There are no secure files yet."
+msgstr ""
+
msgid "There are no topics to show."
msgstr ""
@@ -41383,7 +41401,7 @@ msgstr ""
msgid "Upload object map"
msgstr ""
-msgid "Uploaded"
+msgid "Uploaded date"
msgstr ""
msgid "Uploading changes to terminal"
@@ -44115,9 +44133,6 @@ msgstr ""
msgid "You can only add up to %{max_contacts} contacts at one time"
msgstr ""
-msgid "You can only approve an indivdual user, member, or all members"
-msgstr ""
-
msgid "You can only edit files when you are on a branch"
msgstr ""
@@ -44157,9 +44172,6 @@ msgstr ""
msgid "You cannot access the raw file. Please wait a minute."
msgstr ""
-msgid "You cannot approve all pending members on a free plan"
-msgstr ""
-
msgid "You cannot approve your own deployment."
msgstr ""
diff --git a/qa/qa/flow/login.rb b/qa/qa/flow/login.rb
index b60f74fe9bf..05f114acbc5 100644
--- a/qa/qa/flow/login.rb
+++ b/qa/qa/flow/login.rb
@@ -40,8 +40,14 @@ module QA
sign_in(as: Runtime::User.admin, address: address, admin: true)
end
- def sign_in_unless_signed_in(as: nil, address: :gitlab)
- sign_in(as: as, address: address) unless Page::Main::Menu.perform(&:signed_in?)
+ def sign_in_unless_signed_in(user: nil, address: :gitlab)
+ if user
+ sign_in(as: user, address: address) unless Page::Main::Menu.perform do |menu|
+ menu.signed_in_as_user?(user)
+ end
+ else
+ sign_in(address: address) unless Page::Main::Menu.perform(&:signed_in?)
+ end
end
end
end
diff --git a/qa/qa/page/main/menu.rb b/qa/qa/page/main/menu.rb
index 0bb74455f81..90b419f8cef 100644
--- a/qa/qa/page/main/menu.rb
+++ b/qa/qa/page/main/menu.rb
@@ -113,6 +113,14 @@ module QA
has_personal_area?(wait: 0)
end
+ def signed_in_as_user?(user)
+ return false if has_no_personal_area?
+
+ within_user_menu do
+ has_element?(:user_profile_link, text: /#{user.username}/)
+ end
+ end
+
def not_signed_in?
return true if Page::Main::Login.perform(&:on_login_page?)
@@ -202,7 +210,7 @@ module QA
def within_user_menu(&block)
within_top_menu do
- click_element :user_avatar
+ click_element :user_avatar unless has_element?(:user_profile_link, wait: 1)
within_element(:user_menu, &block)
end
diff --git a/qa/qa/resource/personal_access_token.rb b/qa/qa/resource/personal_access_token.rb
index b7766a3f6de..a12c210ea0e 100644
--- a/qa/qa/resource/personal_access_token.rb
+++ b/qa/qa/resource/personal_access_token.rb
@@ -61,7 +61,7 @@ module QA
end
def fabricate!
- Flow::Login.sign_in_unless_signed_in(as: user)
+ Flow::Login.sign_in_unless_signed_in(user: user)
Page::Main::Menu.perform(&:click_edit_profile_link)
Page::Profile::Menu.perform(&:click_access_tokens)
diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
index d50f1aa1dd8..e5ae1b04a86 100644
--- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb
+++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb
@@ -8,305 +8,294 @@ RSpec.describe Projects::Settings::CiCdController do
let(:project) { project_auto_devops.project }
- before do
- project.add_maintainer(user)
- sign_in(user)
- end
-
- describe 'GET show' do
- let_it_be(:parent_group) { create(:group) }
- let_it_be(:group) { create(:group, parent: parent_group) }
- let_it_be(:other_project) { create(:project, group: group) }
+ context 'as a maintainer' do
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+ end
- it 'renders show with 200 status code' do
- get :show, params: { namespace_id: project.namespace, project_id: project }
+ describe 'GET show' do
+ let_it_be(:parent_group) { create(:group) }
+ let_it_be(:group) { create(:group, parent: parent_group) }
+ let_it_be(:other_project) { create(:project, group: group) }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template(:show)
- end
+ it 'renders show with 200 status code' do
+ get :show, params: { namespace_id: project.namespace, project_id: project }
- context 'with CI/CD disabled' do
- before do
- project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template(:show)
end
- it 'renders show with 404 status code' do
- get :show, params: { namespace_id: project.namespace, project_id: project }
- expect(response).to have_gitlab_http_status(:not_found)
+ context 'with CI/CD disabled' do
+ before do
+ project.project_feature.update_attribute(:builds_access_level, ProjectFeature::DISABLED)
+ end
+
+ it 'renders show with 404 status code' do
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
end
- end
- context 'with group runners' do
- let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) }
- let_it_be(:project_runner) { create(:ci_runner, :project, projects: [other_project]) }
- let_it_be(:shared_runner) { create(:ci_runner, :instance) }
+ context 'with group runners' do
+ let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) }
+ let_it_be(:project_runner) { create(:ci_runner, :project, projects: [other_project]) }
+ let_it_be(:shared_runner) { create(:ci_runner, :instance) }
- it 'sets assignable project runners only' do
- group.add_maintainer(user)
+ it 'sets assignable project runners only' do
+ group.add_maintainer(user)
- get :show, params: { namespace_id: project.namespace, project_id: project }
+ get :show, params: { namespace_id: project.namespace, project_id: project }
- expect(assigns(:assignable_runners)).to contain_exactly(project_runner)
+ expect(assigns(:assignable_runners)).to contain_exactly(project_runner)
+ end
end
- end
- context 'prevents N+1 queries for tags' do
- render_views
+ context 'prevents N+1 queries for tags' do
+ render_views
- def show
- get :show, params: { namespace_id: project.namespace, project_id: project }
- end
+ def show
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
- it 'has the same number of queries with one tag or with many tags', :request_store do
- group.add_maintainer(user)
+ it 'has the same number of queries with one tag or with many tags', :request_store do
+ group.add_maintainer(user)
- show # warmup
+ show # warmup
- # with one tag
- create(:ci_runner, :instance, tag_list: %w(shared_runner))
- create(:ci_runner, :project, projects: [other_project], tag_list: %w(project_runner))
- create(:ci_runner, :group, groups: [group], tag_list: %w(group_runner))
- control = ActiveRecord::QueryRecorder.new { show }
+ # with one tag
+ create(:ci_runner, :instance, tag_list: %w(shared_runner))
+ create(:ci_runner, :project, projects: [other_project], tag_list: %w(project_runner))
+ create(:ci_runner, :group, groups: [group], tag_list: %w(group_runner))
+ control = ActiveRecord::QueryRecorder.new { show }
- # with several tags
- create(:ci_runner, :instance, tag_list: %w(shared_runner tag2 tag3))
- create(:ci_runner, :project, projects: [other_project], tag_list: %w(project_runner tag2 tag3))
- create(:ci_runner, :group, groups: [group], tag_list: %w(group_runner tag2 tag3))
+ # with several tags
+ create(:ci_runner, :instance, tag_list: %w(shared_runner tag2 tag3))
+ create(:ci_runner, :project, projects: [other_project], tag_list: %w(project_runner tag2 tag3))
+ create(:ci_runner, :group, groups: [group], tag_list: %w(group_runner tag2 tag3))
- expect { show }.not_to exceed_query_limit(control)
+ expect { show }.not_to exceed_query_limit(control)
+ end
end
end
- end
- describe '#reset_cache' do
- before do
- sign_in(user)
-
- project.add_maintainer(user)
+ describe '#reset_cache' do
+ before do
+ sign_in(user)
- allow(ResetProjectCacheService).to receive_message_chain(:new, :execute).and_return(true)
- end
+ project.add_maintainer(user)
- subject { post :reset_cache, params: { namespace_id: project.namespace, project_id: project }, format: :json }
+ allow(ResetProjectCacheService).to receive_message_chain(:new, :execute).and_return(true)
+ end
- it 'calls reset project cache service' do
- expect(ResetProjectCacheService).to receive_message_chain(:new, :execute)
+ subject { post :reset_cache, params: { namespace_id: project.namespace, project_id: project }, format: :json }
- subject
- end
+ it 'calls reset project cache service' do
+ expect(ResetProjectCacheService).to receive_message_chain(:new, :execute)
- context 'when service returns successfully' do
- it 'returns a success header' do
subject
-
- expect(response).to have_gitlab_http_status(:ok)
end
- end
- context 'when service does not return successfully' do
- before do
- allow(ResetProjectCacheService).to receive_message_chain(:new, :execute).and_return(false)
+ context 'when service returns successfully' do
+ it 'returns a success header' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
- it 'returns an error header' do
- subject
+ context 'when service does not return successfully' do
+ before do
+ allow(ResetProjectCacheService).to receive_message_chain(:new, :execute).and_return(false)
+ end
- expect(response).to have_gitlab_http_status(:bad_request)
+ it 'returns an error header' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
end
end
- end
- describe 'PUT #reset_registration_token' do
- subject { put :reset_registration_token, params: { namespace_id: project.namespace, project_id: project } }
+ describe 'PUT #reset_registration_token' do
+ subject { put :reset_registration_token, params: { namespace_id: project.namespace, project_id: project } }
- it 'resets runner registration token' do
- expect { subject }.to change { project.reload.runners_token }
- expect(flash[:toast]).to eq('New runners registration token has been generated!')
- end
+ it 'resets runner registration token' do
+ expect { subject }.to change { project.reload.runners_token }
+ expect(flash[:toast]).to eq('New runners registration token has been generated!')
+ end
- it 'redirects the user to admin runners page' do
- subject
+ it 'redirects the user to admin runners page' do
+ subject
- expect(response).to redirect_to(namespace_project_settings_ci_cd_path)
+ expect(response).to redirect_to(namespace_project_settings_ci_cd_path)
+ end
end
- end
- describe 'PATCH update' do
- let(:params) { { ci_config_path: '' } }
-
- subject do
- patch :update,
- params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- project: params
- }
- end
+ describe 'PATCH update' do
+ let(:params) { { ci_config_path: '' } }
- it 'redirects to the settings page' do
- subject
+ subject do
+ patch :update,
+ params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ project: params
+ }
+ end
- expect(response).to have_gitlab_http_status(:found)
- expect(flash[:toast]).to eq("Pipelines settings for '#{project.name}' were successfully updated.")
- end
+ it 'redirects to the settings page' do
+ subject
- context 'when updating the auto_devops settings' do
- let(:params) { { auto_devops_attributes: { enabled: '' } } }
+ expect(response).to have_gitlab_http_status(:found)
+ expect(flash[:toast]).to eq("Pipelines settings for '#{project.name}' were successfully updated.")
+ end
- context 'following the instance default' do
+ context 'when updating the auto_devops settings' do
let(:params) { { auto_devops_attributes: { enabled: '' } } }
- it 'allows enabled to be set to nil' do
- subject
- project_auto_devops.reload
+ context 'following the instance default' do
+ let(:params) { { auto_devops_attributes: { enabled: '' } } }
- expect(project_auto_devops.enabled).to be_nil
- end
- end
+ it 'allows enabled to be set to nil' do
+ subject
+ project_auto_devops.reload
- context 'when run_auto_devops_pipeline is true' do
- before do
- expect_next_instance_of(Projects::UpdateService) do |instance|
- expect(instance).to receive(:run_auto_devops_pipeline?).and_return(true)
+ expect(project_auto_devops.enabled).to be_nil
end
end
- context 'when the project repository is empty' do
- it 'sets a notice flash' do
- subject
-
- expect(controller).to set_flash[:notice]
+ context 'when run_auto_devops_pipeline is true' do
+ before do
+ expect_next_instance_of(Projects::UpdateService) do |instance|
+ expect(instance).to receive(:run_auto_devops_pipeline?).and_return(true)
+ end
end
- it 'does not queue a CreatePipelineWorker' do
- expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+ context 'when the project repository is empty' do
+ it 'sets a notice flash' do
+ subject
- subject
+ expect(controller).to set_flash[:notice]
+ end
+
+ it 'does not queue a CreatePipelineWorker' do
+ expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+
+ subject
+ end
end
- end
- context 'when the project repository is not empty' do
- let(:project) { create(:project, :repository) }
+ context 'when the project repository is not empty' do
+ let(:project) { create(:project, :repository) }
- it 'displays a toast message' do
- allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+ it 'displays a toast message' do
+ allow(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
- subject
+ subject
- expect(controller).to set_flash[:toast]
- end
+ expect(controller).to set_flash[:toast]
+ end
- it 'queues a CreatePipelineWorker' do
- expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
+ it 'queues a CreatePipelineWorker' do
+ expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args)
- subject
- end
+ subject
+ end
- it 'creates a pipeline', :sidekiq_inline do
- project.repository.create_file(user, 'Gemfile', 'Gemfile contents',
- message: 'Add Gemfile',
- branch_name: 'master')
+ it 'creates a pipeline', :sidekiq_inline do
+ project.repository.create_file(user, 'Gemfile', 'Gemfile contents',
+ message: 'Add Gemfile',
+ branch_name: 'master')
- expect { subject }.to change { Ci::Pipeline.count }.by(1)
+ expect { subject }.to change { Ci::Pipeline.count }.by(1)
+ end
end
end
- end
- context 'when run_auto_devops_pipeline is not true' do
- before do
- expect_next_instance_of(Projects::UpdateService) do |instance|
- expect(instance).to receive(:run_auto_devops_pipeline?).and_return(false)
+ context 'when run_auto_devops_pipeline is not true' do
+ before do
+ expect_next_instance_of(Projects::UpdateService) do |instance|
+ expect(instance).to receive(:run_auto_devops_pipeline?).and_return(false)
+ end
end
- end
- it 'does not queue a CreatePipelineWorker' do
- expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args)
+ it 'does not queue a CreatePipelineWorker' do
+ expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args)
- subject
+ subject
+ end
end
end
- end
- context 'when updating general settings' do
- context 'when build_timeout_human_readable is not specified' do
- let(:params) { { build_timeout_human_readable: '' } }
+ context 'when updating general settings' do
+ context 'when build_timeout_human_readable is not specified' do
+ let(:params) { { build_timeout_human_readable: '' } }
- it 'set default timeout' do
- subject
+ it 'set default timeout' do
+ subject
- project.reload
- expect(project.build_timeout).to eq(3600)
+ project.reload
+ expect(project.build_timeout).to eq(3600)
+ end
end
- end
- context 'when build_timeout_human_readable is specified' do
- let(:params) { { build_timeout_human_readable: '1h 30m' } }
+ context 'when build_timeout_human_readable is specified' do
+ let(:params) { { build_timeout_human_readable: '1h 30m' } }
- it 'set specified timeout' do
- subject
+ it 'set specified timeout' do
+ subject
- project.reload
- expect(project.build_timeout).to eq(5400)
+ project.reload
+ expect(project.build_timeout).to eq(5400)
+ end
end
- end
-
- context 'when build_timeout_human_readable is invalid' do
- let(:params) { { build_timeout_human_readable: '5m' } }
- it 'set specified timeout' do
- subject
-
- expect(controller).to set_flash[:alert]
- expect(response).to redirect_to(namespace_project_settings_ci_cd_path)
- end
- end
+ context 'when build_timeout_human_readable is invalid' do
+ let(:params) { { build_timeout_human_readable: '5m' } }
- context 'when default_git_depth is not specified' do
- let(:params) { { ci_cd_settings_attributes: { default_git_depth: 10 } } }
+ it 'set specified timeout' do
+ subject
- before do
- project.ci_cd_settings.update!(default_git_depth: nil)
+ expect(controller).to set_flash[:alert]
+ expect(response).to redirect_to(namespace_project_settings_ci_cd_path)
+ end
end
- it 'set specified git depth' do
- subject
+ context 'when default_git_depth is not specified' do
+ let(:params) { { ci_cd_settings_attributes: { default_git_depth: 10 } } }
- project.reload
- expect(project.ci_default_git_depth).to eq(10)
- end
- end
+ before do
+ project.ci_cd_settings.update!(default_git_depth: nil)
+ end
- context 'when forward_deployment_enabled is not specified' do
- let(:params) { { ci_cd_settings_attributes: { forward_deployment_enabled: false } } }
+ it 'set specified git depth' do
+ subject
- before do
- project.ci_cd_settings.update!(forward_deployment_enabled: nil)
+ project.reload
+ expect(project.ci_default_git_depth).to eq(10)
+ end
end
- it 'sets forward deployment enabled' do
- subject
-
- project.reload
- expect(project.ci_forward_deployment_enabled).to eq(false)
- end
- end
+ context 'when forward_deployment_enabled is not specified' do
+ let(:params) { { ci_cd_settings_attributes: { forward_deployment_enabled: false } } }
- context 'when max_artifacts_size is specified' do
- let(:params) { { max_artifacts_size: 10 } }
+ before do
+ project.ci_cd_settings.update!(forward_deployment_enabled: nil)
+ end
- context 'and user is not an admin' do
- it 'does not set max_artifacts_size' do
+ it 'sets forward deployment enabled' do
subject
project.reload
- expect(project.max_artifacts_size).to be_nil
+ expect(project.ci_forward_deployment_enabled).to eq(false)
end
end
- context 'and user is an admin' do
- let(:user) { create(:admin) }
+ context 'when max_artifacts_size is specified' do
+ let(:params) { { max_artifacts_size: 10 } }
- context 'with admin mode disabled' do
+ context 'and user is not an admin' do
it 'does not set max_artifacts_size' do
subject
@@ -315,33 +304,81 @@ RSpec.describe Projects::Settings::CiCdController do
end
end
- context 'with admin mode enabled', :enable_admin_mode do
- it 'sets max_artifacts_size' do
- subject
+ context 'and user is an admin' do
+ let(:user) { create(:admin) }
- project.reload
- expect(project.max_artifacts_size).to eq(10)
+ context 'with admin mode disabled' do
+ it 'does not set max_artifacts_size' do
+ subject
+
+ project.reload
+ expect(project.max_artifacts_size).to be_nil
+ end
+ end
+
+ context 'with admin mode enabled', :enable_admin_mode do
+ it 'sets max_artifacts_size' do
+ subject
+
+ project.reload
+ expect(project.max_artifacts_size).to eq(10)
+ end
end
end
end
end
end
+
+ describe 'GET #runner_setup_scripts' do
+ it 'renders the setup scripts' do
+ get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64', namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to have_key("install")
+ expect(json_response).to have_key("register")
+ end
+
+ it 'renders errors if they occur' do
+ get :runner_setup_scripts, params: { os: 'foo', arch: 'bar', namespace_id: project.namespace, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to have_key("errors")
+ end
+ end
end
- describe 'GET #runner_setup_scripts' do
- it 'renders the setup scripts' do
- get :runner_setup_scripts, params: { os: 'linux', arch: 'amd64', namespace_id: project.namespace, project_id: project }
+ context 'as a developer' do
+ before do
+ sign_in(user)
+ project.add_developer(user)
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to have_key("install")
- expect(json_response).to have_key("register")
+ it 'responds with 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
- it 'renders errors if they occur' do
- get :runner_setup_scripts, params: { os: 'foo', arch: 'bar', namespace_id: project.namespace, project_id: project }
+ context 'as a reporter' do
+ before do
+ sign_in(user)
+ project.add_reporter(user)
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to have_key("errors")
+ it 'responds with 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'as an unauthenticated user' do
+ before do
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
+
+ it 'redirects to sign in' do
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response).to redirect_to('/users/sign_in')
end
end
end
diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb
index 4751c04584e..65fca3fc314 100644
--- a/spec/factories/ci/stages.rb
+++ b/spec/factories/ci/stages.rb
@@ -1,23 +1,6 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :ci_stage, class: 'Ci::LegacyStage' do
- skip_create
-
- transient do
- name { 'test' }
- status { nil }
- warnings { nil }
- pipeline factory: :ci_empty_pipeline
- end
-
- initialize_with do
- Ci::LegacyStage.new(pipeline, name: name,
- status: status,
- warnings: warnings)
- end
- end
-
factory :ci_stage_entity, class: 'Ci::Stage' do
project factory: :project
pipeline factory: :ci_empty_pipeline
diff --git a/spec/features/projects/ci/secure_files_spec.rb b/spec/features/projects/ci/secure_files_spec.rb
index a0e9d663d35..412330eb5d6 100644
--- a/spec/features/projects/ci/secure_files_spec.rb
+++ b/spec/features/projects/ci/secure_files_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe 'Secure Files', :js do
it 'user sees the Secure Files list component' do
visit project_ci_secure_files_path(project)
- expect(page).to have_content('There are no records to show')
+ expect(page).to have_content('There are no secure files yet.')
end
it 'prompts the user to confirm before deleting a file' do
@@ -37,7 +37,7 @@ RSpec.describe 'Secure Files', :js do
it 'displays an uploaded file in the file list' do
visit project_ci_secure_files_path(project)
- expect(page).to have_content('There are no records to show')
+ expect(page).to have_content('There are no secure files yet.')
page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
click_button 'Upload File'
diff --git a/spec/features/projects/jobs/user_browses_job_spec.rb b/spec/features/projects/jobs/user_browses_job_spec.rb
index 6a2d2c36521..6a0cfcde812 100644
--- a/spec/features/projects/jobs/user_browses_job_spec.rb
+++ b/spec/features/projects/jobs/user_browses_job_spec.rb
@@ -90,4 +90,27 @@ RSpec.describe 'User browses a job', :js do
end
end
end
+
+ context 'job log search' do
+ before do
+ visit(project_job_path(project, build))
+ wait_for_all_requests
+ end
+
+ it 'searches for supplied substring' do
+ find('[data-testid="job-log-search-box"] input').set('GroupsHelper')
+
+ find('[data-testid="search-button"]').click
+
+ expect(page).to have_content('26 results found for GroupsHelper')
+ end
+
+ it 'shows no results for supplied substring' do
+ find('[data-testid="job-log-search-box"] input').set('YouWontFindMe')
+
+ find('[data-testid="search-button"]').click
+
+ expect(page).to have_content('No search results found')
+ end
+ end
end
diff --git a/spec/features/projects/settings/secure_files_settings_spec.rb b/spec/features/projects/settings/secure_files_settings_spec.rb
deleted file mode 100644
index c7c9cafc420..00000000000
--- a/spec/features/projects/settings/secure_files_settings_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Secure Files Settings' do
- let_it_be(:maintainer) { create(:user) }
- let_it_be(:project) { create(:project, creator_id: maintainer.id) }
-
- before_all do
- project.add_maintainer(maintainer)
- end
-
- context 'when the :ci_secure_files feature flag is enabled' do
- before do
- stub_feature_flags(ci_secure_files: true)
-
- sign_in(user)
- visit project_settings_ci_cd_path(project)
- end
-
- context 'authenticated user with admin permissions' do
- let(:user) { maintainer }
-
- it 'shows the secure files settings' do
- expect(page).to have_content('Secure Files')
- end
- end
- end
-
- context 'when the :ci_secure_files feature flag is disabled' do
- before do
- stub_feature_flags(ci_secure_files: false)
-
- sign_in(user)
- visit project_settings_ci_cd_path(project)
- end
-
- context 'authenticated user with admin permissions' do
- let(:user) { maintainer }
-
- it 'does not shows the secure files settings' do
- expect(page).not_to have_content('Secure Files')
- end
- end
- end
-end
diff --git a/spec/features/projects/settings/secure_files_spec.rb b/spec/features/projects/settings/secure_files_spec.rb
new file mode 100644
index 00000000000..ee38acf1953
--- /dev/null
+++ b/spec/features/projects/settings/secure_files_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Secure Files', :js do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+
+ before do
+ stub_feature_flags(ci_secure_files_read_only: false)
+ project.add_maintainer(user)
+ sign_in(user)
+ end
+
+ context 'when the :ci_secure_files feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_secure_files: true)
+
+ visit project_settings_ci_cd_path(project)
+ end
+
+ context 'authenticated user with admin permissions' do
+ it 'shows the secure files settings' do
+ expect(page).to have_content('Secure Files')
+ end
+ end
+ end
+
+ context 'when the :ci_secure_files feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_secure_files: false)
+
+ visit project_settings_ci_cd_path(project)
+ end
+
+ context 'authenticated user with admin permissions' do
+ it 'does not shows the secure files settings' do
+ expect(page).not_to have_content('Secure Files')
+ end
+ end
+ end
+
+ it 'user sees the Secure Files list component' do
+ visit project_settings_ci_cd_path(project)
+
+ within '#js-secure-files' do
+ expect(page).to have_content('There are no secure files yet.')
+ end
+ end
+
+ it 'prompts the user to confirm before deleting a file' do
+ file = create(:ci_secure_file, project: project)
+
+ visit project_settings_ci_cd_path(project)
+
+ within '#js-secure-files' do
+ expect(page).to have_content(file.name)
+
+ find('button.btn-danger-secondary').click
+ end
+
+ expect(page).to have_content("Delete #{file.name}?")
+
+ click_on('Delete secure file')
+
+ visit project_settings_ci_cd_path(project)
+
+ within '#js-secure-files' do
+ expect(page).not_to have_content(file.name)
+ end
+ end
+
+ it 'displays an uploaded file in the file list' do
+ visit project_settings_ci_cd_path(project)
+
+ within '#js-secure-files' do
+ expect(page).to have_content('There are no secure files yet.')
+
+ page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
+ click_button 'Upload File'
+ end
+
+ expect(page).to have_content('upload-keystore.jks')
+ end
+ end
+
+ it 'displays an error when a duplicate file upload is attempted' do
+ create(:ci_secure_file, project: project, name: 'upload-keystore.jks')
+ visit project_settings_ci_cd_path(project)
+
+ within '#js-secure-files' do
+ expect(page).to have_content('upload-keystore.jks')
+
+ page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
+ click_button 'Upload File'
+ end
+
+ expect(page).to have_content('A file with this name already exists.')
+ end
+ end
+end
diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
index 6fd041e1332..04d38a3281a 100644
--- a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
+++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
@@ -59,7 +59,7 @@ describe('SecureFilesList', () => {
const findUploadButton = () => wrapper.findAll('span.gl-button-text');
const findDeleteModal = () => wrapper.findComponent(GlModal);
const findUploadInput = () => wrapper.findAll('input[type="file"]').at(0);
- const findDeleteButton = () => wrapper.findAll('tbody tr td button.btn-danger');
+ const findDeleteButton = () => wrapper.findAll('[data-testid="delete-button"]');
describe('when secure files exist in a project', () => {
beforeEach(async () => {
@@ -71,7 +71,7 @@ describe('SecureFilesList', () => {
});
it('displays a table with expected headers', () => {
- const headers = ['Filename', 'Uploaded'];
+ const headers = ['File name', 'Uploaded date'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
@@ -121,14 +121,14 @@ describe('SecureFilesList', () => {
});
it('displays a table with expected headers', () => {
- const headers = ['Filename', 'Uploaded'];
+ const headers = ['File name', 'Uploaded date'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with a no records message', () => {
- expect(findCell(0, 0).text()).toBe('There are no records to show');
+ expect(findCell(0, 0).text()).toBe('There are no secure files yet.');
});
});
diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js
index cd3ee734466..cc97d111c06 100644
--- a/spec/frontend/jobs/components/job_log_controllers_spec.js
+++ b/spec/frontend/jobs/components/job_log_controllers_spec.js
@@ -1,6 +1,11 @@
+import { GlSearchBoxByClick } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import JobLogControllers from '~/jobs/components/job_log_controllers.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { mockJobLog } from '../mock_data';
+
+const mockToastShow = jest.fn();
describe('Job log controllers', () => {
let wrapper;
@@ -19,14 +24,30 @@ describe('Job log controllers', () => {
isScrollBottomDisabled: false,
isScrollingDown: true,
isJobLogSizeVisible: true,
+ jobLog: mockJobLog,
};
- const createWrapper = (props) => {
+ const createWrapper = (props, jobLogSearch = false) => {
wrapper = mount(JobLogControllers, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ glFeatures: {
+ jobLogSearch,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '82',
+ };
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
});
};
@@ -35,6 +56,8 @@ describe('Job log controllers', () => {
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]');
const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
+ const findJobLogSearch = () => wrapper.findComponent(GlSearchBoxByClick);
+ const findSearchHelp = () => wrapper.findComponent(HelpPopover);
describe('Truncate information', () => {
describe('with isJobLogSizeVisible', () => {
@@ -179,4 +202,40 @@ describe('Job log controllers', () => {
});
});
});
+
+ describe('Job log search', () => {
+ describe('with feature flag off', () => {
+ it('does not display job log search', () => {
+ createWrapper();
+
+ expect(findJobLogSearch().exists()).toBe(false);
+ expect(findSearchHelp().exists()).toBe(false);
+ });
+ });
+
+ describe('with feature flag on', () => {
+ beforeEach(() => {
+ createWrapper({}, { jobLogSearch: true });
+ });
+
+ it('displays job log search', () => {
+ expect(findJobLogSearch().exists()).toBe(true);
+ expect(findSearchHelp().exists()).toBe(true);
+ });
+
+ it('emits search results', () => {
+ const expectedSearchResults = [[[mockJobLog[6].lines[1], mockJobLog[6].lines[2]]]];
+
+ findJobLogSearch().vm.$emit('submit');
+
+ expect(wrapper.emitted('searchResults')).toEqual(expectedSearchResults);
+ });
+
+ it('clears search results', () => {
+ findJobLogSearch().vm.$emit('clear');
+
+ expect(wrapper.emitted('searchResults')).toEqual([[[]]]);
+ });
+ });
+ });
});
diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js
index d184696cd1f..bf80d90e299 100644
--- a/spec/frontend/jobs/components/log/line_spec.js
+++ b/spec/frontend/jobs/components/log/line_spec.js
@@ -179,4 +179,46 @@ describe('Job Log Line', () => {
expect(findLink().exists()).toBe(false);
});
});
+
+ describe('job log search', () => {
+ const mockSearchResults = [
+ {
+ offset: 1533,
+ content: [{ text: '$ echo "82.71"', style: 'term-fg-l-green term-bold' }],
+ section: 'step-script',
+ lineNumber: 20,
+ },
+ { offset: 1560, content: [{ text: '82.71' }], section: 'step-script', lineNumber: 21 },
+ ];
+
+ it('applies highlight class to search result elements', () => {
+ createComponent({
+ line: {
+ offset: 1560,
+ content: [{ text: '82.71' }],
+ section: 'step-script',
+ lineNumber: 21,
+ },
+ path: '/root/ci-project/-/jobs/1089',
+ searchResults: mockSearchResults,
+ });
+
+ expect(wrapper.classes()).toContain('gl-bg-gray-500');
+ });
+
+ it('does not apply highlight class to search result elements', () => {
+ createComponent({
+ line: {
+ offset: 1560,
+ content: [{ text: 'docker' }],
+ section: 'step-script',
+ lineNumber: 29,
+ },
+ path: '/root/ci-project/-/jobs/1089',
+ searchResults: mockSearchResults,
+ });
+
+ expect(wrapper.classes()).not.toContain('gl-bg-gray-500');
+ });
+ });
});
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 4d7ea6a46bd..bf238b2e39a 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1414,3 +1414,167 @@ export const unscheduleMutationResponse = {
},
},
};
+
+export const mockJobLog = [
+ { offset: 0, content: [{ text: 'Running with gitlab-runner 15.0.0 (febb2a09)' }], lineNumber: 0 },
+ { offset: 54, content: [{ text: ' on colima-docker EwM9WzgD' }], lineNumber: 1 },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 91,
+ content: [{ text: 'Resolving secrets', style: 'term-fg-l-cyan term-bold' }],
+ section: 'resolve-secrets',
+ section_header: true,
+ lineNumber: 2,
+ section_duration: '00:00',
+ },
+ lines: [],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 218,
+ content: [{ text: 'Preparing the "docker" executor', style: 'term-fg-l-cyan term-bold' }],
+ section: 'prepare-executor',
+ section_header: true,
+ lineNumber: 4,
+ section_duration: '00:01',
+ },
+ lines: [
+ {
+ offset: 317,
+ content: [{ text: 'Using Docker executor with image ruby:2.7 ...' }],
+ section: 'prepare-executor',
+ lineNumber: 5,
+ },
+ {
+ offset: 372,
+ content: [{ text: 'Pulling docker image ruby:2.7 ...' }],
+ section: 'prepare-executor',
+ lineNumber: 6,
+ },
+ {
+ offset: 415,
+ content: [
+ {
+ text:
+ 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...',
+ },
+ ],
+ section: 'prepare-executor',
+ lineNumber: 7,
+ },
+ ],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 665,
+ content: [{ text: 'Preparing environment', style: 'term-fg-l-cyan term-bold' }],
+ section: 'prepare-script',
+ section_header: true,
+ lineNumber: 9,
+ section_duration: '00:01',
+ },
+ lines: [
+ {
+ offset: 752,
+ content: [
+ { text: 'Running on runner-ewm9wzgd-project-20-concurrent-0 via 8ea689ec6969...' },
+ ],
+ section: 'prepare-script',
+ lineNumber: 10,
+ },
+ ],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 865,
+ content: [{ text: 'Getting source from Git repository', style: 'term-fg-l-cyan term-bold' }],
+ section: 'get-sources',
+ section_header: true,
+ lineNumber: 12,
+ section_duration: '00:01',
+ },
+ lines: [
+ {
+ offset: 962,
+ content: [
+ {
+ text: 'Fetching changes with git depth set to 20...',
+ style: 'term-fg-l-green term-bold',
+ },
+ ],
+ section: 'get-sources',
+ lineNumber: 13,
+ },
+ {
+ offset: 1019,
+ content: [
+ { text: 'Reinitialized existing Git repository in /builds/root/ci-project/.git/' },
+ ],
+ section: 'get-sources',
+ lineNumber: 14,
+ },
+ {
+ offset: 1090,
+ content: [{ text: 'Checking out e0f63d76 as main...', style: 'term-fg-l-green term-bold' }],
+ section: 'get-sources',
+ lineNumber: 15,
+ },
+ {
+ offset: 1136,
+ content: [{ text: 'Skipping Git submodules setup', style: 'term-fg-l-green term-bold' }],
+ section: 'get-sources',
+ lineNumber: 16,
+ },
+ ],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 1217,
+ content: [
+ {
+ text: 'Executing "step_script" stage of the job script',
+ style: 'term-fg-l-cyan term-bold',
+ },
+ ],
+ section: 'step-script',
+ section_header: true,
+ lineNumber: 18,
+ section_duration: '00:00',
+ },
+ lines: [
+ {
+ offset: 1327,
+ content: [
+ {
+ text:
+ 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...',
+ },
+ ],
+ section: 'step-script',
+ lineNumber: 19,
+ },
+ {
+ offset: 1533,
+ content: [{ text: '$ echo "82.71"', style: 'term-fg-l-green term-bold' }],
+ section: 'step-script',
+ lineNumber: 20,
+ },
+ { offset: 1560, content: [{ text: '82.71' }], section: 'step-script', lineNumber: 21 },
+ ],
+ },
+ {
+ offset: 1605,
+ content: [{ text: 'Job succeeded', style: 'term-fg-l-green term-bold' }],
+ lineNumber: 23,
+ },
+];
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 3d25ad075de..42a951f93a9 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -10,6 +10,7 @@ import {
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
+import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -20,6 +21,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@@ -30,8 +32,6 @@ import {
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
@@ -59,6 +59,9 @@ const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunners = runnersData.data.runners.nodes;
const mockRunnersCount = runnersCountData.data.runners.count;
+const mockRunnersQuery = jest.fn();
+const mockRunnersCountQuery = jest.fn();
+
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
@@ -71,8 +74,6 @@ Vue.use(GlToast);
describe('AdminRunnersApp', () => {
let wrapper;
- let mockRunnersQuery;
- let mockRunnersCountQuery;
let cacheConfig;
let localMutations;
@@ -116,15 +117,13 @@ describe('AdminRunnersApp', () => {
},
...options,
});
- };
- beforeEach(async () => {
- setWindowLocation('/admin/runners');
+ return waitForPromises();
+ };
- mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
- mockRunnersCountQuery = jest.fn().mockResolvedValue(runnersCountData);
- createComponent();
- await waitForPromises();
+ beforeEach(() => {
+ mockRunnersQuery.mockResolvedValue(runnersData);
+ mockRunnersCountQuery.mockResolvedValue(runnersCountData);
});
afterEach(() => {
@@ -134,92 +133,46 @@ describe('AdminRunnersApp', () => {
});
it('shows the runner tabs with a runner count for each type', async () => {
- mockRunnersCountQuery.mockImplementation(({ type }) => {
- let count;
- switch (type) {
- case INSTANCE_TYPE:
- count = 3;
- break;
- case GROUP_TYPE:
- count = 2;
- break;
- case PROJECT_TYPE:
- count = 1;
- break;
- default:
- count = 6;
- break;
- }
- return Promise.resolve({ data: { runners: { count } } });
- });
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
-
- expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
- `All 6 Instance 3 Group 2 Project 1`,
- );
- });
-
- it('shows the runner tabs with a formatted runner count', async () => {
- mockRunnersCountQuery.mockImplementation(({ type }) => {
- let count;
- switch (type) {
- case INSTANCE_TYPE:
- count = 3000;
- break;
- case GROUP_TYPE:
- count = 2000;
- break;
- case PROJECT_TYPE:
- count = 1000;
- break;
- default:
- count = 6000;
- break;
- }
- return Promise.resolve({ data: { runners: { count } } });
- });
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
- `All 6,000 Instance 3,000 Group 2,000 Project 1,000`,
+ `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`,
);
});
it('shows the runner setup instructions', () => {
+ createComponent();
+
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE);
});
it('shows total runner counts', async () => {
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
- status: STATUS_ONLINE,
- });
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
- status: STATUS_OFFLINE,
- });
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
- status: STATUS_STALE,
- });
+ await createComponent({ mountFn: mountExtended });
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockRunnersCount,
- offlineRunnersCount: mockRunnersCount,
- staleRunnersCount: mockRunnersCount,
- });
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({ status: STATUS_ONLINE });
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({ status: STATUS_OFFLINE });
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({ status: STATUS_STALE });
+
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Online runners')} ${mockRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Offline runners')} ${mockRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Stale runners')} ${mockRunnersCount}`,
+ );
});
- it('shows the runners list', () => {
+ it('shows the runners list', async () => {
+ await createComponent();
+
expect(findRunnerList().props('runners')).toEqual(mockRunners);
});
it('runner item links to the runner admin page', async () => {
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
const { id, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
@@ -231,12 +184,9 @@ describe('AdminRunnersApp', () => {
});
it('renders runner actions for each runner', async () => {
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
-
const runner = mockRunners[0];
expect(runnerActions.props()).toEqual({
@@ -245,7 +195,9 @@ describe('AdminRunnersApp', () => {
});
});
- it('requests the runners with no filters', () => {
+ it('requests the runners with no filters', async () => {
+ await createComponent();
+
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: undefined,
type: undefined,
@@ -284,10 +236,8 @@ describe('AdminRunnersApp', () => {
beforeEach(async () => {
mockRunnersCountQuery.mockClear();
- createComponent({ mountFn: mountExtended });
+ await createComponent({ mountFn: mountExtended });
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
-
- await waitForPromises();
});
it('Links to the runner page', async () => {
@@ -303,7 +253,6 @@ describe('AdminRunnersApp', () => {
findRunnerActionsCell().vm.$emit('toggledPaused');
expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES);
-
expect(showToast).toHaveBeenCalledTimes(0);
});
@@ -319,8 +268,12 @@ describe('AdminRunnersApp', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
- createComponent();
- await waitForPromises();
+ await createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
});
it('sets the filters in the search bar', () => {
@@ -351,16 +304,17 @@ describe('AdminRunnersApp', () => {
status: STATUS_ONLINE,
tagList: ['tag1'],
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockRunnersCount,
- });
});
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
- mockRunnersCountQuery.mockClear();
+ createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
@@ -375,7 +329,7 @@ describe('AdminRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/admin/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC',
+ url: expect.stringContaining('?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC'),
});
});
@@ -393,26 +347,6 @@ describe('AdminRunnersApp', () => {
tagList: ['tag1'],
status: STATUS_ONLINE,
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockRunnersCount,
- });
- });
-
- it('skips fetching count results for status that were not in filter', () => {
- expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({
- tagList: ['tag1'],
- status: STATUS_OFFLINE,
- });
- expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({
- tagList: ['tag1'],
- status: STATUS_STALE,
- });
-
- expect(findRunnerStats().props()).toMatchObject({
- offlineRunnersCount: null,
- staleRunnersCount: null,
- });
});
});
@@ -458,14 +392,13 @@ describe('AdminRunnersApp', () => {
describe('when no runners are found', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockResolvedValue({
+ mockRunnersQuery.mockResolvedValue({
data: {
runners: { nodes: [] },
},
});
- createComponent();
- await waitForPromises();
+ await createComponent();
});
it('shows an empty state', () => {
@@ -490,9 +423,8 @@ describe('AdminRunnersApp', () => {
describe('when runners query fails', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
- createComponent();
- await waitForPromises();
+ mockRunnersQuery.mockRejectedValue(new Error('Error!'));
+ await createComponent();
});
it('error is shown to the user', async () => {
@@ -509,10 +441,9 @@ describe('AdminRunnersApp', () => {
describe('Pagination', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated);
+ mockRunnersQuery.mockResolvedValue(runnersDataPaginated);
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
});
it('navigates to the next page', async () => {
diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js
index 9da5d842d8f..22d2a9e60f7 100644
--- a/spec/frontend/runner/components/runner_type_tabs_spec.js
+++ b/spec/frontend/runner/components/runner_type_tabs_spec.js
@@ -1,10 +1,30 @@
import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' };
+const mockCount = (type, multiplier = 1) => {
+ let count;
+ switch (type) {
+ case INSTANCE_TYPE:
+ count = 3;
+ break;
+ case GROUP_TYPE:
+ count = 2;
+ break;
+ case PROJECT_TYPE:
+ count = 1;
+ break;
+ default:
+ count = 6;
+ break;
+ }
+ return count * multiplier;
+};
+
describe('RunnerTypeTabs', () => {
let wrapper;
@@ -13,33 +33,94 @@ describe('RunnerTypeTabs', () => {
findTabs()
.filter((tab) => tab.attributes('active') === 'true')
.at(0);
- const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text());
+ const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text().replace(/\s+/g, ' '));
- const createComponent = ({ props, ...options } = {}) => {
+ const createComponent = ({ props, stubs, ...options } = {}) => {
wrapper = shallowMount(RunnerTypeTabs, {
propsData: {
value: mockSearch,
+ countScope: INSTANCE_TYPE,
+ countVariables: {},
...props,
},
stubs: {
GlTab,
+ ...stubs,
},
...options,
});
};
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
it('Renders all options to filter runners by default', () => {
+ createComponent();
+
expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']);
});
+ it('Shows count when receiving a number', () => {
+ createComponent({
+ stubs: {
+ RunnerCount: {
+ props: ['variables'],
+ render() {
+ return this.$scopedSlots.default({
+ count: mockCount(this.variables.type),
+ });
+ },
+ },
+ },
+ });
+
+ expect(getTabsTitles()).toEqual([`All 6`, `Instance 3`, `Group 2`, `Project 1`]);
+ });
+
+ it('Shows formatted count when receiving a large number', () => {
+ createComponent({
+ stubs: {
+ RunnerCount: {
+ props: ['variables'],
+ render() {
+ return this.$scopedSlots.default({
+ count: mockCount(this.variables.type, 1000),
+ });
+ },
+ },
+ },
+ });
+
+ expect(getTabsTitles()).toEqual([
+ `All 6,000`,
+ `Instance 3,000`,
+ `Group 2,000`,
+ `Project 1,000`,
+ ]);
+ });
+
+ it('Renders a count next to each tab', () => {
+ const mockVariables = {
+ paused: true,
+ status: 'ONLINE',
+ };
+
+ createComponent({
+ props: {
+ countVariables: mockVariables,
+ },
+ });
+
+ findTabs().wrappers.forEach((tab) => {
+ expect(tab.find(RunnerCount).props()).toEqual({
+ scope: INSTANCE_TYPE,
+ skip: false,
+ variables: expect.objectContaining(mockVariables),
+ });
+ });
+ });
+
it('Renders fewer options to filter runners', () => {
createComponent({
props: {
@@ -51,6 +132,8 @@ describe('RunnerTypeTabs', () => {
});
it('"All" is selected by default', () => {
+ createComponent();
+
expect(findActiveTab().text()).toBe('All');
});
@@ -71,6 +154,7 @@ describe('RunnerTypeTabs', () => {
const emittedValue = () => wrapper.emitted('input')[0][0];
beforeEach(() => {
+ createComponent();
findTabs().at(2).vm.$emit('click');
});
@@ -89,27 +173,30 @@ describe('RunnerTypeTabs', () => {
});
});
- describe('When using a custom slot', () => {
- const mockContent = 'content';
-
- beforeEach(() => {
- createComponent({
- scopedSlots: {
- title: `
- <span>
- {{props.tab.title}} ${mockContent}
- </span>`,
- },
+ describe('Component API', () => {
+ describe('When .refetch() is called', () => {
+ let mockRefetch;
+
+ beforeEach(() => {
+ mockRefetch = jest.fn();
+
+ createComponent({
+ stubs: {
+ RunnerCount: {
+ methods: {
+ refetch: mockRefetch,
+ },
+ render() {},
+ },
+ },
+ });
+
+ wrapper.vm.refetch();
});
- });
- it('Renders tabs with additional information', () => {
- expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([
- `All ${mockContent}`,
- `Instance ${mockContent}`,
- `Group ${mockContent}`,
- `Project ${mockContent}`,
- ]);
+ it('refetch is called for each count', () => {
+ expect(mockRefetch).toHaveBeenCalledTimes(4);
+ });
});
});
});
diff --git a/spec/frontend/runner/components/stat/runner_count_spec.js b/spec/frontend/runner/components/stat/runner_count_spec.js
new file mode 100644
index 00000000000..85a01235139
--- /dev/null
+++ b/spec/frontend/runner/components/stat/runner_count_spec.js
@@ -0,0 +1,148 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
+import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { captureException } from '~/runner/sentry_utils';
+
+import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.query.graphql';
+import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql';
+
+import { runnersCountData, groupRunnersCountData } from '../../mock_data';
+
+jest.mock('~/runner/sentry_utils');
+
+Vue.use(VueApollo);
+
+describe('RunnerCount', () => {
+ let wrapper;
+ let mockRunnersCountQuery;
+ let mockGroupRunnersCountQuery;
+
+ const createComponent = ({ props = {}, ...options } = {}) => {
+ const handlers = [
+ [adminRunnersCountQuery, mockRunnersCountQuery],
+ [getGroupRunnersCountQuery, mockGroupRunnersCountQuery],
+ ];
+
+ wrapper = shallowMount(RunnerCount, {
+ apolloProvider: createMockApollo(handlers),
+ propsData: {
+ ...props,
+ },
+ scopedSlots: {
+ default: '<strong>{{props.count}}</strong>',
+ },
+ ...options,
+ });
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ mockRunnersCountQuery = jest.fn().mockResolvedValue(runnersCountData);
+ mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData);
+ });
+
+ describe('in admin scope', () => {
+ const mockVariables = { status: 'ONLINE' };
+
+ beforeEach(async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE } });
+ });
+
+ it('fetches data from admin query', () => {
+ expect(mockRunnersCountQuery).toHaveBeenCalledTimes(1);
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith({});
+ });
+
+ it('fetches data with filters', async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE, variables: mockVariables } });
+
+ expect(mockRunnersCountQuery).toHaveBeenCalledTimes(2);
+ expect(mockRunnersCountQuery).toHaveBeenCalledWith(mockVariables);
+
+ expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`);
+ });
+
+ it('does not fetch from the group query', async () => {
+ expect(mockGroupRunnersCountQuery).not.toHaveBeenCalled();
+ });
+
+ describe('when this query is skipped after data was loaded', () => {
+ beforeEach(async () => {
+ wrapper.setProps({ skip: true });
+
+ await nextTick();
+ });
+
+ it('clears current data', () => {
+ expect(wrapper.html()).toBe('<strong></strong>');
+ });
+ });
+ });
+
+ describe('when skipping query', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } });
+ });
+
+ it('does not fetch data', async () => {
+ expect(mockRunnersCountQuery).not.toHaveBeenCalled();
+ expect(mockGroupRunnersCountQuery).not.toHaveBeenCalled();
+
+ expect(wrapper.html()).toBe('<strong></strong>');
+ });
+ });
+
+ describe('when runners query fails', () => {
+ const mockError = new Error('error!');
+
+ beforeEach(async () => {
+ mockRunnersCountQuery.mockRejectedValue(mockError);
+
+ await createComponent({ props: { scope: INSTANCE_TYPE } });
+ });
+
+ it('data is not shown and error is reported', async () => {
+ expect(wrapper.html()).toBe('<strong></strong>');
+
+ expect(captureException).toHaveBeenCalledWith({
+ component: 'RunnerCount',
+ error: mockError,
+ });
+ });
+ });
+
+ describe('in group scope', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { scope: GROUP_TYPE } });
+ });
+
+ it('fetches data from the group query', async () => {
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(1);
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({});
+
+ expect(wrapper.html()).toBe(
+ `<strong>${groupRunnersCountData.data.group.runners.count}</strong>`,
+ );
+ });
+
+ it('does not fetch from the group query', () => {
+ expect(mockRunnersCountQuery).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when .refetch() is called', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE } });
+ wrapper.vm.refetch();
+ });
+
+ it('data is not shown and error is reported', async () => {
+ expect(mockRunnersCountQuery).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/stat/runner_stats_spec.js b/spec/frontend/runner/components/stat/runner_stats_spec.js
index 68db8621ef0..f1ba6403dfb 100644
--- a/spec/frontend/runner/components/stat/runner_stats_spec.js
+++ b/spec/frontend/runner/components/stat/runner_stats_spec.js
@@ -1,21 +1,24 @@
import { shallowMount, mount } from '@vue/test-utils';
+import { s__ } from '~/locale';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue';
-import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants';
+import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants';
describe('RunnerStats', () => {
let wrapper;
+ const findRunnerCountAt = (i) => wrapper.findAllComponents(RunnerCount).at(i);
const findRunnerStatusStatAt = (i) => wrapper.findAllComponents(RunnerStatusStat).at(i);
- const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => {
wrapper = mountFn(RunnerStats, {
propsData: {
- onlineRunnersCount: 3,
- offlineRunnersCount: 2,
- staleRunnersCount: 1,
+ scope: INSTANCE_TYPE,
+ variables: {},
...props,
},
+ ...options,
});
};
@@ -24,13 +27,46 @@ describe('RunnerStats', () => {
});
it('Displays all the stats', () => {
- createComponent({ mountFn: mount });
+ const mockCounts = {
+ [STATUS_ONLINE]: 3,
+ [STATUS_OFFLINE]: 2,
+ [STATUS_STALE]: 1,
+ };
+
+ createComponent({
+ mountFn: mount,
+ stubs: {
+ RunnerCount: {
+ props: ['variables'],
+ render() {
+ return this.$scopedSlots.default({
+ count: mockCounts[this.variables.status],
+ });
+ },
+ },
+ },
+ });
+
+ const text = wrapper.text();
+ expect(text).toMatch(`${s__('Runners|Online runners')} 3`);
+ expect(text).toMatch(`${s__('Runners|Offline runners')} 2`);
+ expect(text).toMatch(`${s__('Runners|Stale runners')} 1`);
+ });
- const stats = wrapper.text();
+ it('Displays counts for filtered searches', () => {
+ createComponent({ props: { variables: { paused: true } } });
- expect(stats).toMatch('Online runners 3');
- expect(stats).toMatch('Offline runners 2');
- expect(stats).toMatch('Stale runners 1');
+ expect(findRunnerCountAt(0).props('variables').paused).toBe(true);
+ expect(findRunnerCountAt(1).props('variables').paused).toBe(true);
+ expect(findRunnerCountAt(2).props('variables').paused).toBe(true);
+ });
+
+ it('Skips overlapping statuses', () => {
+ createComponent({ props: { variables: { status: STATUS_ONLINE } } });
+
+ expect(findRunnerCountAt(0).props('skip')).toBe(false);
+ expect(findRunnerCountAt(1).props('skip')).toBe(true);
+ expect(findRunnerCountAt(2).props('skip')).toBe(true);
});
it.each`
@@ -38,9 +74,10 @@ describe('RunnerStats', () => {
${0} | ${STATUS_ONLINE}
${1} | ${STATUS_OFFLINE}
${2} | ${STATUS_STALE}
- `('Displays status types at index $i', ({ i, status }) => {
- createComponent();
+ `('Displays status $status at index $i', ({ i, status }) => {
+ createComponent({ mountFn: mount });
+ expect(findRunnerCountAt(i).props('variables').status).toBe(status);
expect(findRunnerStatusStatAt(i).props('status')).toBe(status);
});
});
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
index eb9f85a7d0f..3249f54d1be 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -10,6 +10,7 @@ import {
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
+import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -18,6 +19,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@@ -28,7 +30,6 @@ import {
DEFAULT_SORT,
INSTANCE_TYPE,
GROUP_TYPE,
- PROJECT_TYPE,
PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
@@ -61,6 +62,9 @@ const mockRegistrationToken = 'AABBCC';
const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
const mockGroupRunnersCount = mockGroupRunnersEdges.length;
+const mockGroupRunnersQuery = jest.fn();
+const mockGroupRunnersCountQuery = jest.fn();
+
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
@@ -70,8 +74,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('GroupRunnersApp', () => {
let wrapper;
- let mockGroupRunnersQuery;
- let mockGroupRunnersCountQuery;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
@@ -85,12 +87,7 @@ describe('GroupRunnersApp', () => {
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
- const mockCountQueryResult = (count) =>
- Promise.resolve({
- data: { group: { id: groupRunnersCountData.data.group.id, runners: { count } } },
- });
-
- const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
const handlers = [
[getGroupRunnersQuery, mockGroupRunnersQuery],
[getGroupRunnersCountQuery, mockGroupRunnersCountQuery],
@@ -110,89 +107,75 @@ describe('GroupRunnersApp', () => {
emptyStateSvgPath,
emptyStateFilteredSvgPath,
},
+ ...options,
});
+
+ return waitForPromises();
};
beforeEach(async () => {
- setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`);
+ mockGroupRunnersQuery.mockResolvedValue(groupRunnersData);
+ mockGroupRunnersCountQuery.mockResolvedValue(groupRunnersCountData);
+ });
- mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData);
- mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData);
+ afterEach(() => {
+ mockGroupRunnersQuery.mockReset();
+ mockGroupRunnersCountQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ it('shows the runner tabs with a runner count for each type', async () => {
+ await createComponent({ mountFn: mountExtended });
+ expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
+ `All ${mockGroupRunnersCount} Group ${mockGroupRunnersCount} Project ${mockGroupRunnersCount}`,
+ );
+ });
+
+ it('shows the runner setup instructions', () => {
createComponent();
- await waitForPromises();
+
+ expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
+ expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
});
it('shows total runner counts', async () => {
+ await createComponent({ mountFn: mountExtended });
+
expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
+ groupFullPath: mockGroupFullPath,
});
expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
status: STATUS_OFFLINE,
+ groupFullPath: mockGroupFullPath,
});
expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
status: STATUS_STALE,
+ groupFullPath: mockGroupFullPath,
});
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockGroupRunnersCount,
- offlineRunnersCount: mockGroupRunnersCount,
- staleRunnersCount: mockGroupRunnersCount,
- });
- });
-
- it('shows the runner tabs with a runner count for each type', async () => {
- mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
- switch (type) {
- case GROUP_TYPE:
- return mockCountQueryResult(2);
- case PROJECT_TYPE:
- return mockCountQueryResult(1);
- default:
- return mockCountQueryResult(4);
- }
- });
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
-
- expect(findRunnerTypeTabs().text()).toMatchInterpolatedText('All 4 Group 2 Project 1');
- });
-
- it('shows the runner tabs with a formatted runner count', async () => {
- mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
- switch (type) {
- case GROUP_TYPE:
- return mockCountQueryResult(2000);
- case PROJECT_TYPE:
- return mockCountQueryResult(1000);
- default:
- return mockCountQueryResult(3000);
- }
- });
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
-
- expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
- 'All 3,000 Group 2,000 Project 1,000',
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Online runners')} ${mockGroupRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Offline runners')} ${mockGroupRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Stale runners')} ${mockGroupRunnersCount}`,
);
});
- it('shows the runner setup instructions', () => {
- expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
- expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
- });
+ it('shows the runners list', async () => {
+ await createComponent();
- it('shows the runners list', () => {
const runners = findRunnerList().props('runners');
expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node));
});
- it('requests the runners with group path and no other filters', () => {
+ it('requests the runners with group path and no other filters', async () => {
+ await createComponent();
+
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: undefined,
@@ -229,12 +212,8 @@ describe('GroupRunnersApp', () => {
const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs
beforeEach(async () => {
- mockGroupRunnersCountQuery.mockClear();
-
- createComponent({ mountFn: mountExtended });
+ await createComponent({ mountFn: mountExtended });
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
-
- await waitForPromises();
});
it('view link is displayed correctly', () => {
@@ -277,8 +256,12 @@ describe('GroupRunnersApp', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`);
- createComponent();
- await waitForPromises();
+ await createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
});
it('sets the filters in the search bar', () => {
@@ -306,15 +289,18 @@ describe('GroupRunnersApp', () => {
type: INSTANCE_TYPE,
status: STATUS_ONLINE,
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockGroupRunnersCount,
- });
});
});
describe('when a filter is selected by the user', () => {
beforeEach(async () => {
+ createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
+
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
filters: [
@@ -330,7 +316,7 @@ describe('GroupRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC',
+ url: expect.stringContaining('?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC'),
});
});
@@ -350,28 +336,6 @@ describe('GroupRunnersApp', () => {
tagList: ['tag1'],
status: STATUS_ONLINE,
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockGroupRunnersCount,
- });
- });
-
- it('skips fetching count results for status that were not in filter', () => {
- expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
- tagList: ['tag1'],
- status: STATUS_OFFLINE,
- });
- expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
- tagList: ['tag1'],
- status: STATUS_STALE,
- });
-
- expect(findRunnerStats().props()).toMatchObject({
- offlineRunnersCount: null,
- staleRunnersCount: null,
- });
});
});
@@ -382,7 +346,7 @@ describe('GroupRunnersApp', () => {
describe('when no runners are found', () => {
beforeEach(async () => {
- mockGroupRunnersQuery = jest.fn().mockResolvedValue({
+ mockGroupRunnersQuery.mockResolvedValue({
data: {
group: {
id: '1',
@@ -390,8 +354,7 @@ describe('GroupRunnersApp', () => {
},
},
});
- createComponent();
- await waitForPromises();
+ await createComponent();
});
it('shows an empty state', async () => {
@@ -401,9 +364,8 @@ describe('GroupRunnersApp', () => {
describe('when runners query fails', () => {
beforeEach(async () => {
- mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
- createComponent();
- await waitForPromises();
+ mockGroupRunnersQuery.mockRejectedValue(new Error('Error!'));
+ await createComponent();
});
it('error is shown to the user', async () => {
@@ -420,10 +382,9 @@ describe('GroupRunnersApp', () => {
describe('Pagination', () => {
beforeEach(async () => {
- mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
+ mockGroupRunnersQuery.mockResolvedValue(groupRunnersDataPaginated);
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
});
it('navigates to the next page', async () => {
diff --git a/spec/lib/gitlab/ci/reports/coverage_report_generator_spec.rb b/spec/lib/gitlab/ci/reports/coverage_report_generator_spec.rb
index eec218346c2..f116b175fc7 100644
--- a/spec/lib/gitlab/ci/reports/coverage_report_generator_spec.rb
+++ b/spec/lib/gitlab/ci/reports/coverage_report_generator_spec.rb
@@ -75,16 +75,6 @@ RSpec.describe Gitlab::Ci::Reports::CoverageReportGenerator, factory_default: :k
end
it_behaves_like 'having a coverage report'
-
- context 'when feature flag ci_child_pipeline_coverage_reports is disabled' do
- before do
- stub_feature_flags(ci_child_pipeline_coverage_reports: false)
- end
-
- it 'returns empty coverage reports' do
- expect(subject).to be_empty
- end
- end
end
context 'when both parent and child pipeline have builds with coverage reports' do
diff --git a/spec/lib/gitlab/ci/status/stage/common_spec.rb b/spec/lib/gitlab/ci/status/stage/common_spec.rb
index bbd2ce6c83b..a7e0ebf9158 100644
--- a/spec/lib/gitlab/ci/status/stage/common_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/common_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Gitlab::Ci::Status::Stage::Common do
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:stage) do
- build(:ci_stage, pipeline: pipeline, name: 'test')
+ build(:ci_stage_entity, pipeline: pipeline, name: 'test')
end
subject do
diff --git a/spec/lib/gitlab/ci/status/stage/factory_spec.rb b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
index e0f5531f370..348e0a1eb68 100644
--- a/spec/lib/gitlab/ci/status/stage/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
@@ -7,9 +7,7 @@ RSpec.describe Gitlab::Ci::Status::Stage::Factory do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
- let(:stage) do
- build(:ci_stage, pipeline: pipeline, name: 'test')
- end
+ let(:stage) { create(:ci_stage_entity, pipeline: pipeline) }
subject do
described_class.new(stage, user)
@@ -26,11 +24,7 @@ RSpec.describe Gitlab::Ci::Status::Stage::Factory do
context 'when stage has a core status' do
(Ci::HasStatus::AVAILABLE_STATUSES - %w(manual skipped scheduled)).each do |core_status|
context "when core status is #{core_status}" do
- before do
- create(:ci_build, pipeline: pipeline, stage: 'test', status: core_status)
- create(:commit_status, pipeline: pipeline, stage: 'test', status: core_status)
- create(:ci_build, pipeline: pipeline, stage: 'build', status: :failed)
- end
+ let(:stage) { create(:ci_stage_entity, pipeline: pipeline, status: core_status) }
it "fabricates a core status #{core_status}" do
expect(status).to be_a(
@@ -48,12 +42,12 @@ RSpec.describe Gitlab::Ci::Status::Stage::Factory do
context 'when stage has warnings' do
let(:stage) do
- build(:ci_stage, name: 'test', status: :success, pipeline: pipeline)
+ create(:ci_stage_entity, status: :success, pipeline: pipeline)
end
before do
create(:ci_build, :allowed_to_fail, :failed,
- stage: 'test', pipeline: stage.pipeline)
+ stage_id: stage.id, pipeline: stage.pipeline)
end
it 'fabricates extended "success with warnings" status' do
@@ -70,11 +64,7 @@ RSpec.describe Gitlab::Ci::Status::Stage::Factory do
context 'when stage has manual builds' do
(Ci::HasStatus::BLOCKED_STATUS + ['skipped']).each do |core_status|
context "when status is #{core_status}" do
- before do
- create(:ci_build, pipeline: pipeline, stage: 'test', status: core_status)
- create(:commit_status, pipeline: pipeline, stage: 'test', status: core_status)
- create(:ci_build, pipeline: pipeline, stage: 'build', status: :manual)
- end
+ let(:stage) { create(:ci_stage_entity, pipeline: pipeline, status: core_status) }
it 'fabricates a play manual status' do
expect(status).to be_a(Gitlab::Ci::Status::Stage::PlayManual)
diff --git a/spec/lib/gitlab/diff/rendered/notebook/diff_file_helper_spec.rb b/spec/lib/gitlab/diff/rendered/notebook/diff_file_helper_spec.rb
index cb046548880..42ab2d1d063 100644
--- a/spec/lib/gitlab/diff/rendered/notebook/diff_file_helper_spec.rb
+++ b/spec/lib/gitlab/diff/rendered/notebook/diff_file_helper_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFileHelper do
where(:case, :transformed_blocks, :result) do
'if transformed diff is empty' | [] | 0
'if the transformed line does not map to any in the original file' | [{ source_line: nil }] | 0
- 'if the transformed line maps to a line in the source file' | [{ source_line: 2 }] | 3
+ 'if the transformed line maps to a line in the source file' | [{ source_line: 3 }] | 3
end
with_them do
@@ -81,8 +81,8 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFileHelper do
let(:blocks) do
{
- from: [0, 2, 1, nil, nil, 3].map { |i| { source_line: i } },
- to: [0, 1, nil, 2, nil, 3].map { |i| { source_line: i } }
+ from: [1, 3, 2, nil, nil, 4].map { |i| { source_line: i } },
+ to: [1, 2, nil, 3, nil, 4].map { |i| { source_line: i } }
}
end
diff --git a/spec/models/ci/legacy_stage_spec.rb b/spec/models/ci/legacy_stage_spec.rb
deleted file mode 100644
index 2487ad85889..00000000000
--- a/spec/models/ci/legacy_stage_spec.rb
+++ /dev/null
@@ -1,268 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::LegacyStage do
- let(:stage) { build(:ci_stage) }
- let(:pipeline) { stage.pipeline }
- let(:stage_name) { stage.name }
-
- describe '#expectations' do
- subject { stage }
-
- it { is_expected.to include_module(StaticModel) }
-
- it { is_expected.to respond_to(:pipeline) }
- it { is_expected.to respond_to(:name) }
-
- it { is_expected.to delegate_method(:project).to(:pipeline) }
- end
-
- describe '#statuses' do
- let!(:stage_build) { create_job(:ci_build) }
- let!(:commit_status) { create_job(:commit_status) }
- let!(:other_build) { create_job(:ci_build, stage: 'other stage') }
-
- subject { stage.statuses }
-
- it "returns only matching statuses" do
- is_expected.to contain_exactly(stage_build, commit_status)
- end
- end
-
- describe '#groups' do
- before do
- create_job(:ci_build, name: 'rspec 0 2')
- create_job(:ci_build, name: 'rspec 0 1')
- create_job(:ci_build, name: 'spinach 0 1')
- create_job(:commit_status, name: 'aaaaa')
- end
-
- it 'returns an array of three groups' do
- expect(stage.groups).to be_a Array
- expect(stage.groups).to all(be_a Ci::Group)
- expect(stage.groups.size).to eq 3
- end
-
- it 'returns groups with correctly ordered statuses' do
- expect(stage.groups.first.jobs.map(&:name))
- .to eq ['aaaaa']
- expect(stage.groups.second.jobs.map(&:name))
- .to eq ['rspec 0 1', 'rspec 0 2']
- expect(stage.groups.third.jobs.map(&:name))
- .to eq ['spinach 0 1']
- end
-
- it 'returns groups with correct names' do
- expect(stage.groups.map(&:name))
- .to eq %w[aaaaa rspec spinach]
- end
-
- context 'when a name is nil on legacy pipelines' do
- before do
- pipeline.builds.first.update_attribute(:name, nil)
- end
-
- it 'returns an array of three groups' do
- expect(stage.groups.map(&:name))
- .to eq ['', 'aaaaa', 'rspec', 'spinach']
- end
- end
- end
-
- describe '#statuses_count' do
- before do
- create_job(:ci_build)
- create_job(:ci_build, stage: 'other stage')
- end
-
- subject { stage.statuses_count }
-
- it "counts statuses only from current stage" do
- is_expected.to eq(1)
- end
- end
-
- describe '#builds' do
- let!(:stage_build) { create_job(:ci_build) }
- let!(:commit_status) { create_job(:commit_status) }
-
- subject { stage.builds }
-
- it "returns only builds" do
- is_expected.to contain_exactly(stage_build)
- end
- end
-
- describe '#status' do
- subject { stage.status }
-
- context 'if status is already defined' do
- let(:stage) { build(:ci_stage, status: 'success') }
-
- it "returns defined status" do
- is_expected.to eq('success')
- end
- end
-
- context 'if status has to be calculated' do
- let!(:stage_build) { create_job(:ci_build, status: :failed) }
-
- it "returns status of a build" do
- is_expected.to eq('failed')
- end
-
- context 'and builds are retried' do
- let!(:new_build) { create_job(:ci_build, status: :success) }
-
- before do
- stage_build.update!(retried: true)
- end
-
- it "returns status of latest build" do
- is_expected.to eq('success')
- end
- end
- end
- end
-
- describe '#detailed_status' do
- let(:user) { create(:user) }
-
- subject { stage.detailed_status(user) }
-
- context 'when build is created' do
- let!(:stage_build) { create_job(:ci_build, status: :created) }
-
- it 'returns detailed status for created stage' do
- expect(subject.text).to eq s_('CiStatusText|created')
- end
- end
-
- context 'when build is pending' do
- let!(:stage_build) { create_job(:ci_build, status: :pending) }
-
- it 'returns detailed status for pending stage' do
- expect(subject.text).to eq s_('CiStatusText|pending')
- end
- end
-
- context 'when build is running' do
- let!(:stage_build) { create_job(:ci_build, status: :running) }
-
- it 'returns detailed status for running stage' do
- expect(subject.text).to eq s_('CiStatus|running')
- end
- end
-
- context 'when build is successful' do
- let!(:stage_build) { create_job(:ci_build, status: :success) }
-
- it 'returns detailed status for successful stage' do
- expect(subject.text).to eq s_('CiStatusText|passed')
- end
- end
-
- context 'when build is failed' do
- let!(:stage_build) { create_job(:ci_build, status: :failed) }
-
- it 'returns detailed status for failed stage' do
- expect(subject.text).to eq s_('CiStatusText|failed')
- end
- end
-
- context 'when build is canceled' do
- let!(:stage_build) { create_job(:ci_build, status: :canceled) }
-
- it 'returns detailed status for canceled stage' do
- expect(subject.text).to eq s_('CiStatusText|canceled')
- end
- end
-
- context 'when build is skipped' do
- let!(:stage_build) { create_job(:ci_build, status: :skipped) }
-
- it 'returns detailed status for skipped stage' do
- expect(subject.text).to eq s_('CiStatusText|skipped')
- end
- end
- end
-
- describe '#success?' do
- context 'when stage is successful' do
- before do
- create_job(:ci_build, status: :success)
- create_job(:generic_commit_status, status: :success)
- end
-
- it 'is successful' do
- expect(stage).to be_success
- end
- end
-
- context 'when stage is not successful' do
- before do
- create_job(:ci_build, status: :failed)
- create_job(:generic_commit_status, status: :success)
- end
-
- it 'is not successful' do
- expect(stage).not_to be_success
- end
- end
- end
-
- describe '#has_warnings?' do
- context 'when stage has warnings' do
- context 'when using memoized warnings flag' do
- context 'when there are warnings' do
- let(:stage) { build(:ci_stage, warnings: true) }
-
- it 'returns true using memoized value' do
- expect(stage).not_to receive(:statuses)
- expect(stage).to have_warnings
- end
- end
-
- context 'when there are no warnings' do
- let(:stage) { build(:ci_stage, warnings: false) }
-
- it 'returns false using memoized value' do
- expect(stage).not_to receive(:statuses)
- expect(stage).not_to have_warnings
- end
- end
- end
-
- context 'when calculating warnings from statuses' do
- before do
- create(:ci_build, :failed, :allowed_to_fail,
- stage: stage_name, pipeline: pipeline)
- end
-
- it 'has warnings calculated from statuses' do
- expect(stage).to receive(:statuses).and_call_original
- expect(stage).to have_warnings
- end
- end
- end
-
- context 'when stage does not have warnings' do
- before do
- create(:ci_build, :success, stage: stage_name,
- pipeline: pipeline)
- end
-
- it 'does not have warnings calculated from statuses' do
- expect(stage).to receive(:statuses).and_call_original
- expect(stage).not_to have_warnings
- end
- end
- end
-
- def create_job(type, status: 'success', stage: stage_name, **opts)
- create(type, pipeline: pipeline, stage: stage, status: status, **opts)
- end
-
- it_behaves_like 'manual playable stage', :ci_stage
-end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 7357ba5267b..15cb5b42248 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1333,48 +1333,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
status: 'success')
end
- describe '#legacy_stages' do
- using RSpec::Parameterized::TableSyntax
-
- subject { pipeline.legacy_stages }
-
- context 'stages list' do
- it 'returns ordered list of stages' do
- expect(subject.map(&:name)).to eq(%w[build test deploy])
- end
- end
-
- context 'stages with statuses' do
- let(:statuses) do
- subject.map { |stage| [stage.name, stage.status] }
- end
-
- it 'returns list of stages with correct statuses' do
- expect(statuses).to eq([%w(build failed),
- %w(test success),
- %w(deploy running)])
- end
- end
-
- context 'when there is a stage with warnings' do
- before do
- create(:commit_status, pipeline: pipeline,
- stage: 'deploy',
- name: 'prod:2',
- stage_idx: 2,
- status: 'failed',
- allow_failure: true)
- end
-
- it 'populates stage with correct number of warnings' do
- deploy_stage = pipeline.legacy_stages.third
-
- expect(deploy_stage).not_to receive(:statuses)
- expect(deploy_stage).to have_warnings
- end
- end
- end
-
describe '#stages_count' do
it 'returns a valid number of stages' do
expect(pipeline.stages_count).to eq(3)
@@ -1388,32 +1346,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
- describe '#legacy_stage' do
- subject { pipeline.legacy_stage('test') }
-
- let(:pipeline) { build(:ci_empty_pipeline, :created) }
-
- context 'with status in stage' do
- before do
- create(:commit_status, pipeline: pipeline, stage: 'test')
- end
-
- it { expect(subject).to be_a Ci::LegacyStage }
- it { expect(subject.name).to eq 'test' }
- it { expect(subject.statuses).not_to be_empty }
- end
-
- context 'without status in stage' do
- before do
- create(:commit_status, pipeline: pipeline, stage: 'build')
- end
-
- it 'return stage object' do
- is_expected.to be_nil
- end
- end
- end
-
describe '#stages' do
let(:pipeline) { build(:ci_empty_pipeline, :created) }
@@ -4320,7 +4252,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
- describe '#find_stage_by_name' do
+ describe 'fetching a stage by name' do
let_it_be(:pipeline) { create(:ci_pipeline) }
let(:stage_name) { 'test' }
@@ -4336,19 +4268,37 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
create_list(:ci_build, 2, pipeline: pipeline, stage: stage.name)
end
- subject { pipeline.find_stage_by_name!(stage_name) }
+ describe '#stage' do
+ subject { pipeline.stage(stage_name) }
- context 'when stage exists' do
- it { is_expected.to eq(stage) }
+ context 'when stage exists' do
+ it { is_expected.to eq(stage) }
+ end
+
+ context 'when stage does not exist' do
+ let(:stage_name) { 'build' }
+
+ it 'returns nil' do
+ is_expected.to be_nil
+ end
+ end
end
- context 'when stage does not exist' do
- let(:stage_name) { 'build' }
+ describe '#find_stage_by_name' do
+ subject { pipeline.find_stage_by_name!(stage_name) }
- it 'raises an ActiveRecord exception' do
- expect do
- subject
- end.to raise_exception(ActiveRecord::RecordNotFound)
+ context 'when stage exists' do
+ it { is_expected.to eq(stage) }
+ end
+
+ context 'when stage does not exist' do
+ let(:stage_name) { 'build' }
+
+ it 'raises an ActiveRecord exception' do
+ expect do
+ subject
+ end.to raise_exception(ActiveRecord::RecordNotFound)
+ end
end
end
end
diff --git a/spec/presenters/ci/legacy_stage_presenter_spec.rb b/spec/presenters/ci/legacy_stage_presenter_spec.rb
deleted file mode 100644
index 5268ef0f246..00000000000
--- a/spec/presenters/ci/legacy_stage_presenter_spec.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::LegacyStagePresenter do
- let(:legacy_stage) { create(:ci_stage) }
- let(:presenter) { described_class.new(legacy_stage) }
-
- let!(:build) { create(:ci_build, :tags, :artifacts, pipeline: legacy_stage.pipeline, stage: legacy_stage.name) }
- let!(:retried_build) { create(:ci_build, :tags, :artifacts, :retried, pipeline: legacy_stage.pipeline, stage: legacy_stage.name) }
-
- before do
- create(:generic_commit_status, pipeline: legacy_stage.pipeline, stage: legacy_stage.name)
- end
-
- describe '#latest_ordered_statuses' do
- subject(:latest_ordered_statuses) { presenter.latest_ordered_statuses }
-
- it 'preloads build tags' do
- expect(latest_ordered_statuses.second.association(:tags)).to be_loaded
- end
-
- it 'preloads build artifacts archive' do
- expect(latest_ordered_statuses.second.association(:job_artifacts_archive)).to be_loaded
- end
-
- it 'preloads build artifacts metadata' do
- expect(latest_ordered_statuses.second.association(:metadata)).to be_loaded
- end
- end
-
- describe '#retried_ordered_statuses' do
- subject(:retried_ordered_statuses) { presenter.retried_ordered_statuses }
-
- it 'preloads build tags' do
- expect(retried_ordered_statuses.first.association(:tags)).to be_loaded
- end
-
- it 'preloads build artifacts archive' do
- expect(retried_ordered_statuses.first.association(:job_artifacts_archive)).to be_loaded
- end
-
- it 'preloads build artifacts metadata' do
- expect(retried_ordered_statuses.first.association(:metadata)).to be_loaded
- end
- end
-end
diff --git a/spec/presenters/ci/stage_presenter_spec.rb b/spec/presenters/ci/stage_presenter_spec.rb
index 368f03b0150..6b82785c2d4 100644
--- a/spec/presenters/ci/stage_presenter_spec.rb
+++ b/spec/presenters/ci/stage_presenter_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::StagePresenter do
- let(:stage) { create(:ci_stage) }
+ let(:stage) { create(:ci_stage_entity) }
let(:presenter) { described_class.new(stage) }
let!(:build) { create(:ci_build, :tags, :artifacts, pipeline: stage.pipeline, stage: stage.name) }
diff --git a/spec/serializers/ci/dag_job_group_entity_spec.rb b/spec/serializers/ci/dag_job_group_entity_spec.rb
index 5a75c04efe5..d05dd8b05b9 100644
--- a/spec/serializers/ci/dag_job_group_entity_spec.rb
+++ b/spec/serializers/ci/dag_job_group_entity_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Ci::DagJobGroupEntity do
let_it_be(:request) { double(:request) }
let_it_be(:pipeline) { create(:ci_pipeline) }
- let_it_be(:stage) { create(:ci_stage, pipeline: pipeline) }
+ let_it_be(:stage) { create(:ci_stage_entity, pipeline: pipeline) }
let(:group) { Ci::Group.new(pipeline.project, stage, name: 'test', jobs: jobs) }
let(:entity) { described_class.new(group, request: request) }
@@ -14,7 +14,7 @@ RSpec.describe Ci::DagJobGroupEntity do
subject { entity.as_json }
context 'when group contains 1 job' do
- let(:job) { create(:ci_build, stage: stage, pipeline: pipeline, name: 'test') }
+ let(:job) { create(:ci_build, stage_id: stage.id, pipeline: pipeline, name: 'test') }
let(:jobs) { [job] }
it 'exposes a name' do
@@ -38,8 +38,8 @@ RSpec.describe Ci::DagJobGroupEntity do
end
context 'when group contains multiple parallel jobs' do
- let(:job_1) { create(:ci_build, stage: stage, pipeline: pipeline, name: 'test 1/2') }
- let(:job_2) { create(:ci_build, stage: stage, pipeline: pipeline, name: 'test 2/2') }
+ let(:job_1) { create(:ci_build, stage_id: stage.id, pipeline: pipeline, name: 'test 1/2') }
+ let(:job_2) { create(:ci_build, stage_id: stage.id, pipeline: pipeline, name: 'test 2/2') }
let(:jobs) { [job_1, job_2] }
it 'exposes a name' do
diff --git a/spec/serializers/ci/dag_stage_entity_spec.rb b/spec/serializers/ci/dag_stage_entity_spec.rb
index 0262ccdac68..554437ecef5 100644
--- a/spec/serializers/ci/dag_stage_entity_spec.rb
+++ b/spec/serializers/ci/dag_stage_entity_spec.rb
@@ -6,10 +6,10 @@ RSpec.describe Ci::DagStageEntity do
let_it_be(:pipeline) { create(:ci_pipeline) }
let_it_be(:request) { double(:request) }
- let(:stage) { build(:ci_stage, pipeline: pipeline, name: 'test') }
+ let(:stage) { create(:ci_stage_entity, pipeline: pipeline, name: 'test') }
let(:entity) { described_class.new(stage, request: request) }
- let!(:job) { create(:ci_build, :success, pipeline: pipeline) }
+ let!(:job) { create(:ci_build, :success, pipeline: pipeline, stage_id: stage.id) }
describe '#as_json' do
subject { entity.as_json }
diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb
index b977d5d33aa..ab74ce76cbd 100644
--- a/spec/serializers/stage_entity_spec.rb
+++ b/spec/serializers/stage_entity_spec.rb
@@ -12,12 +12,12 @@ RSpec.describe StageEntity do
end
let(:stage) do
- build(:ci_stage, pipeline: pipeline, name: 'test')
+ create(:ci_stage_entity, pipeline: pipeline, status: :success)
end
before do
allow(request).to receive(:current_user).and_return(user)
- create(:ci_build, :success, pipeline: pipeline)
+ create(:ci_build, :success, pipeline: pipeline, stage_id: stage.id)
end
describe '#as_json' do
diff --git a/spec/services/ci/generate_coverage_reports_service_spec.rb b/spec/services/ci/generate_coverage_reports_service_spec.rb
index dea946f3b1c..212e6be9d07 100644
--- a/spec/services/ci/generate_coverage_reports_service_spec.rb
+++ b/spec/services/ci/generate_coverage_reports_service_spec.rb
@@ -88,20 +88,6 @@ RSpec.describe Ci::GenerateCoverageReportsService do
end
it { is_expected.to be_falsy }
-
- context 'when feature flag ci_child_pipeline_coverage_reports is disabled' do
- let!(:key) do
- # `let!` is executed before `before` block. If the feature flag
- # is stubbed in `before`, the first call to `#key` uses the
- # default feature flag value (enabled).
- # The feature flag needs to be stubbed before the first call to `#key`
- # so that the first and second key are calculated using the same method.
- stub_feature_flags(ci_child_pipeline_coverage_reports: false)
- service.send(:key, base_pipeline, head_pipeline)
- end
-
- it { is_expected.to be_truthy }
- end
end
end
end
diff --git a/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb b/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
index 4b85c52ebce..31548793bac 100644
--- a/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
+++ b/spec/services/ci/pipeline_artifacts/coverage_report_service_spec.rb
@@ -69,16 +69,6 @@ RSpec.describe Ci::PipelineArtifacts::CoverageReportService do
end
it_behaves_like 'creating or updating a pipeline coverage report'
-
- context 'when the feature flag is disabled' do
- before do
- stub_feature_flags(ci_child_pipeline_coverage_reports: false)
- end
-
- it 'does not change existing pipeline artifact' do
- expect { subject }.not_to change { pipeline_artifact.reload.updated_at }
- end
- end
end
context 'when pipeline is running and coverage report does not exist' do
diff --git a/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb b/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb
index f51e66aa948..7b28384a5bf 100644
--- a/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb
+++ b/spec/workers/ci/pipeline_artifacts/coverage_report_worker_spec.rb
@@ -46,20 +46,6 @@ RSpec.describe Ci::PipelineArtifacts::CoverageReportWorker do
subject
end
end
-
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(ci_child_pipeline_coverage_reports: false)
- end
-
- it 'calls the pipeline coverage report service on the pipeline' do
- expect_next_instance_of(::Ci::PipelineArtifacts::CoverageReportService, pipeline) do |service|
- expect(service).to receive(:execute)
- end
-
- subject
- end
- end
end
context 'when pipeline does not exist' do
diff --git a/spec/workers/pages/invalidate_domain_cache_worker_spec.rb b/spec/workers/pages/invalidate_domain_cache_worker_spec.rb
index 8fdc7316069..fe453db59f4 100644
--- a/spec/workers/pages/invalidate_domain_cache_worker_spec.rb
+++ b/spec/workers/pages/invalidate_domain_cache_worker_spec.rb
@@ -3,27 +3,38 @@
require 'spec_helper'
RSpec.describe Pages::InvalidateDomainCacheWorker do
- let(:event) do
- Pages::PageDeployedEvent.new(data: {
- project_id: 1,
- namespace_id: 2,
- root_namespace_id: 3
- })
- end
+ shared_examples 'clears caches with' do |event_class:, event_data:, caches:|
+ let(:event) do
+ event_class.new(data: event_data)
+ end
- subject { consume_event(subscriber: described_class, event: event) }
+ subject { consume_event(subscriber: described_class, event: event) }
- it_behaves_like 'subscribes to event'
+ it_behaves_like 'subscribes to event'
- it 'enqueues ScheduleAggregationWorker' do
- expect_next_instance_of(Gitlab::Pages::CacheControl, type: :project, id: 1) do |cache_control|
- expect(cache_control).to receive(:clear_cache)
- end
+ it 'clears the cache with Gitlab::Pages::CacheControl' do
+ caches.each do |cache_type, cache_id|
+ expect_next_instance_of(Gitlab::Pages::CacheControl, type: cache_type, id: cache_id) do |cache_control|
+ expect(cache_control).to receive(:clear_cache)
+ end
+ end
- expect_next_instance_of(Gitlab::Pages::CacheControl, type: :namespace, id: 3) do |cache_control|
- expect(cache_control).to receive(:clear_cache)
+ subject
end
-
- subject
end
+
+ it_behaves_like 'clears caches with',
+ event_class: Pages::PageDeployedEvent,
+ event_data: { project_id: 1, namespace_id: 2, root_namespace_id: 3 },
+ caches: { namespace: 3, project: 1 }
+
+ it_behaves_like 'clears caches with',
+ event_class: Pages::PageDeletedEvent,
+ event_data: { project_id: 1, namespace_id: 2, root_namespace_id: 3 },
+ caches: { namespace: 3, project: 1 }
+
+ it_behaves_like 'clears caches with',
+ event_class: Projects::ProjectDeletedEvent,
+ event_data: { project_id: 1, namespace_id: 2, root_namespace_id: 3 },
+ caches: { namespace: 3, project: 1 }
end
diff --git a/vendor/gems/ipynbdiff/Gemfile.lock b/vendor/gems/ipynbdiff/Gemfile.lock
index a5e8e3e4e86..288a31ce75d 100644
--- a/vendor/gems/ipynbdiff/Gemfile.lock
+++ b/vendor/gems/ipynbdiff/Gemfile.lock
@@ -3,18 +3,21 @@ PATH
specs:
ipynbdiff (0.4.7)
diffy (~> 3.3)
- json (~> 2.5, >= 2.5.1)
+ oj (~> 3.13.16)
GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
+ benchmark-memory (0.2.0)
+ memory_profiler (~> 1)
binding_ninja (0.2.3)
coderay (1.1.3)
diff-lcs (1.5.0)
diffy (3.4.2)
- json (2.6.2)
+ memory_profiler (1.0.0)
method_source (1.0.0)
+ oj (3.13.16)
parser (3.1.2.0)
ast (~> 2.4.1)
proc_to_ast (0.1.0)
@@ -37,7 +40,7 @@ GEM
rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0)
- rspec-parameterized (0.5.1)
+ rspec-parameterized (0.5.2)
binding_ninja (>= 0.2.3)
parser
proc_to_ast
@@ -53,6 +56,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
+ benchmark-memory (~> 0.2.0)
bundler (~> 2.2)
ipynbdiff!
pry (~> 0.14)
diff --git a/vendor/gems/ipynbdiff/ipynbdiff.gemspec b/vendor/gems/ipynbdiff/ipynbdiff.gemspec
index 8f4dfef8177..3054118ea47 100644
--- a/vendor/gems/ipynbdiff/ipynbdiff.gemspec
+++ b/vendor/gems/ipynbdiff/ipynbdiff.gemspec
@@ -23,11 +23,12 @@ Gem::Specification.new do |s|
s.require_paths = ['lib']
s.add_runtime_dependency 'diffy', '~> 3.3'
- s.add_runtime_dependency 'json', '~> 2.5', '>= 2.5.1'
+ s.add_runtime_dependency 'oj', '~> 3.13.16'
s.add_development_dependency 'bundler', '~> 2.2'
s.add_development_dependency 'pry', '~> 0.14'
s.add_development_dependency 'rake', '~> 13.0'
s.add_development_dependency 'rspec', '~> 3.10'
s.add_development_dependency 'rspec-parameterized', '~> 0.5.1'
+ s.add_development_dependency 'benchmark-memory', '~>0.2.0'
end
diff --git a/vendor/gems/ipynbdiff/lib/ipynb_symbol_map.rb b/vendor/gems/ipynbdiff/lib/ipynb_symbol_map.rb
deleted file mode 100644
index 33e06aa8d18..00000000000
--- a/vendor/gems/ipynbdiff/lib/ipynb_symbol_map.rb
+++ /dev/null
@@ -1,218 +0,0 @@
-# frozen_string_literal: true
-
-module IpynbDiff
- class InvalidTokenError < StandardError
- end
-
- # Creates a symbol map for a ipynb file (JSON format)
- class IpynbSymbolMap
- class << self
- def parse(notebook, objects_to_ignore = [])
- IpynbSymbolMap.new(notebook, objects_to_ignore).parse('')
- end
- end
-
- attr_reader :current_line, :char_idx, :results
-
- WHITESPACE_CHARS = ["\t", "\r", ' ', "\n"].freeze
-
- VALUE_STOPPERS = [',', '[', ']', '{', '}', *WHITESPACE_CHARS].freeze
-
- def initialize(notebook, objects_to_ignore = [])
- @chars = notebook.chars
- @current_line = 0
- @char_idx = 0
- @results = {}
- @objects_to_ignore = objects_to_ignore
- end
-
- def parse(prefix = '.')
- raise_if_file_ended
-
- skip_whitespaces
-
- if (c = current_char) == '"'
- parse_string
- elsif c == '['
- parse_array(prefix)
- elsif c == '{'
- parse_object(prefix)
- else
- parse_value
- end
-
- results
- end
-
- def parse_array(prefix)
- # [1, 2, {"some": "object"}, [1]]
-
- i = 0
-
- current_should_be '['
-
- loop do
- raise_if_file_ended
-
- break if skip_beginning(']')
-
- new_prefix = "#{prefix}.#{i}"
-
- add_result(new_prefix, current_line)
-
- parse(new_prefix)
-
- i += 1
- end
- end
-
- def parse_object(prefix)
- # {"name":"value", "another_name": [1, 2, 3]}
-
- current_should_be '{'
-
- loop do
- raise_if_file_ended
-
- break if skip_beginning('}')
-
- prop_name = parse_string(return_value: true)
-
- next_and_skip_whitespaces
-
- current_should_be ':'
-
- next_and_skip_whitespaces
-
- if @objects_to_ignore.include? prop_name
- skip
- else
- new_prefix = "#{prefix}.#{prop_name}"
-
- add_result(new_prefix, current_line)
-
- parse(new_prefix)
- end
- end
- end
-
- def parse_string(return_value: false)
- current_should_be '"'
- init_idx = @char_idx
-
- loop do
- increment_char_index
-
- raise_if_file_ended
-
- if current_char == '"' && !prev_backslash?
- init_idx += 1
- break
- end
- end
-
- @chars[init_idx...@char_idx].join if return_value
- end
-
- def add_result(key, line_number)
- @results[key] = line_number
- end
-
- def parse_value
- increment_char_index until raise_if_file_ended || VALUE_STOPPERS.include?(current_char)
- end
-
- def skip_whitespaces
- while WHITESPACE_CHARS.include?(current_char)
- raise_if_file_ended
- check_for_new_line
- increment_char_index
- end
- end
-
- def increment_char_index
- @char_idx += 1
- end
-
- def next_and_skip_whitespaces
- increment_char_index
- skip_whitespaces
- end
-
- def current_char
- raise_if_file_ended
-
- @chars[@char_idx]
- end
-
- def prev_backslash?
- @chars[@char_idx - 1] == '\\' && @chars[@char_idx - 2] != '\\'
- end
-
- def current_should_be(another_char)
- raise InvalidTokenError unless current_char == another_char
- end
-
- def check_for_new_line
- @current_line += 1 if current_char == "\n"
- end
-
- def raise_if_file_ended
- @char_idx >= @chars.size && raise(InvalidTokenError)
- end
-
- def skip
- raise_if_file_ended
-
- skip_whitespaces
-
- if (c = current_char) == '"'
- parse_string
- elsif c == '['
- skip_array
- elsif c == '{'
- skip_object
- else
- parse_value
- end
- end
-
- def skip_array
- loop do
- raise_if_file_ended
-
- break if skip_beginning(']')
-
- skip
- end
- end
-
- def skip_object
- loop do
- raise_if_file_ended
-
- break if skip_beginning('}')
-
- parse_string
-
- next_and_skip_whitespaces
-
- current_should_be ':'
-
- next_and_skip_whitespaces
-
- skip
- end
- end
-
- def skip_beginning(closing_char)
- check_for_new_line
-
- next_and_skip_whitespaces
-
- return true if current_char == closing_char
-
- next_and_skip_whitespaces if current_char == ','
- end
- end
-end
diff --git a/vendor/gems/ipynbdiff/lib/output_transformer.rb b/vendor/gems/ipynbdiff/lib/output_transformer.rb
index e7adfbd8c3e..57e8a9edce3 100644
--- a/vendor/gems/ipynbdiff/lib/output_transformer.rb
+++ b/vendor/gems/ipynbdiff/lib/output_transformer.rb
@@ -14,7 +14,7 @@ module IpynbDiff
'stream' => %w[text]
}.freeze
- def initialize(hide_images: false)
+ def initialize(hide_images = false)
@hide_images = hide_images
end
diff --git a/vendor/gems/ipynbdiff/lib/symbol_map.rb b/vendor/gems/ipynbdiff/lib/symbol_map.rb
new file mode 100644
index 00000000000..89cbccbed1b
--- /dev/null
+++ b/vendor/gems/ipynbdiff/lib/symbol_map.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module IpynbDiff
+ require 'oj'
+
+ # Creates a map from a symbol to the line number it appears in a Json file
+ #
+ # Example:
+ #
+ # Input:
+ #
+ # 1. {
+ # 2. 'obj1': [
+ # 3. {
+ # 4. 'obj2': 5
+ # 5. },
+ # 6. 3,
+ # 7. {
+ # 8. 'obj3': {
+ # 9. 'obj4': 'b'
+ # 10. }
+ # 11. }
+ # 12. ]
+ # 13.}
+ #
+ # Output:
+ #
+ # Symbol Line Number
+ # .obj1 -> 2
+ # .obj1.0 -> 3
+ # .obj1.0 -> 3
+ # .obj1.0.obj2 -> 4
+ # .obj1.1 -> 6
+ # .obj1.2 -> 7
+ # .obj1.2.obj3 -> 8
+ # .obj1.2.obj3.obj4 -> 9
+ #
+ class SymbolMap
+ class << self
+ def handler
+ @handler ||= SymbolMap.new
+ end
+
+ def parser
+ @parser ||= Oj::Parser.new(:saj).tap { |p| p.handler = handler }
+ end
+
+ def parse(notebook, *args)
+ handler.reset
+ parser.parse(notebook)
+ handler.symbols
+ end
+ end
+
+ attr_accessor :symbols
+
+ def hash_start(key, line, column)
+ add_symbol(key_or_index(key), line)
+ end
+
+ def hash_end(key, line, column)
+ @current_path.pop
+ end
+
+ def array_start(key, line, column)
+ @current_array_index << 0
+
+ add_symbol(key, line)
+ end
+
+ def array_end(key, line, column)
+ @current_path.pop
+ @current_array_index.pop
+ end
+
+ def add_value(value, key, line, column)
+ add_symbol(key_or_index(key), line)
+
+ @current_path.pop
+ end
+
+ def add_symbol(symbol, line)
+ @symbols[@current_path.append(symbol).join('.')] = line if symbol
+ end
+
+ def key_or_index(key)
+ if key.nil? # value in an array
+ if @current_path.empty?
+ @current_path = ['']
+ return nil
+ end
+
+ symbol = @current_array_index.last
+ @current_array_index[-1] += 1
+ symbol
+ else
+ key
+ end
+ end
+
+ def reset
+ @current_path = []
+ @symbols = {}
+ @current_array_index = []
+ end
+ end
+end
diff --git a/vendor/gems/ipynbdiff/lib/transformer.rb b/vendor/gems/ipynbdiff/lib/transformer.rb
index 64d59eeaea8..1b2c63bb35c 100644
--- a/vendor/gems/ipynbdiff/lib/transformer.rb
+++ b/vendor/gems/ipynbdiff/lib/transformer.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module IpynbDiff
+ require 'oj'
+
class InvalidNotebookError < StandardError
end
@@ -10,26 +12,25 @@ module IpynbDiff
require 'yaml'
require 'output_transformer'
require 'symbolized_markdown_helper'
- require 'ipynb_symbol_map'
+ require 'symbol_map'
require 'transformed_notebook'
include SymbolizedMarkdownHelper
@include_frontmatter = true
- @objects_to_ignore = ['application/javascript', 'application/vnd.holoviews_load.v0+json']
def initialize(include_frontmatter: true, hide_images: false)
@include_frontmatter = include_frontmatter
@hide_images = hide_images
- @out_transformer = OutputTransformer.new(hide_images: hide_images)
+ @out_transformer = OutputTransformer.new(hide_images)
end
def validate_notebook(notebook)
- notebook_json = JSON.parse(notebook)
+ notebook_json = Oj::Parser.usual.parse(notebook)
return notebook_json if notebook_json.key?('cells')
raise InvalidNotebookError
- rescue JSON::ParserError
+ rescue EncodingError, Oj::ParseError, JSON::ParserError
raise InvalidNotebookError
end
@@ -38,7 +39,7 @@ module IpynbDiff
notebook_json = validate_notebook(notebook)
transformed = transform_document(notebook_json)
- symbol_map = IpynbSymbolMap.parse(notebook)
+ symbol_map = SymbolMap.parse(notebook)
TransformedNotebook.new(transformed, symbol_map)
end
diff --git a/vendor/gems/ipynbdiff/spec/benchmark.rb b/vendor/gems/ipynbdiff/spec/benchmark.rb
new file mode 100644
index 00000000000..99f088f2056
--- /dev/null
+++ b/vendor/gems/ipynbdiff/spec/benchmark.rb
@@ -0,0 +1,64 @@
+require 'ipynbdiff'
+require 'benchmark'
+require 'benchmark/memory'
+require_relative 'test_helper'
+
+large_cell = '{
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "24f32781-48bf-4378-b30c-ccdce7b05ba0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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\n",
+ "text/plain": [
+ "<Figure size 2000x600 with 1 Axes>"
+ ]
+ },
+ "metadata": {
+ "needs_background": "light"
+ },
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "do_plot(is_sin = False)"
+ ]
+},'
+
+base = '{
+ "cells": [
+ <<>>{
+ "cell_type": "markdown",
+ "id": "1",
+ "metadata": {
+ "tags": [
+ "hello",
+ "world"
+ ]
+ },
+ "source": [
+ "# A\n",
+ "\n",
+ "B"
+ ]
+ }
+ ],
+ "metadata": {
+ }
+}'
+
+SMALL_NOTEBOOK = base.gsub('<<>>', large_cell)
+LARGE_NOTEBOOK = base.gsub('<<>>', Array.new(100, large_cell).join("\n"))
+
+puts "Small Notebook: #{SMALL_NOTEBOOK.bytesize}"
+puts "Large Notebook: #{LARGE_NOTEBOOK.bytesize}"
+
+def cases(benchmark_runner)
+ benchmark_runner.report('small_notebook') { IpynbDiff.transform(SMALL_NOTEBOOK) }
+ benchmark_runner.report('large_notebook') { IpynbDiff.transform(LARGE_NOTEBOOK) }
+end
+
+Benchmark.benchmark { |x| cases(x) }
+Benchmark.memory { |x| cases(x) }
diff --git a/vendor/gems/ipynbdiff/spec/ipynb_symbol_map_spec.rb b/vendor/gems/ipynbdiff/spec/ipynb_symbol_map_spec.rb
deleted file mode 100644
index a002fc370f5..00000000000
--- a/vendor/gems/ipynbdiff/spec/ipynb_symbol_map_spec.rb
+++ /dev/null
@@ -1,165 +0,0 @@
-# frozen_string_literal: true
-
-require 'rspec'
-require 'json'
-require 'rspec-parameterized'
-require 'ipynb_symbol_map'
-
-describe IpynbDiff::IpynbSymbolMap do
- def res(*cases)
- cases&.to_h || []
- end
-
- describe '#parse_string' do
- using RSpec::Parameterized::TableSyntax
-
- let(:mapper) { IpynbDiff::IpynbSymbolMap.new(input) }
-
- where(:input, :result) do
- # Empty string
- '""' | ''
- # Some string with quotes
- '"he\nll\"o"' | 'he\nll\"o'
- end
-
- with_them do
- it { expect(mapper.parse_string(return_value: true)).to eq(result) }
- it { expect(mapper.parse_string).to be_nil }
- it { expect(mapper.results).to be_empty }
- end
-
- it 'raises if invalid string' do
- mapper = IpynbDiff::IpynbSymbolMap.new('"')
-
- expect { mapper.parse_string }.to raise_error(IpynbDiff::InvalidTokenError)
- end
-
- end
-
- describe '#parse_object' do
- using RSpec::Parameterized::TableSyntax
-
- let(:mapper) { IpynbDiff::IpynbSymbolMap.new(notebook, objects_to_ignore) }
-
- before do
- mapper.parse_object('')
- end
-
- where(:notebook, :objects_to_ignore, :result) do
- # Empty object
- '{ }' | [] | res
- # Object with string
- '{ "hello" : "world" }' | [] | res(['.hello', 0])
- # Object with boolean
- '{ "hello" : true }' | [] | res(['.hello', 0])
- # Object with integer
- '{ "hello" : 1 }' | [] | res(['.hello', 0])
- # Object with 2 properties in the same line
- '{ "hello" : "world" , "my" : "bad" }' | [] | res(['.hello', 0], ['.my', 0])
- # Object with 2 properties in the different lines line
- "{ \"hello\" : \"world\" , \n \n \"my\" : \"bad\" }" | [] | res(['.hello', 0], ['.my', 2])
- # Object with 2 properties, but one is ignored
- "{ \"hello\" : \"world\" , \n \n \"my\" : \"bad\" }" | ['hello'] | res(['.my', 2])
- end
-
- with_them do
- it { expect(mapper.results).to include(result) }
- end
- end
-
- describe '#parse_array' do
- using RSpec::Parameterized::TableSyntax
-
- where(:notebook, :result) do
- # Empty Array
- '[]' | res
- # Array with string value
- '["a"]' | res(['.0', 0])
- # Array with boolean
- '[ true ]' | res(['.0', 0])
- # Array with integer
- '[ 1 ]' | res(['.0', 0])
- # Two values on the same line
- '["a", "b"]' | res(['.0', 0], ['.1', 0])
- # With line breaks'
- "[\n \"a\" \n , \n \"b\" ]" | res(['.0', 1], ['.1', 3])
- end
-
- let(:mapper) { IpynbDiff::IpynbSymbolMap.new(notebook) }
-
- before do
- mapper.parse_array('')
- end
-
- with_them do
- it { expect(mapper.results).to match_array(result) }
- end
- end
-
- describe '#skip_object' do
- subject { IpynbDiff::IpynbSymbolMap.parse(JSON.pretty_generate(source)) }
- end
-
- describe '#parse' do
-
- let(:objects_to_ignore) { [] }
-
- subject { IpynbDiff::IpynbSymbolMap.parse(JSON.pretty_generate(source), objects_to_ignore) }
-
- context 'Empty object' do
- let(:source) { {} }
-
- it { is_expected.to be_empty }
- end
-
- context 'Object with inner object and number' do
- let(:source) { { obj1: { obj2: 1 } } }
-
- it { is_expected.to match_array(res(['.obj1', 1], ['.obj1.obj2', 2])) }
- end
-
- context 'Object with inner object and number, string and array with object' do
- let(:source) { { obj1: { obj2: [123, 2, true], obj3: "hel\nlo", obj4: true, obj5: 123, obj6: 'a' } } }
-
- it do
- is_expected.to match_array(
- res(['.obj1', 1],
- ['.obj1.obj2', 2],
- ['.obj1.obj2.0', 3],
- ['.obj1.obj2.1', 4],
- ['.obj1.obj2.2', 5],
- ['.obj1.obj3', 7],
- ['.obj1.obj4', 8],
- ['.obj1.obj5', 9],
- ['.obj1.obj6', 10])
- )
- end
- end
-
- context 'When index is exceeded because of failure' do
- it 'raises an exception' do
- source = '{"\\a": "a\""}'
-
- mapper = IpynbDiff::IpynbSymbolMap.new(source)
-
- expect(mapper).to receive(:prev_backslash?).at_least(1).time.and_return(false)
-
- expect { mapper.parse('') }.to raise_error(IpynbDiff::InvalidTokenError)
- end
- end
-
- context 'Object with inner object and number, string and array with object' do
- let(:source) { { obj1: { obj2: [123, 2, true], obj3: "hel\nlo", obj4: true, obj5: 123, obj6: { obj7: 'a' } } } }
- let(:objects_to_ignore) { %w(obj2 obj6) }
- it do
- is_expected.to match_array(
- res(['.obj1', 1],
- ['.obj1.obj3', 7],
- ['.obj1.obj4', 8],
- ['.obj1.obj5', 9],
- )
- )
- end
- end
- end
-end
diff --git a/vendor/gems/ipynbdiff/spec/symbol_map_spec.rb b/vendor/gems/ipynbdiff/spec/symbol_map_spec.rb
new file mode 100644
index 00000000000..5fba47c85af
--- /dev/null
+++ b/vendor/gems/ipynbdiff/spec/symbol_map_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'rspec'
+require 'json'
+require 'rspec-parameterized'
+require 'symbol_map'
+
+describe IpynbDiff::SymbolMap do
+ def res(*cases)
+ cases&.to_h || []
+ end
+
+ describe '#parse' do
+ subject { IpynbDiff::SymbolMap.parse(JSON.pretty_generate(source)) }
+
+ context 'Object with blank key' do
+ let(:source) { { "": { "": 5 } }}
+
+ it { is_expected.to match_array(res([".", 2], ["..", 3])) }
+ end
+
+ context 'Empty object' do
+ let(:source) { {} }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'Empty array' do
+ let(:source) { [] }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'Object with inner object and number' do
+ let(:source) { { obj1: { obj2: 1 } } }
+
+ it { is_expected.to match_array(res( ['.obj1', 2], ['.obj1.obj2', 3])) }
+ end
+
+ context 'Object with inner object and number, string and array with object' do
+ let(:source) { { obj1: { obj2: [123, 2, true], obj3: "hel\nlo", obj4: true, obj5: 123, obj6: 'a' } } }
+
+ it do
+ is_expected.to match_array(
+ res(['.obj1', 2],
+ ['.obj1.obj2', 3],
+ ['.obj1.obj2.0', 4],
+ ['.obj1.obj2.1', 5],
+ ['.obj1.obj2.2', 6],
+ ['.obj1.obj3', 8],
+ ['.obj1.obj4', 9],
+ ['.obj1.obj5', 10],
+ ['.obj1.obj6', 11])
+ )
+ end
+ end
+ end
+end
diff --git a/vendor/gems/ipynbdiff/spec/test_helper.rb b/vendor/gems/ipynbdiff/spec/test_helper.rb
new file mode 100644
index 00000000000..f9c416885a1
--- /dev/null
+++ b/vendor/gems/ipynbdiff/spec/test_helper.rb
@@ -0,0 +1,23 @@
+BASE_PATH = File.join(File.expand_path(File.dirname(__FILE__)), 'testdata')
+
+FROM_PATH = File.join(BASE_PATH, 'from.ipynb')
+TO_PATH = File.join(BASE_PATH, 'to.ipynb')
+
+FROM_IPYNB = File.read(FROM_PATH)
+TO_IPYNB = File.read(TO_PATH)
+
+def input_for_test(test_case)
+ File.join(BASE_PATH, test_case, 'input.ipynb')
+end
+
+def expected_symbols(test_case)
+ File.join(BASE_PATH, test_case, 'expected_symbols.txt')
+end
+
+def expected_md(test_case)
+ File.join(BASE_PATH, test_case, 'expected.md')
+end
+
+def expected_line_numbers(test_case)
+ File.join(BASE_PATH, test_case, 'expected_line_numbers.txt')
+end
diff --git a/vendor/gems/ipynbdiff/spec/testdata/from.ipynb b/vendor/gems/ipynbdiff/spec/testdata/from.ipynb
index a731c9bfffd..68a4b11cbbc 100644
--- a/vendor/gems/ipynbdiff/spec/testdata/from.ipynb
+++ b/vendor/gems/ipynbdiff/spec/testdata/from.ipynb
@@ -57,8 +57,7 @@
"tags": [
"senoid"
]
- },
- "outputs": [
+ }, "outputs": [
{
"data": {
"text/plain": [
diff --git a/vendor/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt b/vendor/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt
new file mode 100644
index 00000000000..62e35deb96d
--- /dev/null
+++ b/vendor/gems/ipynbdiff/spec/testdata/text_png_output/expected_line_numbers.txt
@@ -0,0 +1,14 @@
+3
+
+36
+37
+38
+39
+40
+
+
+12
+
+16
+
+25
diff --git a/vendor/gems/ipynbdiff/spec/transformer_spec.rb b/vendor/gems/ipynbdiff/spec/transformer_spec.rb
index 31acfe85359..c5873906ca9 100644
--- a/vendor/gems/ipynbdiff/spec/transformer_spec.rb
+++ b/vendor/gems/ipynbdiff/spec/transformer_spec.rb
@@ -5,10 +5,10 @@ require 'ipynbdiff'
require 'json'
require 'rspec-parameterized'
-BASE_PATH = File.join(File.expand_path(File.dirname(__FILE__)), 'testdata')
+TRANSFORMER_BASE_PATH = File.join(File.expand_path(File.dirname(__FILE__)), 'testdata')
def read_file(*paths)
- File.read(File.join(BASE_PATH, *paths))
+ File.read(File.join(TRANSFORMER_BASE_PATH, *paths))
end
def default_config
@@ -68,12 +68,27 @@ describe IpynbDiff::Transformer do
expect(transformed.as_text).to eq expected_md
end
- it 'generates the expected symbol map' do
- expect(transformed.blocks.map { |b| b[:source_symbol] }.join("\n")).to eq expected_symbols
+ it 'marks the lines correctly' do
+ blocks = transformed.blocks.map { |b| b[:source_symbol] }.join("\n")
+ result = expected_symbols
+
+ expect(blocks).to eq result
end
end
end
+ it 'generates the correct transformed to source line map' do
+ input = read_file('text_png_output', 'input.ipynb' )
+ expected_line_numbers = read_file('text_png_output', 'expected_line_numbers.txt' )
+
+ transformed = IpynbDiff::Transformer.new(**{ include_frontmatter: false }).transform(input)
+
+ line_numbers = transformed.blocks.map { |b| b[:source_line] }.join("\n")
+
+ expect(line_numbers).to eq(expected_line_numbers)
+
+ end
+
context 'When the notebook is invalid' do
[
['because the json is invalid', 'a'],