diff options
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": "iVBORw0KGgoAAAANSUhEUgAABj0AAAHwCAYAAAD91q10AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/MnkTPAAAACXBIWXMAAA9hAAAPYQGoP6dpAABPSUlEQVR4nO39eZCfd34f+L2fxkmAAAiCBO/7PobHkCAxIEjcV/eWHTl/yOVKvFaloo3kTZXjlKUa2bVS4tiyna2JElmudaUqK+0m9m4ceVVxutEAiIMXeIEEObyG4PC+QPAEQIA4+8kfD1pf/DgkhwC78XT/+vWqYmHQnybxLml6COCNz/dT1XUdAAAAAACA8a6n7QAAAAAAAAAjQekBAAAAAAB0BaUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BUmtx3g66qqqpJcnGR/21kAAAAAAIAxYVaSD+q6rr/rk8Zc6ZGm8Hiv7RAAAAAAAMCYcmmS97/rE8Zi6bE/Sd59993Mnj277SwAAAAAAECL9u3bl8suuyz5Hi9EjcXSI0kye/ZspQcAAAAAAPC9OWQOAAAAAAB0BaUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BWUHgAAAAAAQFdQegAAAAAAAF1B6QEAAAAAAHQFpQcAAAAAANAVlB4AAAAAAEBXUHoAAAAAAABdQekBAAAAAAB0BaUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXeGUSo+qqn5aVdXTVVXtr6pqT1VVf1VV1Q1f+5w/r6qq/tpfT4xsbAAAAAAA6DKffJJs2dJ2inFt8il+/pIkf5bk6RN/7z9LsrGqqpvruj5w0ucNJvmtk75/5AelBAAAAACAblPXyXPPJf39ycBA8sQTyfTpyaefJmed1Xa6cemUSo+6rtee/P2qqn4ryZ4kdyV5+KTR4bqud//weAAAAAAA0EX27082by5FxwcfdM6vvz55773kuuvayTfOneqmx9fNOfHtZ1/7+NKqqvYk+SLJQ0n+cV3Xe77pH1BV1bQk00760KwfmAkAAAAAAMaO115rSo7+/uThh5MjJz2ONGNGsnJl0teX9PYml17aXs4ucNqlR1VVVZKfJXm0rusXTxqtT/Ifkryd5Kok/zTJlqqq7qrr+vA3/KN+muQPTzcHAAAAAACMKYcPN+XG8DbHa691zq+5pik5+vqSBx5onrRiRFR1XZ/e31hVf5akL8niuq7f+47PuyhNAfK367r+j98w/6ZNj/f27t2b2bNnn1Y2AAAAAAA4o95/vyk4BgaSTZuSAyedwZ4ypSk3enubouP665Oqai/rOLNv377MmTMnSebUdb3vuz73tDY9qqr60yR/I8kD31V4JEld1x9WVfV2km98gOzE9sdfb4BU/h8NAAAAAMBYd/x48tRT5dmq557rnF94YSk5Vq5M/CH/M+KUSo8TT1r9aZLfSLK0rus3v8ffMy/JZUk+PK2EAAAAAAAwFnz2WbJhQ1NyDA4mn35aZlWV3HNPebbqjjuSnp7Wok5Up7rp8WdJ/k6Sv5lkf1VVF574+N66rr+qqursJH+U5C/TlBxXJvnnST5J8j+NRGAAAAAAADgj6jp54YXmyar+/mT79mRoqMznzEnWrGlKjrVrk/nz28tKklMvPX7nxLfbvvbx30ry50mOJ/lRkr+b5Jw0xcfWJL9Z1/X+0w0JAAAAAABnxIEDyZYt5Qj5u+92zm+5pWxzLFqUTD6tKxKMklP6/0Zd1995cKOu66+SrPlBiQAAAAAA4Ex6441ym2PbtuTw4TKbPj1ZsaIpOXp7kyuuaC0mv54KCgAAAACAieXo0eTRR0vR8YtfdM6vuKJscyxblpx1Vjs5OWVKDwAAAAAAut/u3cn69U3JsWlTsm9fmU2alCxeXIqOm25qDpMz7ig9AAAAAADoPkNDyY4d5Qj5jh2d8/PPb56r6u1NVq9OzjmnlZiMLKUHAAAAAADdYe/eZOPGpuRYvz7Zs6dzfvfdTcnR19f8556ednIyapQeAAAAAACMT3WdvPJKU3IMDDR3Oo4dK/NZs5otjr6+ZN265MIL28vKGaH0AAAAAABg/Pjqq2TbtnKE/K23Ouc33lhuc9x3XzJ1ahspaYnSAwAAAACAse2dd8o2x+bNTfExbNq0ZNmy8mzV1Ve3l5PWKT0AAAAAABhbjh1LHn+8bHO8+GLn/NJLyzbH8uXJzJnt5GTMUXoAAAAAANC+jz9OBgebkmPDhuSLL8qspydZtKgUHbfemlRVa1EZu5QeAAAAAACceXWd7NxZtjmeeqr52LB585K1a5uSY82a5Nxz28vKuKH0AAAAAADgzNi/P9m0qbnNMTCQfPhh5/yOO8o2xz33JJMmtRKT8UvpAQAAAADA6Nm1q2xzPPxwcvRomc2cmaxa1ZQc69Yll1zSXk66gtIDAAAAAICRc/hw8tBDTckxMJD88ped82uvLdscDzyQTJvWTk66ktIDAAAAAIAf5v33m4Kjvz958MHkwIEymzIlWbKkFB3XXddeTrqe0gMAAAAAgFNz/Hjy5JPl2arnn++cX3RR0tvblBwrVyazZrWTkwlH6QEAAAAAwK/32WfJ4GBTcgwONt8fVlXJvfeWbY477mg+BmeY0gMAAAAAgF9V18kLL5RtjscfT4aGyvycc5K1a5uNjrVrk/PPby0qDFN6AAAAAADQOHAg2by5HCF/773O+a23lm2On/wkmey3mBlb/DcSAAAAAGAie/31coR827bk8OEyO+usZMWKZpujtze54orWYsL3ofQAAAAAAJhIjhxJHn20PFv16qud8yuvLNscS5c2xQeME0oPAAAAAIBut3t3s80xMJBs3Jjs319mkycnixeXouPGGx0hZ9xSegAAAAAAdJuhoWTHjrLN8cwznfP588uTVatXJ3PmtJMTRpjSAwAAAACgG3zxRbPFMTCQrF+f7NnTOb/77rLNcdddSU9PKzFhNCk9AAAAAADGo7pOXnmlbHM8+mhy/HiZz57dbHH09ibr1iUXXtheVjhDlB4AAAAAAOPFV18lW7c2JcfAQPLWW53zG28s2xz33ZdMndpKTGiL0gMAAAAAYCx7++1ScmzZ0hQfw6ZNS5Yta7Y5+vqSq69uLyeMAUoPAAAAAICx5NixZPv28mzVSy91zi+9tGxzLF+ezJzZTk4Yg5QeAAAAAABt+/jj5vh4f3+yYUOyd2+Z9fQkixaVouPWW5Oqai8rjGFKDwAAAACAM21oKNm5s2xzPP10c5h82Lx5zfHxvr7mGPm557aXFcYRpQcAAAAAwJmwb1/y4IPlPsfu3Z3zO+8stznuuSeZNKmdnDCOKT0AAAAAAEZDXSe7dpVtjkceSY4eLfOZM5NVq5qSo7c3ufji9rJCl1B6AAAAAACMlEOHkoceKtscr7/eOb/uunKb4/77k2nT2skJXUrpAQAAAADwQ7z3XlNw9Pc3z1cdPFhmU6cmS5aUZ6uuu669nDABKD0AAAAAAE7F8ePJE0+UZ6t+/vPO+cUXl5Jj5crk7LPbyQkTkNIDAAAAAODX+fTTZHCw2egYHEw++6zMqipZuLA8W3X77c3HgDNO6QEAAAAA8HV13WxwDG9zPPFEMjRU5nPnJmvXNhsda9cm553XXlbgryk9AAAAAACS5Msvk82bm22OgYHmVsfJfvSjss2xcGEy2W+vwljjqxIAAAAAmLhef71sc2zblhw5UmZnndXc5Ojtbf66/PLWYgLfj9IDAAAAAJg4jhxJHnmkFB27dnXOr7qqbHMsXZpMn95KTOD0KD0AAAAAgO724YflyapNm5L9+8ts8uTk/vubTY6+vuTGGx0hh3FM6QEAAAAAdJehoeTpp8s2x7PPds7nzy8lx6pVyZw57eQERpzSAwAAAAAY/774ItmwoSk5BgeTjz/unC9Y0JQcvb3JXXclPT2txARGl9IDAAAAABh/6jp56aXmyar+/uSxx5Ljx8t89uxk9eqm6Fi3LrnggvayAmeM0gMAAAAAGB8OHky2bm1KjoGB5O23O+c33VSOkN93XzJlSjs5gdYoPQAAAACAseutt8ptjq1bk0OHymzatGT58vJs1VVXtRYTGBuUHgAAAADA2HH0aLJ9eyk6Xn65c37ZZWWbY/nyZMaMdnICY5LSAwAAAABo1549yfr1TcmxcWOyd2+ZTZqULFpUio5bbkmqqr2swJim9AAAAAAAzqyhoeTZZ8s2x44dzWHyYeed1xwf7+1N1qxJ5s5tLyswrig9AAAAAIDRt29fsmlTOUL+0Ued8x//uCk5+vqSBQuaDQ+AU6T0AAAAAABGXl0nr75atjkeeSQ5dqzMzz47WbWqKTnWrUsuvri9rEDXUHoAAAAAACPj0KFk27ayzfHGG53z668v2xz3359Mm9ZKTKB7KT0AAAAAgNP37rtNwdHfn2zenBw8WGZTpyZLl5ai49prW4sJTAxKDwAAAADg+zt2LHniifJs1QsvdM4vvrgpOPr6khUrmmesAM4QpQcAAAAA8N0++STZsKEpOQYHk88/L7OenmThwrLNcfvtSVW1lxWY0JQeAAAAAECnuk6ef75sczz5ZDI0VOZz5yZr1zYlx5o1yXnntZcV4CRKDwAAAAAg+fLL5MEHyxHyDz7onN92W1Ny9PY2mx2T/dYiMPb4XyYAAAAAmKh++cuyzfHQQ8mRI2U2Y0Zzk2O46LjssvZyAnxPSg8AAAAAmCiOHEkefrhsc+za1Tm/+upyhHzJkmT69HZyApwmpQcAAAAAdLMPPkjWr2+Kjk2bmmeshk2enNx/fyk6brjBEXJgXFN6AAAAAEA3OX48efrp8mzVzp2d8wsuaJ6r6utLVq1KZs9uJyfAKFB6AAAAAMB49/nnyYYNTckxOJh88kmZVVWyYEG5zfHjHyc9Pe1lBRhFSg8AAAAAGG/qOnnppbLNsX17s+ExbM6cZM2apuRYty6ZP7+9rABnkNIDAAAAAMaDgweTLVvKEfJ33umc33xzuc2xaFEyZUo7OQFapPQAAAAAgLHqzTdLybF1a3LoUJlNn54sX16erbryytZiAowVSg8AAAAAGCuOHk0efbQpOfr7k1de6ZxffnnZ5li2LJkxo52cAGOU0gMAAAAA2vTRR8n69U3JsXFjsm9fmU2alNx3Xyk6br65OUwOwDdSegAAAADAmTQ0lDzzTDlCvmNH5/z885vj4319yapVydy57eQEGIeUHgAAAAAw2vbuTTZtakqO9eub7Y6T3XVXc5ejry9ZsCDp6WknJ8A4p/QAAAAAgJFW18kvflG2OR59NDl2rMxnzWq2OPr6mq2Oiy5qLytAF1F6AAAAAMBIOHQo2bq1HCF/883O+Q03NCVHb29y//3J1Knt5AToYkoPAAAAADhd775btjk2b06++qrMpk5Nli4tR8ivuaa1mAATxSmVHlVV/TTJ30pyY5KvkmxP8vt1Xb960udUSf4wyW8nmZvkySR/v67rl0YqNAAAAAC04tix5PHHyzbHCy90zi+5pGxzrFiRnH12OzkBJqhT3fRYkuTPkjx94u/9Z0k2VlV1c13XB058zu8l+YdJ/l6SXUn+SZJNVVXdUNf1/hFJDQAAAABnyiefJIODTcmxYUPy+edl1tOTLFxYtjluuy2pqvayAkxwVV3Xp/83V9X5SfYkWVLX9cMntjw+SPIndV3/yxOfMy3JR2k2Qv7t9/hnzk6yd+/evZk9e/ZpZwMAAACA01LXyXPPNSXHwEDyxBPNx4ade26ydm1TcqxZk8yb11pUgIlg3759mTNnTpLMqet633d97g+96THnxLefnfj2qiQXJtk4/Al1XR+uquqhJIuS/ErpcaIUmXbSh2b9wEwAAAAAcGr2729ucgwXHR980Dm/7bayzXHvvclkp3IBxqLT/l/nE1sdP0vyaF3XL5748IUnvv3oa5/+UZIrvuUf9dM0N0AAAAAA4Mx57bVyhPzhh5MjR8psxoxk5cpyn+PSS9vLCcD39kMq6X+d5LYki79h9vU3s6pv+NiwP05TngybleS9H5ALAAAAAH7V4cNNuTG8zfHaa53za64pJceSJcn06e3kBOC0nVbpUVXVnyb5G0keqOv65IJi94lvL0zy4Ukfn59f3f5I0jx/leTwSf/s04kEAAAAAL/q/feT9eubouPBB5MvvyyzyZOTBx4oz1Zdf70j5ADj3CmVHieetPrTJL+RZGld129+7VPeTFN8rEqy88TfMzXJkiS//4PTAgAAAMB3OX48eeqp8mzVc891zi+8sNnk6Otrnq+aPbuVmACMjlPd9PizJH8nyd9Msr+qquEbHnvruv6qruu6qqo/SfIHVVW9luS1JH+Q5GCSfzdCmQEAAACg+OyzZMOGpuQYHEw+/bTMqiq5557ybNWddyY9Pe1lBWBUnWrp8Tsnvt32tY//VpI/P/Gf/1WSs5L8myRzkzyZZHVd1/tPLyIAAAAAnKSukxdeaO5y9Pcn27cnQ0NlPmdOsmZNU3SsXZvMn99eVgDOqKquv+2+eDuqqpqdZO/evXsz23ohAAAAAEly4ECyZUs5Qv7uu53zW24ptzkWLWrudQDQFfbt25c5c+YkyZy6rvd91+f6X38AAAAAxqY33ii3ObZtSw4fLrPp05MVK8qzVVdc0VpMAMYOpQcAAAAAY8ORI8ljj5Wi4xe/6JxfcUXZ5li2LDnrrHZyAjBmKT0AAAAAaM/u3cn69U3JsXFjsv+ks7CTJiWLF5ei46abmsPkAPAtlB4AAAAAnDlDQ8mOHeU2x44dnfPzz2+eq+rtTVavTs45p5WYAIxPSg8AAAAARtcXXySbNjVFx/r1yZ49nfO77irbHHffnfT0tBITgPFP6QEAAADAyKrr5JVXym2Oxx5Ljh0r81mzmi2Ovr5k3brkwgvbywpAV1F6AAAAAPDDffVVsm1bKTreeqtzfsMNZZtj8eJk6tQ2UgLQ5ZQeAAAAAJyed94pJceWLU3xMWzatGTp0qbk6O1NrrmmtZgATBxKDwAAAAC+n2PHku3byxHyF1/snF96aSk5VqxIZs5sJycAE5bSAwAAAIBv9/HHyeBgU3Rs2NAcJR/W05P85Cfl2aof/SipqtaiAoDSAwAAAICirpOdO8s2x5NPNh8bdu65zfHxvr7mGPm8ee1lBYCvUXoAAAAATHT79ycPPliKjg8/7JzffnvZ5rj33mTSpHZyAsCvofQAAAAAmIh27SpHyB9+ODl6tMxmzkxWrmxKjnXrmlsdADAOKD0AAAAAJoLDh5tyY7jo+OUvO+fXXFO2OZYsSaZNaycnAPwASg8AAACAbvX++81zVf39zfNVBw6U2ZQpyQMPlKLj+uvbywkAI0TpAQAAANAtjh9vDo8PFx3PPdc5v+iipLe3KTlWrkxmzWolJgCMFqUHAAAAwHj22WfJhg1NyTE4mHz6aZlVVXLPPWWb4447kp6e1qICwGhTegAAAACMJ3WdvPBCuc3x+OPJ0FCZn3NOsmZNU3KsXZucf35rUQHgTFN6AAAAAIx1Bw4kmzc3JcfAQPLee53zW29tSo7e3mTRomSy3/IBYGLyb0AAAACAsej118ttjm3bksOHy+yss5Lly0vRccUVrcUEgLFE6QEAAAAwFhw5kjz6aHm26tVXO+dXXllucyxd2hQfAEAHpQcAAABAW3bvbrY5BgaSjRuT/fvLbPLkZPHiZpOjry+56abmMDkA8K2UHgAAAABnytBQ8vTT5dmqZ57pnM+fn6xb15Qcq1cnc+a0kxMAximlBwAAAMBo+uKLZoujvz9Zvz75+OPO+d13l2er7ror6elpJSYAdAOlBwAAAMBIquvk5ZfLbY7HHkuOHy/z2bObLY7e3mar48IL28sKAF1G6QEAAADwQ331VbJ1ayk63n67c37jjWWb4777kqlT28kJAF1O6QEAAABwOt5+u5QcW7Ykhw6V2bRpybJl5Qj51Ve3lxMAJhClBwAAAMD3cexYsn17KTpeeqlzfumlZZtj+fJk5sx2cgLABKb0AAAAAPg2H3/cHB/v7082bEj27i2znp5k0aJSdNx6a1JV7WUFAJQeAAAAAH9taCjZuTMZGGiKjqeeag6TD5s3L1m7tik51qxJzj23vawAwK9QegAAAAAT2/79yaZNTckxMJDs3t05v+OOss1xzz3JpEmtxAQAfj2lBwAAADCx1HWya1fZ5nj44eTo0TKfOTNZtaopOdatSy65pL2sAMApUXoAAAAA3e/w4eShh8oR8tdf75xfd11TcvT2Jg88kEyb1k5OAOAHUXoAAAAA3en998uTVQ8+mBw4UGZTpiRLlpRnq667rr2cAMCIUXoAAAAA3eH48eTJJ8s2x/PPd84vvrjZ5OjrS1asSGbNaicnADBqlB4AAADA+PXZZ8ngYFNyDA423x9WVcnCheXZqjvuaD4GAHQtpQcAAAAwftR18vOfl2erHn88GRoq83POSdaubYqOtWuT885rLSoAcOYpPQAAAICx7cCBZPPmUnS8917n/Ec/Krc5Fi5MJvvtDgCYqPwsAAAAABh7Xn+93ObYti05cqTMzjorWbmyebKqtze5/PLWYgIAY4vSAwAAAGjfkSPJI4+UomPXrs75VVeVbY6lS5Pp01uJCQCMbUoPAAAAoB0ffpisX9+UHJs2Jfv3l9nkycn99zebHH19yY03OkIOAPxaSg8AAADgzBgaSp5+umxzPPts53z+/FJyrFqVzJnTTk4AYNxSegAAAACj54svkg0bmpJjcDD5+OPO+YIFTcnR25vcdVfS09NKTACgOyg9AAAAgJFT18nLL5dtjsceS44fL/PZs5PVq5uiY9265IIL2ssKAHQdpQcAAADwwxw8mGzd2pQcAwPJ2293zm+6qRwhv+++ZMqUdnICAF1P6QEAAACcurfeagqO/v5ky5bk0KEymzYtWbasFB1XXdVaTABgYlF6AAAAAL/e0aPJ9u3l2aqXX+6cX3ZZKTmWL09mzGgnJwAwoSk9AAAAgG+2Z0+yfn2z0bFhQ7J3b5lNmpQsWlSOkN96a1JV7WUFAIjSAwAAABg2NJTs3Fm2OZ5+ujlMPmzevOb4eF9fsmZNMndue1kBAL6B0gMAAAAmsn37kk2bmpJj/fpk9+7O+Z13lmerFixoNjwAAMYopQcAAABMJHWd7NpVtjkeeaS51zHs7LOTVauaJ6t6e5OLL24vKwDAKVJ6AAAAQLc7dCh56KFSdLzxRuf8uuvKNsf99yfTprWTEwDgB1J6AAAAQDd6772m4BgYSB58MDl4sMymTk2WLClHyK+7rr2cAAAjSOkBAAAA3eDYseTJJ8s2x89/3jm/+OKyzbFiRfOMFQBAl1F6AAAAwHj16afJ4GBTcmzYkHz2WZn19CQLFzabHH19ye23J1XVXlYAgDNA6QEAAADjRV0nzz/fPFnV35888UQyNFTmc+cma9c2JceaNcl557WXFQCgBUoPAAAAGMu+/DLZvLnc53j//c75bbeV2xwLFyaT/VIfAJi4/EwIAAAAxppf/rLc5njooeTIkTKbMaO5yTFcdFx2WXs5AQDGGKUHAAAAtO3IkeThh8uzVbt2dc6vvrocIV+yJJk+vZ2cAABjnNIDAAAA2vDhh6Xk2LSpecZq2OTJyQMPlG2OG25whBwA4HtQegAAAMCZcPx48vTT5TbHs892zi+4oCk4+vqSVauS2bPbyQkAMI4pPQAAAGC0fP55snFjU3SsX5988kmZVVWyYEHZ5vjxj5OenvayAgB0AaUHAAAAjJS6Tl56qRwh37692fAYNnt2smZNU3SsW5fMn99eVgCALqT0AAAAgB/i4MFky5Zyn+OddzrnN99cjpAvWpRMmdJOTgCACUDpAQAAAKfqrbfKNsfWrcmhQ2U2fXqyfHm5z3HllW2lBACYcJQeAAAA8OscPZo89lg5Qv7yy53zyy8v2xzLliUzZrSTEwBgglN6AAAAwDfZs6c5Pt7f3xwj37u3zCZNSu67r2xz3HJLc5gcAIBWKT0AAAAgSYaGkmefLc9W7djRHCYfdt55zfHxvr5k9epk7tz2sgIA8I2UHgAAAExc+/Y1WxwDA81fH33UOf/xj8uzVXff3Wx4AAAwZik9AAAAmDjqOnn11bLN8cgjybFjZX722cmqVU3JsW5dcvHF7WUFAOCUKT0AAADobocOJdu2lSPkb7zROb/++rLNcf/9ydSprcQEAOCHO+XSo6qqB5L8oyR3JbkoyW/Udf1XJ83/PMl//rW/7cm6rheefkwAAAA4Be++2xQc/f3J5s3JwYNlNnVqsnRpU3L09ibXXttaTAAARtbpbHrMTPJ8kv82yV9+y+cMJvmtk75/5DR+HAAAAPh+jh1LnniiPFv1wgud80suaQqOvr5kxYrmGSsAALrOKZcedV2vT7I+Saqq+rZPO1zX9e4fkAsAAAC+2yefJIODTcmxYUPy+edl1tOTLFxYnq267bbk238NCwBAlxitmx5Lq6rak+SLJA8l+cd1Xe/5pk+sqmpakmknfWjWKGUCAABgPKvr5Lnnym2OJ55oPjbs3HOTtWubjY61a5N581qLCgBAO0aj9Fif5D8keTvJVUn+aZItVVXdVdf14W/4/J8m+cNRyAEAAMB49+WXyYMPlqLjgw8657fdVrY57r03mTxaf7YPAIDxYMR/NljX9f940ndfrKpqR5oCpC/Jf/yGv+WPk/zspO/PSvLeSOcCAABgnHjttXKb4+GHkyMnnYmcMSNZubIcIb/00vZyAgAw5oz6H4Gp6/rDqqreTnLdt8wPJ/nrDZDvuBMCAABANzp8uCk3hrc5Xnutc3711WWbY8mSZPr0dnICADDmjXrpUVXVvCSXJflwtH8sAAAAxokPPmgKjv7+5vmqL78ss8mTkwceKEXH9dc7Qg4AwPdyyqVHVVVnJ7n2pA9dVVXVHUk+O/HXHyX5yzQlx5VJ/nmST5L8Tz8sKgAAAOPW8ePJU0+VbY6dOzvnF17YPFfV19c8XzV7djs5AQAY105n0+PuJFtP+v7wPY6/SPI7SX6U5O8mOSdN8bE1yW/Wdb3/9GMCAAAw7nz+ebJhQ1N0DA4mn3xSZlWVLFhQtjnuvDPp6WkvKwAAXeGUS4+6rrcl+a694jWnnQYAAIDxq66TF18sR8i3b0+Ghsp8zpxkzZqm5Fi7Npk/v72sAAB0pVG/6QEAAEAXO3gw2by53Od4993O+S23lGerFi1KpkxpJycAABOC0gMAAIBT8+abZZtj69bk8OEymz49Wb68KTl6e5Mrr2wtJgAAE4/SAwAAgO929Gjy6KNlm+OVVzrnV1xRbnMsXZrMmNFKTAAAUHoAAADwqz76KFm/vik5Nm5M9u0rs0mTkvvuK0XHzTc3h8kBAKBlSg8AAACag+PPPFOerdqxo3N+/vnJunXNk1WrVydz57aTEwAAvoPSAwAAYKLau7fZ4hgYaLY6Pvqoc37XXeU2x4IFSU9POzkBAOB7UnoAAABMFHXd3OMYvs3x6KPJsWNlPmtWsmpVU3SsW5dcdFF7WQEA4DQoPQAAALrZV18l27aVZ6veeqtzfsMN5TbH4sXJ1KltpAQAgBGh9AAAAOg277zTFBwDA8nmzU3xMWzq1GTZsvJs1TXXtJcTAABGmNIDAABgvDt2LHn88bLN8eKLnfNLL20Kjr6+ZMWKZObMdnICAMAoU3oAAACMRx9/nAwONiXHhg3JF1+UWU9P8pOflGerfvSjpKpaiwoAAGeK0gMAAGA8qOtk587ybNWTTzYfG3buuc3x8b6+ZPXqZN689rICAEBLlB4AAABj1f79yYMPlqLjww8757ffXrY57r03mTSpnZwAADBGKD0AAADGkl27Ssnx0EPJ0aNlNnNmsnJlOUJ+ySXt5QQAgDFI6QEAANCmw4eThx8uR8h/+cvO+bXXlpJjyZJk2rR2cgIAwDig9AAAADjT3n+/2eTo72+erzpwoMymTEkeeKA8W3X99e3lBACAcUbpAQAAMNqOH0+eeqpsczz3XOf8oouaTY6+vub5qlmzWokJAADjndIDAABgNHz2WbJhQ1NyDA4mn35aZlWV3HNP2ea4446kp6e1qAAA0C2UHgAAACOhrpMXXijPVm3fngwNlfk55yRr1jQlx9q1yfnntxYVAAC6ldIDAADgdB04kGzZ0pQcAwPJu+92zm+9tTxbtWhRMtkvwQAAYDT5GTcAAMCpeOONcptj27bk8OEyO+usZPnypuTo7U2uuKK1mAAAMBEpPQAAAL7LkSPJY4+VouMXv+icX3llKTmWLWuKDwAAoBVKDwAAgK/bvTtZv74pOTZuTPbvL7NJk5LFi8sR8ptuag6TAwAArVN6AAAADA0lO3aUbY5nnumcz5+frFvXlByrVjVHyQEAgDFH6QEAAExMX3zRbHEMDDRbHXv2dM7vvrs8W3X33UlPTysxAQCA70/pAQAATAx1nbzyStnmePTR5PjxMp81K1m9uik61q1LLrywvawAAMBpUXoAAADd66uvkq1bm5JjYCB5663O+Y03ltsc992XTJ3aSkwAAGBkKD0AAIDu8vbbpeTYsqUpPoZNm5YsW9Y8WdXXl1x9dXs5AQCAEaf0AAAAxrdjx5Lt28uzVS+91Dm/9NKyzbF8eTJzZjs5AQCAUaf0AAAAxp+PP26Oj/f3Jxs2JHv3lllPT7JoUSk6br01qar2sgIAAGeM0gMAABj7hoaSnTubJ6v6+5OnnmoOkw+bNy9Zu7YpOdasSc49t72sAABAa5QeAADA2LR/f7JpU7nPsXt35/yOO8o2xz33JJMmtRITAAAYO5QeAADA2FDXya5dZZvj4YeTo0fLfObMZNWqpuRYty655JL2sgIAAGOS0gMAAGjP4cPJQw+VI+Svv945v/bass3xwAPJtGnt5AQAAMYFpQcAAHBmvfde2ebYvDk5cKDMpkxJlixpSo7e3uT669vLCQAAjDtKDwAAYHQdP548+WTZ5nj++c75RRc1BUdfX7JyZTJrVjs5AQCAcU/pAQAAjLzPPksGB5uSY3Cw+f6wqkruvbc8W3XHHc3HAAAAfiClBwAA8MPVdfLznzclx8BA8vjjydBQmZ9zTrJmTVNyrF2bnH9+a1EBAIDupfQAAABOz4EDzU2O4aLjvfc657feWrY5fvKTZLJffgAAAKPLrzoAAIDv7/XXy22ObduSI0fK7KyzkhUryhHyyy9vLSYAADAxKT0AAIBvd+RI8sgjZZvj1Vc751ddVUqOpUub4gMAAKAlSg8AAKDThx82BcfAQLJpU7J/f5lNnpwsXlyerbrxRkfIAQCAMUPpAQAAE93QUPL00+XZqmef7ZzPn99scvT1JatWJXPmtJMTAADg11B6AADARPTFF8mGDU3JMTiYfPxx53zBgvJs1V13JT09rcQEAAA4FUoPAACYCOo6eeml5smq/v7ksceS48fLfPbsZPXqpuhYty654IL2sgIAAJwmpQcAAHSrgweTrVvLEfK33+6c33RTuc1x333JlCnt5AQAABghSg8AAOgmb71VSo4tW5JDh8ps2rRk+fJyn+Oqq1qLCQAAMBqUHgAAMJ4dPZps316OkL/8cuf8ssvKNsfy5cmMGe3kBAAAOAOUHgAAMN7s2ZOsX9+UHBs3Jnv3ltmkScmiRaXouOWWpKraywoAAHAGKT0AAGCsGxpKdu4s2xxPP90cJh82b15zfLyvL1mzJpk7t72sAAAALVJ6AADAWLRvX7JpU1NyrF+f7N7dOb/zzrLNsWBBs+EBAAAwwSk9AABgLKjrZNeuss3xyCPNvY5hZ5+drFrVHCHv7U0uvri9rAAAAGOU0gMAANpy6FDy0EOl6Hjjjc75ddeVbY7770+mTWsnJwAAwDih9AAAgDPpvfeSgYGm5HjwweTgwTKbOjVZsqQpOXp7m9IDAACA703pAQAAo+n48eSJJ8o2x89/3jm/+OKm4OjrS1aubJ6xAgAA4LQoPQAAYKR9+mkyONhsdAwOJp99VmZVlSxcWJ6tuv325mMAAAD8YEoPAAD4oeq62eAY3uZ44olkaKjM585N1q5tSo41a5LzzmsvKwAAQBdTegAAwOn48stk8+Zmm2NgoLnVcbLbbivPVi1cmEz2U28AAIDR5ldeAADwfb3+etnm2LYtOXKkzGbMSFasKEfIL7ustZgAAAATldIDAAC+zZEjySOPlKJj167O+dVXl5Jj6dJk+vRWYgIAANBQegAAwMk+/DBZv74pOTZtSvbvL7PJk5P77y9HyG+4wRFyAACAMUTpAQDAxDY0lDz9dNnmePbZzvkFF5TbHKtWJbNnt5MTAACAX0vpAQDAxPPFF8mGDU3JMTiYfPxxmVVVsmBB2ea4886kp6e1qAAAAHx/Sg8AALpfXScvvZQMDDRFx2OPJcePl/mcOcmaNc1Gx7p1yfz57WUFAADgtCk9AADoTgcPJlu3NiXHwEDy9tud85tvLtscixYlU6a0kxMAAIARo/QAAKB7vPVWuc2xdWty6FCZTZ+eLF9e7nNceWVbKQEAABglSg8AAMavo0eT7dtL0fHyy53zyy8v2xzLliUzZrSTEwAAgDNC6QEAwPiyZ0+yfn1TcmzcmOzdW2aTJiX33deUHL29yS23NIfJAQAAmBCUHgAAjG1DQ8mzz5Ztjh07msPkw847rzk+3teXrF6dzJ3bXlYAAABapfQAAGDs2bcv2bSpHCH/6KPO+Y9/XLY5FixoNjwAAACY8JQeAAC0r66TV18t2xyPPJIcO1bmZ5+drFrVFB3r1iUXX9xeVgAAAMasUy49qqp6IMk/SnJXkouS/EZd13910rxK8odJfjvJ3CRPJvn7dV2/NBKBAQDoEocOJdu2lW2ON97onF9/fbPJ0deX3H9/Mm1aKzEBAAAYP05n02NmkueT/LdJ/vIb5r+X5B8m+XtJdiX5J0k2VVV1Q13X+08zJwAA3eDdd5uCo78/2bw5OXiwzKZOTZYuLUXHtde2FhMAAIDx6ZRLj7qu1ydZnyTNUkdxYsvjHyT5Z3Vd/8cTH/vPk3yU5O8k+bc/LC4AAOPKsWPJE0+UZ6teeKFzfvHFTcHR15esWNE8YwUAAACnaaRvelyV5MIkG4c/UNf14aqqHkqyKN9QelRVNS3JyW8VzBrhTAAAnEmffJJs2NCUHIODyeefl1lPT7JwYdnmuP325Gt/kAYAAABO10iXHhee+Pajr338oyRXfMvf89M0N0AAABiP6jp5/vmyzfHkk8nQUJnPnZusXduUHGvWJOed115WAAAAutpIlx7D6q99v/qGjw374yQ/O+n7s5K8NxqhAAAYIV9+mTz4YDlC/sEHnfPbbmtKjt7eZrNj8mj9tBMAAACKkf7V5+4T316Y5MOTPj4/v7r9kaR5/irJ4eHvf/1OCAAAY8Qvf1m2OR56KDlypMxmzGhucgwXHZdd1l5OAAAAJqyRLj3eTFN8rEqyM0mqqpqaZEmS3x/hHwsAgNF05Ejy8MNlm2PXrs751VeXI+RLliTTp7eTEwAAAE445dKjqqqzk1x70oeuqqrqjiSf1XX9TlVVf5LkD6qqei3Ja0n+IMnBJP/uh8cFAGBUffBBsn59U3Rs2tQ8YzVs8uTk/vtL0XHDDY6QAwAAMKaczqbH3Um2nvT94Xscf5Hk7yX5V0nOSvJvksxN8mSS1XVd7z/9mAAAjIrjx5Onny7PVu3c2Tm/4ILmuaq+vmTVqmT27HZyAgAAwPdQ1fW33RdvR1VVs5Ps3bt3b2b7RTUAwMj7/PNkw4am5BgcTD75pMyqKlmwoNzm+PGPk56e9rICAAAw4e3bty9z5sxJkjl1Xe/7rs8d6ZseAACMNXWdvPRS2ebYvr3Z8Bg2Z06yZk1Tcqxbl8yf315WAAAA+AGUHgAA3ejgwWTLlnKE/J13Ouc331xucyxalEyZ0k5OAAAAGEFKDwCAbvHmm6Xk2Lo1OXSozKZPT5YvL89WXXllazEBAABgtCg9AADGq6NHk0cfbUqO/v7klVc655dfXrY5li1LZsxoJycAAACcIUoPAIDx5KOPkvXrm5Jj48Zk30n32yZNSu67rxQdN9/cHCYHAACACULpAQAwlg0NJc88U7Y5nn66c37++c3x8b6+ZNWqZO7cdnICAADAGKD0AAAYa/buTTZtakqO9eub7Y6T3XVXc5ejry9ZsCDp6WknJwAAAIwxSg8AgLbVdfKLXzQlR39/c6fj2LEynzWr2eLo62u2Oi66qL2sAAAAMIYpPQAA2nDoULJ1a3m26s03O+c33NCUHL29yf33J1OntpMTAAAAxhGlBwDAmfLuu2WbY/Pm5Kuvymzq1GTp0nKE/JprWosJAAAA45XSAwBgtBw7ljz+eNnmeOGFzvkll5SSY8WKZObMdnICAABAl1B6AACMpE8+SQYHm5Jjw4bk88/LrKcnWbiwFB233ZZUVXtZAQAAoMsoPQAAfoi6Tp57rik5BgaSJ55oPjbs3HOTtWubkmPNmmTevNaiAgAAQLdTegAAnKr9+5ubHMNFxwcfdM5vv70cIV+4MJk0qZ2cAAAAMMEoPQAAvo/XXitHyB9+ODlypMxmzEhWrixFx6WXtpcTAAAAJjClBwDANzl8uCk3hrc5Xnutc37NNeU2xwMPJNOnt5MTAAAA+GtKDwCAYe+/n6xf3xQdDz6YfPllmU2Z0pQbvb1N0XH99Y6QAwAAwBij9AAAJq7jx5OnnirPVj33XOf8wgtLybFyZTJ7disxAQAAgO9H6QEATCyffZZs2NCUHIODyaeflllVJffcU56tuuOOpKentagAAADAqVF6AADdra6TF15o7nL09yfbtydDQ2U+Z06yZk1Tcqxdm8yf315WAAAA4AdRegAA3efAgWTLlnKE/N13O+e33FK2ORYtSib7KREAAAB0A7/CBwC6wxtvlNsc27Ylhw+X2fTpyYoVTcnR25tccUVrMQEAAIDRo/QAAManI0eSxx4rRccvftE5v+KKss2xbFly1lnt5AQAAADOGKUHADB+7N6drF/flBwbNyb795fZpEnJ4sWl6LjppuYwOQAAADBhKD0AgLFraCjZsaPc5tixo3N+/vnNc1W9vcnq1ck557QSEwAAABgblB4AwNiyd2+zxdHf32x17NnTOb/rrrLNcffdSU9POzkBAACAMUfpAQC0q66TV14p2xyPPpocO1bms2Y1Wxx9fcm6dcmFF7aXFQAAABjTlB4AwJn31VfJtm3lCPlbb3XOb7ihbHMsXpxMndpGSgAAAGCcUXoAAGfGO++UkmPLlqb4GDZtWrJ0aVNy9PYm11zTWkwAAABg/FJ6AACj49ix5PHHS9Hx4oud80svLSXHihXJzJnt5AQAAAC6htIDABg5H3+cDA42JceGDckXX5RZT0/yk5+UZ6t+9KOkqlqLCgAAAHQfpQcAcPrqOtm5s2xzPPVU87Fh556brF3blBxr1iTz5rWXFQAAAOh6Sg8A4NTs359s2pQMDDR/ffhh5/yOO5onq/r6knvvTSZNaiUmAAAAMPEoPQCAX2/XrrLN8fDDydGjZTZzZrJyZbnPcckl7eUEAAAAJjSlBwDwqw4fTh56qCk5BgaSX/6yc37tteU2xwMPJNOmtZMTAAAA4CRKDwCg8d575cmqBx9MDhwosylTmnJjuOi4/vr2cgIAAAB8C6UHAExUx48nTz5Znq16/vnO+UUXldscK1cms2a1kxMAAADge1J6AMBE8tlnyeBgU3IMDjbfH1ZVzeHx4W2OO+5oPgYAAAAwTig9AKCb1XXy85+X2xyPP54MDZX5Oecka9c2Gx1r1ybnn99aVAAAAIAfSukBAN3mwIFk8+ZSdLz3Xuf81lvLNsdPfpJM9tMBAAAAoDv4XQ4A6Aavv15Kjm3bksOHy+yss5IVK5qSY9265IorWosJAAAAMJqUHgAwHh05kjz6aDlC/uqrnfOrrmpKjt7eZOnSpvgAAAAA6HJKDwAYL3bvbjY5+vuTTZuS/fvLbPLkZPHi8mzVjTc6Qg4AAABMOEoPABirhoaSHTvKNsczz3TO589vNjl6e5PVq5M5c9rJCQAAADBGKD0AYCz54otk48am5Fi/Pvn448753XeXbY677kp6elqJCQAAADAWKT0AoE11nbzyStnmePTR5PjxMp89u9ni6OtL1q5NLrywvawAAAAAY5zSAwDOtK++SrZubUqOgYHkrbc65zfeWLY5Fi9OpkxpJSYAAADAeKP0AIAz4e23S8mxZUtTfAybNi1ZtqwpOXp7k6uvbi8nAAAAwDim9ACA0XD0aPL44+XZqpde6pxfemnZ5li+PJk5s52cAAAAAF1E6QEAI+Xjj5vj4/39yYYNyd69ZdbTkyxaVIqOW29Nqqq9rAAAAABdSOkBAKdraCjZubNsczz9dHOYfNi8ecm6dU3JsXp1cu657WUFAAAAmACUHgBwKvbtSx58sNzn2L27c37nneU2xz33JJMmtZMTAAAAYAJSegDAd6nrZNeuss3xyCPNvY5hM2cmq1aVouPii9vLCgAAADDBKT0A4OsOHUoeeqhsc7z+euf8uuvKbY7770+mTWsnJwAAAAAdlB4AkCTvvdcUHP39zfNVBw+W2dSpyZIlzSZHX19TegAAAAAw5ig9AJiYjh9PnniiPFv18593zi++uJQcK1cmZ5/dTk4AAAAAvjelBwATx6efJoODzUbH4GDy2WdlVlXJwoXl2arbb28+BgAAAMC4ofQAoHvVdbPBMbzN8cQTydBQmc+dm6xd22x0rF2bnHdee1kBAAAA+MGUHgB0ly+/TDZvLkfI33+/c/6jH5VtjoULk8n+VQgAAADQLfxODwDj3+uvl22ObduSI0fKbMaMZMWKpuRYty65/PLWYgIAAAAwupQeAIw/R44kjzxSio5duzrnV11VtjmWLk2mT28lJgAAAABnltIDgPHhww+b56oGBpJNm5L9+8ts8uTk/vubkqO3N7nxRkfIAQAAACYgpQcAY9PQUPL002Wb49lnO+cXXNA8V9XXl6xalcyZ005OAAAAAMYMpQcAY8cXXyQbNjTbHOvXJx9/3DlfsKA8W/XjHyc9Pa3EBAAAAGBsUnoA0J66Tl56qSk5+vuTxx5Ljh8v89mzk9WryxHyCy5oLysAAAAAY57SA4Az6+DBZOvWpuQYGEjefrtzftNN5TbH4sXJlCnt5AQAAABg3FF6ADD63nqr3ObYujU5dKjMpk1Lli8vRcdVV7UWEwAAAIDxTekBwMg7erR5qmr42aqXX+6cX3ZZuc2xfHkyY0Y7OQEAAADoKkoPAEbGnj3N8fH+/mTjxmTv3jKbNClZtKgUHbfcklRVe1kBAAAA6EpKDwBOz9BQ8uyz5dmqHTuaw+TDzjuvOT7e19ccI587t72sAAAAAEwISg8Avr99+5otjoGB5q+PPuqc//jHzV2Ovr5kwYJmwwMAAAAAzpARLz2qqvqjJH/4tQ9/VNf1hSP9YwEwyuo6efXVss3xyCPJsWNlfvbZyapVTcmxbl1y8cXtZQUAAABgwhutTY+Xkqw86fvHR+nHAWCkHTqUbNvWlBwDA8kbb3TOr7++3OZYvDiZNq2VmAAAAADwdaNVehyr63r3KP2zARhp777bFBz9/cnmzcnBg2U2dWqyZEkpOq69tr2cAAAAAPAdRqv0uK6qqg+SHE7yZJI/qOv6jW/6xKqqpiU5+Y8JzxqlTAAMO3YseeKJ8mzVCy90zi+5pNzmWLGiecYKAAAAAMa40Sg9nkzyd5PsSnJBkn+SZHtVVbfUdf3pN3z+T/OrN0AAGGmffJIMDjYlx4YNyeefl1lPT7JwYVNy9PYmt9+eVFV7WQEAAADgNFR1XY/uD1BVM5O8nuRf1XX9s2+Yf9Omx3t79+7N7NmzRzUbQFer6+T558s2x5NPJkNDZT53brJ2bVN0rF2bzJvXXlYAAAAA+Bb79u3LnDlzkmROXdf7vutzR+t5q79W1/WBqqpeSHLdt8wPp3kGK0lS+ZPFAKfvyy+TBx8sR8g/+KBzfttt5TbHvfcmk0f9XwMAAAAAcMaM+u92ndjkuCnJI6P9YwFMSK+9Vo6QP/RQcuRImc2Y0dzkGH626rLL2ssJAAAAAKNsxEuPqqr+6yT/Kck7SeanuekxO8lfjPSPBTAhHTmSPPxwebbqtdc651dfXbY5lixJpk9vJycAAAAAnGGjselxaZJ/n+S8JB8neSLJwrqu3x6FHwtgYvjgg2T9+qbk2LSpecZq2OTJyQMPlG2OG25whBwAAACACWnES4+6rv/2SP8zASac48eTp58u2xw7d3bOL7igKTj6+pJVq5LZs9vJCQAAAABjiAu2AGPF558nGzY09znWr08++aTMqipZsKBsc/z4x0lPT3tZAQAAAGAMUnoAtKWuk5deKtsc27c3Gx7D5sxJ1qxpSo5165L589vLCgAAAADjgNID4Ew6eDDZsqUpOQYGknfe6ZzffHM5Qr5oUTJlSjs5AQAAAGAcUnoAjLa33irbHFu3JocOldn06cny5eXZqiuvbCslAAAAAIx7Sg+AkXb0aPLYY2Wb4+WXO+eXX162OZYtS2bMaCcnAAAAAHQZpQfASNizpzk+3t+fbNyY7N1bZpMmJffdV4qOm29uDpMDAAAAACNK6QFwOoaGkmefLc9W7djRHCYfdv75zfHxvr5k9erknHNaiwoAAAAAE4XSA+D72rev2eIYGGj++uijzvldd5XbHAsWJD097eQEAAAAgAlK6QHwbeo6efXVss3xyCPJsWNlPmtWsmpVU3SsW5dcdFF7WQEAAAAApQdAh0OHkm3byhHyN97onN9wQ7nNsXhxMnVqKzEBAAAAgF+l9AB4992m4OjvTzZvTg4eLLOpU5OlS0vRcc01rcUEAAAAAL6b0gOYeI4dS554ojxb9cILnfNLLiklx4oVycyZ7eQEAAAAAE6J0gOYGD75JBkcbEqODRuSzz8vs56eZOHCUnTcdltSVe1lBQAAAABOi9ID6E51nTz3XLnN8cQTzceGnXtusnZtU3KsWZPMm9daVAAAAABgZCg9gO7x5ZfJgw+WouODDzrnt9/elBy9vc1mx6RJ7eQEAAAAAEaF0gMY3157rdzmePjh5MiRMpsxI1m5shQdl17aXk4AAAAAYNQpPYDx5fDhptwY3uZ47bXO+dVXl9scS5Yk06e3kxMAAAAAOOOUHsDY98EHTcHR3988X/Xll2U2eXLywAOl6Lj+ekfIAQAAAGCCUnoAY8/x48lTT5Vtjp07O+cXXtg8V9XX1zxfNXt2OzkBAAAAgDFF6QGMDZ9/nmzY0BQdg4PJJ5+UWVUlCxaUbY4770x6etrLCgAAAACMSUoPoB11nbz4YjlCvn17MjRU5nPmJGvWNCXH2rXJ/PntZQUAAAAAxgWlB3DmHDyYbN5c7nO8+27n/JZbyrNVixYlU6a0kxMAAAAAGJeUHsDoevPNss2xdWty+HCZTZ+eLF/elBy9vcmVV7YWEwAAAAAY/5QewMg6ejR59NGyzfHKK53zK64otzmWLk1mzGglJgAAAADQfZQewA/30UfJ+vVNybFxY7JvX5lNmpTcd18pOm6+uTlMDgAAAAAwwpQewKkbGkqeeaY8W7VjR+f8/POTdeuaJ6tWr07mzm0nJwAAAAAwoSg9gO9n795mi6O/v9nq2LOnc37XXeU2x4IFSU9POzkBAAAAgAlL6QF8s7pu7nEM3+Z49NHk2LEynzUrWbWqKTrWrUsuuqi9rAAAAAAAUXoAJ/vqq2TbtvJs1Vtvdc5vuKHc5li8OJk6tY2UAAAAAADfSOkBE9077zQFx8BAsnlzU3wMmzo1WbasPFt1zTXt5QQAAAAA+DWUHjDRHDuWbN9enq168cXO+aWXNgVHX1+yYkUyc2Y7OQEAAAAATpHSAyaCjz9OBgebkmPDhuSLL8qspyf5yU/Ks1U/+lFSVa1FBQAAAAA4XUoP6EZ1nezcWW5zPPVU87Fh557bHB/v60tWr07mzWsvKwAAAADACFF6QLfYvz958MFyn+PDDzvnd9xRnq26995k0qRWYgIAAAAAjBalB4xnu3aVbY6HH06OHi2zmTOTlSvLEfJLLmkvJwAAAADAGaD0gPHk8OGm3BguOn75y875NdeU2xxLliTTprWTEwAAAACgBUoPGOvef795rqq/v3m+6sCBMpsyJXnggVJ0XH99ezkBAAAAAFqm9ICx5vjx5MknS9Hx3HOd84suKrc5Vq5MZs1qJSYAAAAAwFij9ICx4LPPkg0bmpJjcDD59NMyq6rknnvKNscddyQ9Pa1FBQAAAAAYq5Qe0Ia6Tl54oWxzbN+eDA2V+TnnJGvWNCXH2rXJ+ee3FhUAAAAAYLxQesCZcuBAsmVLU3IMDCTvvts5v/XW8mzVokXJZF+eAAAAAACnwu+qwmh6442m5OjvT7ZtSw4fLrOzzkqWL29Kjt7e5IorWosJAAAAANANlB4wko4cSR57rBQdv/hF5/zKK0vJsWxZU3wAAAAAADAilB7wQ+3enaxf35QcGzcm+/eX2aRJyeLF5Qj5TTc1h8kBAAAAABhxSg84VUNDyY4dZZvjmWc65/PnJ+vWNSXHqlXNUXIAAAAAAEad0gO+jy++aLY4BgaarY49ezrnd99dnq26++6kp6eVmAAAAAAAE5nSA75JXSevvFK2OR59NDl+vMxnz05Wr25KjnXrkgsvbC8rAAAAAABJlB5QfPVVsnVrU3IMDCRvvdU5v/HGcpvjvvuSqVNbiQkAAAAAwDdTejCxvf12KTm2bGmKj2HTpiXLlpVnq66+ur2cAAAAAAD8WkoPJpZjx5Lt28uzVS+91Dm/9NKyzbF8eTJzZjs5AQAAAAA4ZUoPut/HHzfHx/v7kw0bkr17y6ynJ1m0qBQdt96aVFV7WQEAAAAAOG1KD7rP0FCyc2fzZFV/f/LUU81h8mHz5jXHx/v6mmPk557bXlYAAAAAAEaM0oPusH9/smlTuc+xe3fn/I47yjbHPfckkya1EhMAAAAAgNGj9GB8qutk166yzfHww8nRo2U+c2ayalVTcqxbl1xySXtZAQAAAAA4I5QejB+HDycPPVSOkL/+euf82mvLNscDDyTTprWTEwAAAACAVig9GNvee69sc2zenBw4UGZTpiRLlpSi47rr2ssJAAAAAEDrlB6MLcePJ08+WbY5nn++c37RRUlvb1NyrFyZzJrVTk4AAAAAAMYcpQft++yzZHCwKTkGB5vvD6uq5N57yzbHHXc0HwMAAAAAgK9RenDm1XXy8583JcfAQPL448nQUJmfc06yZk1Tcqxdm5x/fmtRAQAAAAAYP5QenBkHDjQ3OYaLjvfe65zfemvZ5vjJT5LJ/qsJAAAAAMCp8TvLjJ7XXy+3ObZtS44cKbOzzkpWrGhKjt7e5PLLW4sJAAAAAEB3UHowco4cSR55pGxzvPpq5/yqq0rJsXRpU3wAAAAAAMAIUXrww3z4YVNwDAwkmzYl+/eX2eTJyeLF5dmqG290hBwAAAAAgFGj9ODUDA0lTz9dnq169tnO+fz5zSZHX1+yalUyZ047OQEAAAAAmHCUHvx6X3yRbNjQlByDg8nHH3fOFywoz1bddVfS09NKTAAAAAAAJjalB7+qrpOXXmqerOrvTx57LDl+vMxnz05Wr26KjnXrkgsuaC8rAAAAAACcoPSgcfBgsnVrOUL+9tud85tuKrc57rsvmTKlnZwAAAAAAPAtlB4T2VtvlZJjy5bk0KEymzYtWb683Oe46qrWYgIAAAAAwPeh9JhIjh5Ntm8vR8hffrlzftllZZtj+fJkxox2cgIAAAAAwGlQenS7PXuS9eubkmPjxmTv3jKbNClZtKgUHbfcklRVe1kBAAAAAOAHUHp0m6GhZOfOss3x9NPNYfJh8+Y1x8f7+pI1a5K5c9vLCgAAAAAAI2jUSo+qqn43yT9KclGSl5L8g7quHxmtH29C27cv2bSpKTnWr0927+6c33ln2eZYsKDZ8AAAAAAAgC4zKqVHVVW/meRPkvxukseS/BdJ1ldVdXNd1++Mxo85odR1smtX2eZ45JHmXsewmTOTVauakqO3N7n44vayAgAAAADAGVLVJz99NFL/0Kp6MsmzdV3/zkkfeyXJX9V1/dNf8/fOTrJ37969mT179ohnG7cOH062bStFxxtvdM6vu65sc9x/fzJtWisxAQAAAABgJO3bty9z5sxJkjl1Xe/7rs8d8U2PqqqmJrkryb/42mhjkkXf8PnTkpz8O/SzRjpTV9i5M1m7tnx/6tRkyZJmk6Ovryk9AAAAAABgAhuN563OSzIpyUdf+/hHSS78hs//aZI/HIUc3WXBguT225tv+/qSlSuTs89uOxUAAAAAAIwZo3bIPMnX382qvuFjSfLHSX520vdnJXlvtEKNW5MmJc8913YKAAAAAAAYs0aj9PgkyfH86lbH/Pzq9kfquj6c5PDw96uqGoVIAAAAAABAt+sZ6X9gXddHkjyTZNXXRquSbB/pHw8AAAAAACAZveetfpbkv6+qakeSx5P8dpLLk/w3o/TjAQAAAAAAE9yolB51Xf+PVVXNS/JfJbkoyYtJeuu6fns0fjwAAAAAAIBRO2Re1/W/SfJvRuufDwAAAAAAcLIRv+kBAAAAAADQBqUHAAAAAADQFZQeAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BWUHgAAAAAAQFdQegAAAAAAAF1B6QEAAAAAAHQFpQcAAAAAANAVlB4AAAAAAEBXUHoAAAAAAABdYXLbAb7Nvn372o4AAAAAAAC07FT6gqqu61GMcuqqqrokyXtt5wAAAAAAAMaUS+u6fv+7PmEslh5VkouT7G87yxg0K00hdGn83wfONF9/0B5ff9AuX4PQHl9/0B5ff9AuX4N8k1lJPqh/Takx5p63OhH4O5uaiarpg5Ik++u69v4XnEG+/qA9vv6gXb4GoT2+/qA9vv6gXb4G+Rbf678LDpkDAAAAAABdQekBAAAAAAB0BaXH+HI4yf/hxLfAmeXrD9rj6w/a5WsQ2uPrD9rj6w/a5WuQ0zbmDpkDAAAAAACcDpseAAAAAABAV1B6AAAAAAAAXUHpAQAAAAAAdAWlBwAAAAAA0BWUHgAAAAAAQFdQeowTVVX9blVVb1ZVdaiqqmeqqrq/7UwwEVRV9dOqqp6uqmp/VVV7qqr6q6qqbmg7F0xEJ74e66qq/qTtLDARVFV1SVVV/8+qqj6tqupgVVXPVVV1V9u5YCKoqmpyVVX/pxO/Bvyqqqo3qqr6r6qq8mt4GGFVVT1QVdV/qqrqgxM/1/yffW1eVVX1RyfmX1VVta2qqltaigtd5bu+/qqqmlJV1b+squqFqqoOnPic/66qqotbjMw44SdM40BVVb+Z5E+S/LMkdyZ5JMn6qqoubzMXTBBLkvxZkoVJViWZnGRjVVUzW00FE0xVVQuS/HaSn7edBSaCqqrmJnksydEk65LcnOR/n+SLFmPBRPL7Sf43Sf7LJDcl+b0k/yjJ/7bNUNClZiZ5Ps3X2zf5vST/8MR8QZLdSTZVVTXrzMSDrvZdX38zkvw4yT898e3fSnJ9kv/vGUvHuFXVdd12Bn6NqqqeTPJsXde/c9LHXknyV3Vd/7S9ZDDxVFV1fpI9SZbUdf1w23lgIqiq6uwkzyb53ST/JMlzdV3/g1ZDQZerqupfJLmvrmvbxdCCqqr+f0k+quv6f3XSx/4yycG6rv+X7SWD7lZVVZ3kN+q6/qsT36+SfJDkT+q6/pcnPjYtyUdJfr+u63/bVlboNl//+vuWz1mQ5KkkV9R1/c6Zysb4Y9NjjKuqamqSu5Js/NpoY5JFZz4RTHhzTnz7WaspYGL5syT9dV0/2HYQmED+RpIdVVX9hxPPO+6squp/3XYomEAeTbKiqqrrk6SqqtuTLE4y0GoqmHiuSnJhTvo9mbquDyd5KH5PBtowJ0kd28f8GpPbDsCvdV6SSWn+FMHJPkrzL17gDDnxp3x+luTRuq5fbDsPTARVVf3tNKvMC9rOAhPM1Ul+J82/9/55knuS/N+qqjpc1/V/12oymBj+ZZrf2PlFVVXH0/ya8B/Xdf3v240FE87w77t80+/JXHGGs8CEVlXV9CT/Ism/q+t6X9t5GNuUHuPH198hq77hY8Do+tdJbkvzp+yAUVZV1WVJ/q9JVtd1fajtPDDB9CTZUdf1H5z4/s4TR1t/J4nSA0bfbyb5XyT5O0leSnJHkj+pquqDuq7/os1gMEH5PRloUVVVU5L8D2l+jvq7LcdhHFB6jH2fJDmeX93qmJ9f/ZMGwCipqupP0zz18UBd1++1nQcmiLvS/PvumWbRKknzJ10fqKrqv0wyra7r422Fgy73YZKXv/axV5L8z1vIAhPR/znJv6jr+n848f0Xqqq6IslPkyg94MzZfeLbC9P8u3GY35OBM+RE4fH/TvPc3HJbHnwfbnqMcXVdH0nyTJJVXxutSrL9zCeCiaVq/OskfyvNv1zfbDsTTCCbk/wozZ9uHf5rR5L/V5I7FB4wqh5LcsPXPnZ9krdbyAIT0YwkQ1/72PH4NTycaW+mKT7++vdkTtxeXRK/JwOj7qTC47okK+u6/rTlSIwTNj3Gh58l+e+rqtqR5PEkv53k8iT/TaupYGL4szTPCvzNJPurqhreutpb1/VX7cWC7lfX9f4kHfdzqqo6kORTd3Vg1P1fkmyvquoP0vxC8540Pwf97VZTwcTxn5L846qq3knzvNWdSf5hkv9Hq6mgC1VVdXaSa0/60FVVVd2R5LO6rt+pqupPkvxBVVWvJXktyR8kOZjk353prNBtvuvrL8kHSf4/aW48/mdJJp30ezKfnfiD4vCNqrr2BOF4UFXV7yb5vSQXpfkNoP9dXdcPt5sKul9VVd/2P5K/Vdf1n5/JLEBSVdW2JM/Vdf0PWo4CXa+qqv8syR+n+ZN1byb5WV3X//d2U8HEUFXVrCT/NMlvpHlG54Mk/z7J/9Fv8sDIqqpqaZKt3zD6i7qu/17VvLP6h0n+iyRzkzyZ5O/7Qzjww33X11+SP0rzc9Bvsqyu622jEoquoPQAAAAAAAC6gvdAAQAAAACArqD0AAAAAAAAuoLSAwAAAAAA6ApKDwAAAAAAoCsoPQAAAAAAgK6g9AAAAAAAALqC0gMAAAAAAOgKSg8AAAAAAKArKD0AAAAAAICuoPQAAAAAAAC6gtIDAAAAAADoCv9/1MXeuKac8eEAAAAASUVORK5CYII=\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'], |