Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-04-05 21:08:46 +00:00
parent bc3187f6d9
commit 6cdb39a9ef
42 changed files with 339 additions and 273 deletions

View File

@ -150,6 +150,7 @@ export default {
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
data-qa-selector="search_term_field"
autocomplete="off"
:placeholder="$options.i18n.searchGitlab"
:aria-activedescendant="currentFocusedId"

View File

@ -431,7 +431,7 @@ export default {
clearFlash() {
if (this.flashContainer) {
this.flashContainer.style.display = 'none';
this.flashContainer.close();
this.flashContainer = null;
}
},

View File

@ -57,6 +57,7 @@ export default {
:value="sortDirection"
:storage-key="storageKey"
:persist="persistSortOrder"
as-string
@input="setDiscussionSortDirection({ direction: $event })"
/>
<gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">

View File

@ -99,7 +99,6 @@ export default {
<local-storage-sync
storage-key="package_registry_list_sorting"
:value="sorting"
as-json
@input="updateSorting"
>
<url-sync>

View File

@ -23,6 +23,7 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for
return new Vue({
el,
name: 'SignupRestrictions',
provide: {
...parsedDataset,
},

View File

@ -273,6 +273,7 @@ export default {
<local-storage-sync
:storage-key="$options.viewTypeKey"
:value="currentViewType"
as-string
@input="updateViewType"
>
<graph-view-selector

View File

@ -144,7 +144,6 @@ export default {
<local-storage-sync
v-model="autoDevopsEnabledAlertDismissedProjects"
:storage-key="$options.autoDevopsEnabledAlertStorageKey"
as-json
/>
<user-callout-dismisser

View File

@ -113,13 +113,14 @@ export default {
'gl-display-grid gl-align-items-center': showVerticalList,
'gl-mb-3': index !== users.length - 1 && showVerticalList,
}"
class="assignee-attention-grid"
class="assignee-grid"
>
<assignee-avatar-link
:user="user"
:issuable-type="issuableType"
:tooltip-has-name="!showVerticalList"
class="gl-grid-column-2 gl-grid-row-1 gl-word-break-word"
class="gl-word-break-word"
data-css-area="user"
>
<div
v-if="showVerticalList"
@ -134,7 +135,8 @@ export default {
v-if="showVerticalList"
:user="user"
type="assignee"
class="gl-grid-column-1 gl-grid-row-1 gl-mr-2"
class="gl-mr-2"
data-css-area="attention"
@toggle-attention-requested="toggleAttentionRequested"
/>
</div>

View File

@ -94,15 +94,19 @@ export default {
<div
v-for="(user, index) in users"
:key="user.id"
:class="{ 'gl-mb-3': index !== users.length - 1 }"
class="gl-display-grid gl-align-items-center reviewer-attention-grid"
:class="{
'gl-mb-3': index !== users.length - 1,
'attention-requests': glFeatures.mrAttentionRequests,
}"
class="gl-display-grid gl-align-items-center reviewer-grid"
data-testid="reviewer"
>
<reviewer-avatar-link
:user="user"
:root-path="rootPath"
:issuable-type="issuableType"
class="gl-grid-column-2 gl-grid-row-1 gl-word-break-word gl-mr-2"
class="gl-word-break-word gl-mr-2"
data-css-area="user"
>
<div class="gl-ml-3 gl-line-height-normal gl-display-grid">
<span>{{ user.name }}</span>
@ -113,7 +117,8 @@ export default {
v-if="glFeatures.mrAttentionRequests"
:user="user"
type="reviewer"
class="gl-grid-column-1 gl-grid-row-1 gl-mr-2"
class="gl-mr-2"
data-css-area="attention"
@toggle-attention-requested="toggleAttentionRequested"
/>
<gl-icon

View File

@ -112,7 +112,6 @@ export default {
v-model="mergeRequestMeta"
:storage-key="$options.storageKey"
:clear="clearStorage"
as-json
/>
<edit-meta-controls
ref="editMetaControls"

View File

@ -37,7 +37,7 @@ export default {
<template>
<div v-show="showAlert">
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" />
<gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
<slot></slot>
</gl-alert>

View File

@ -21,12 +21,17 @@ export default {
default: () => ({}),
},
},
methods: {
targetFn() {
return this.$refs.popoverTrigger?.$el;
},
},
};
</script>
<template>
<span>
<gl-button ref="popoverTrigger" variant="link" icon="question-o" :aria-label="__('Help')" />
<gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options">
<gl-popover :target="targetFn" v-bind="options">
<template v-if="options.title" #title>
<span v-safe-html="options.title"></span>
</template>

View File

@ -1,6 +1,18 @@
<script>
import { isEqual } from 'lodash';
import { isEqual, isString } from 'lodash';
/**
* This component will save and restore a value to and from localStorage.
* The value will be saved only when the value changes; the initial value won't be saved.
*
* By default, the value will be saved using JSON.stringify(), and retrieved back using JSON.parse().
*
* If you would like to save the raw string instead, you may set the 'asString' prop to true, though be aware that this is a
* legacy prop to maintain backwards compatibility.
*
* For new components saving data for the first time, it's recommended to not use 'asString' even if you're saving a string; it will still be
* saved and restored properly using JSON.stringify()/JSON.parse().
*/
export default {
props: {
storageKey: {
@ -12,7 +24,7 @@ export default {
required: false,
default: '',
},
asJson: {
asString: {
type: Boolean,
required: false,
default: false,
@ -30,6 +42,8 @@ export default {
},
watch: {
value(newVal) {
if (!this.persist) return;
this.saveValue(this.serialize(newVal));
},
clear(newVal) {
@ -67,15 +81,22 @@ export default {
}
},
saveValue(val) {
if (!this.persist) return;
localStorage.setItem(this.storageKey, val);
},
serialize(val) {
return this.asJson ? JSON.stringify(val) : val;
if (!isString(val) && this.asString) {
// eslint-disable-next-line no-console
console.warn(
`[gitlab] LocalStorageSync is saving`,
val,
`to the key "${this.storageKey}", but it is not a string and the 'asString' prop is true. This will save and restore the stringified value rather than the original value. If this is not intended, please remove or set the 'asString' prop to false.`,
);
}
return this.asString ? val : JSON.stringify(val);
},
deserialize(val) {
return this.asJson ? JSON.parse(val) : val;
return this.asString ? val : JSON.parse(val);
},
},
render() {

View File

@ -43,7 +43,7 @@ export default {
</script>
<template>
<local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected">
<local-storage-sync :storage-key="storageKey" :value="selected" as-string @input="setSelected">
<gl-dropdown :text="dropdownText" lazy>
<gl-dropdown-item
v-for="option in parsedOptions"

View File

@ -314,6 +314,7 @@ export default {
<local-storage-sync
storage-key="gl-web-ide-button-selected"
:value="selection"
as-string
@input="select"
/>
<gl-modal

View File

@ -227,22 +227,28 @@
margin-right: -$gl-spacing-scale-2;
}
.reviewer-attention-grid,
.assignee-attention-grid {
grid-template-columns: min-content 1fr min-content;
.assignee-grid {
grid-template-areas: ' attention user';
grid-template-columns: min-content 1fr;
}
/* TODO: These are non-standardized classes, and should be moved into gitlab-ui
Please see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1780
*/
.gl-grid-column-1 {
grid-column: 1;
.reviewer-grid {
grid-template-areas: ' user approval rerequest';
grid-template-columns: 1fr min-content min-content;
&.attention-requests {
grid-template-areas: ' attention user approval';
grid-template-columns: min-content 1fr min-content;
}
}
.gl-grid-row-1 {
grid-row: 1;
}
.assignee-grid,
.reviewer-grid {
[data-css-area='attention'] {
grid-area: attention;
}
.gl-grid-column-2 {
grid-column: 2;
[data-css-area='user'] {
grid-area: user;
}
}

View File

@ -22,6 +22,8 @@ class JiraConnect::ApplicationController < ApplicationController
def verify_qsh_claim!
payload, _ = decode_auth_token!
return if request.format.json? && payload['qsh'] == 'context-qsh'
# Make sure `qsh` claim matches the current request
render_403 unless payload['qsh'] == Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url)
rescue StandardError

View File

@ -21,7 +21,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
end
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :verify_qsh_claim!
before_action :authenticate_user!, only: :create
def index

View File

@ -16,8 +16,6 @@ class ContainerRepository < ApplicationRecord
ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze
SKIPPABLE_MIGRATION_STATES = (ABORTABLE_MIGRATION_STATES + %w[import_aborted]).freeze
IRRECONCILABLE_MIGRATIONS_STATUSES = %w[import_in_progress pre_import_in_progress pre_import_canceled import_canceled].freeze
MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
TooManyImportsError = Class.new(StandardError)
@ -113,7 +111,7 @@ class ContainerRepository < ApplicationRecord
end
event :start_pre_import do
transition default: :pre_importing
transition %i[default pre_importing importing import_aborted] => :pre_importing
end
event :finish_pre_import do
@ -153,7 +151,10 @@ class ContainerRepository < ApplicationRecord
container_repository.migration_pre_import_done_at = nil
end
after_transition any => :pre_importing do |container_repository|
after_transition any => :pre_importing do |container_repository, transition|
forced = transition.args.first.try(:[], :forced)
next if forced
container_repository.try_import do
container_repository.migration_pre_import
end
@ -168,7 +169,10 @@ class ContainerRepository < ApplicationRecord
container_repository.migration_import_done_at = nil
end
after_transition any => :importing do |container_repository|
after_transition any => :importing do |container_repository, transition|
forced = transition.args.first.try(:[], :forced)
next if forced
container_repository.try_import do
container_repository.migration_import
end
@ -259,10 +263,10 @@ class ContainerRepository < ApplicationRecord
super
end
def start_pre_import
def start_pre_import(*args)
return false unless ContainerRegistry::Migration.enabled?
super
super(*args)
end
def retry_pre_import
@ -295,8 +299,18 @@ class ContainerRepository < ApplicationRecord
case status
when 'native'
finish_import_as(:native_import)
when *IRRECONCILABLE_MIGRATIONS_STATUSES
nil
when 'pre_import_in_progress'
return if pre_importing?
start_pre_import(forced: true)
when 'import_in_progress'
return if importing?
start_import(forced: true)
when 'import_canceled', 'pre_import_canceled'
return if import_skipped?
skip_import(reason: :migration_canceled)
when 'import_complete'
finish_import
when 'import_failed'

View File

@ -26,7 +26,7 @@ class Environment < ApplicationRecord
has_many :self_managed_prometheus_alert_events, inverse_of: :environment
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
has_one :last_deployment, -> { Feature.enabled?(:env_last_deployment_by_finished_at) ? success.ordered : success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: true
has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: true

View File

@ -16,6 +16,6 @@
domain_denylist_raw: @application_setting.domain_denylist_raw,
email_restrictions_enabled: @application_setting[:email_restrictions_enabled].to_s,
supported_syntax_link_url: 'https://github.com/google/re2/wiki/Syntax',
email_restrictions: @application_setting.email_restrictions,
after_sign_up_text: @application_setting[:after_sign_up_text],
email_restrictions: @application_setting.email_restrictions.to_s,
after_sign_up_text: @application_setting[:after_sign_up_text].to_s,
pending_user_count: pending_user_count } }

View File

@ -6,7 +6,10 @@
= form_tag search_path, method: :get do |_f|
.gl-search-box-by-type
= sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
%input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'), class: 'form-control gl-form-input gl-search-box-by-type-input', autocomplete: 'off' }
%input{ id: 'search', name: 'search', type: "text", placeholder: s_('GlobalSearch|Search GitLab'),
class: 'form-control gl-form-input gl-search-box-by-type-input',
autocomplete: 'off',
data: { qa_selector: 'search_box' } }
= hidden_field_tag :group_id, header_search_context[:group][:id] if header_search_context[:group]
= hidden_field_tag :project_id, header_search_context[:project][:id] if header_search_context[:project]

View File

@ -4,16 +4,12 @@ module BulkImports
class EntityWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
data_consistency :always
feature_category :importers
sidekiq_options retry: false, dead: false
worker_has_external_dependencies!
idempotent!
deduplicate :until_executed, including_scheduled: true
deduplicate :until_executing
data_consistency :always
feature_category :importers
sidekiq_options retry: false, dead: false
worker_has_external_dependencies!
def perform(entity_id, current_stage = nil)
return if stage_running?(entity_id, current_stage)

View File

@ -20,8 +20,17 @@ module Namespaces
Namespaces::StatisticsRefresherService.new.execute(namespace)
namespace.aggregation_schedule.destroy
notify_storage_usage(namespace)
rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex
Gitlab::ErrorTracking.track_exception(ex, namespace_id: namespace_id, namespace: namespace&.full_path)
end
private
def notify_storage_usage(namespace)
end
end
end
Namespaces::RootStatisticsWorker.prepend_mod_with('Namespaces::RootStatisticsWorker')

View File

@ -0,0 +1,8 @@
---
name: env_last_deployment_by_finished_at
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83558
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/357299
milestone: '14.10'
type: development
group: group::release
default_enabled: false

View File

@ -1881,8 +1881,9 @@ Input type: `DastSiteProfileCreateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationdastsiteprofilecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationdastsiteprofilecreatedastsiteprofile"></a>`dastSiteProfile` | [`DastSiteProfile`](#dastsiteprofile) | Site Profile object. |
| <a id="mutationdastsiteprofilecreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationdastsiteprofilecreateid"></a>`id` | [`DastSiteProfileID`](#dastsiteprofileid) | ID of the site profile. |
| <a id="mutationdastsiteprofilecreateid"></a>`id` **{warning-solid}** | [`DastSiteProfileID`](#dastsiteprofileid) | **Deprecated:** use `dastSiteProfile.id` field. Deprecated in 14.10. |
### `Mutation.dastSiteProfileDelete`
@ -1927,8 +1928,9 @@ Input type: `DastSiteProfileUpdateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationdastsiteprofileupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationdastsiteprofileupdatedastsiteprofile"></a>`dastSiteProfile` | [`DastSiteProfile`](#dastsiteprofile) | Site profile object. |
| <a id="mutationdastsiteprofileupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationdastsiteprofileupdateid"></a>`id` | [`DastSiteProfileID`](#dastsiteprofileid) | ID of the site profile. |
| <a id="mutationdastsiteprofileupdateid"></a>`id` **{warning-solid}** | [`DastSiteProfileID`](#dastsiteprofileid) | **Deprecated:** use `dastSiteProfile.id` field. Deprecated in 14.10. |
### `Mutation.dastSiteTokenCreate`

View File

@ -17,7 +17,11 @@ You can then connect to this session by using the [pry-shell](https://github.com
You can watch [this video](https://www.youtube.com/watch?v=Lzs_PL_BySo), for more information about
how to use the `pry-shell`.
## `byebug` vs `binding.pry`
WARNING:
`binding.pry` can occasionally experience autoloading issues and fail during name resolution.
If needed, `binding.irb` can be used instead with a more limited feature set.
## `byebug` vs `binding.pry` vs `binding.irb`
`byebug` has a very similar interface as `gdb`, but `byebug` does not
use the powerful Pry REPL.
@ -41,6 +45,12 @@ this document, so for the full documentation head over to the [Pry wiki](https:/
Below are a few features definitely worth checking out, also run
`help` in a pry session to see what else you can do.
## `binding.irb`
As of Ruby 2.7, IRB ships with a simple interactive debugger.
Check out [the docs](https://ruby-doc.org/stdlib-2.7.0/libdoc/irb/rdoc/Binding.html) for more.
### State navigation
With the [state navigation](https://github.com/pry/pry/wiki/State-navigation)

View File

@ -381,6 +381,12 @@ Learn more on overriding security jobs:
All the security scanning tools define their stage, so this error can occur with all of them.
## Self managed installation options
For self managed installations, you can choose to run most of the GitLab security scanners even when [not connected to the internet](offline_deployments/index.md).
Self managed installations can also run the security scanners on a GitLab Runner [running inside OpenShift](../../install/openshift_and_gitlab/index.md).
## Security report validation
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/321918) in GitLab 13.11.

View File

@ -33855,7 +33855,7 @@ msgstr ""
msgid "SecurityReports|Take survey"
msgstr ""
msgid "SecurityReports|The Vulnerability Report shows the results of the latest successful pipeline on your project's default branch, as well as vulnerabilities from your latest container scan. %{linkStart}Learn more.%{linkEnd}"
msgid "SecurityReports|The Vulnerability Report shows results of successful scans on your project's default branch, manually added vulnerability records, and vulnerabilities found from scanning operational environments. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "SecurityReports|The following security reports contain one or more vulnerability findings that could not be parsed and were not recorded. To investigate a report, download the artifacts in the job output. Ensure the security report conforms to the relevant %{helpPageLinkStart}JSON schema%{helpPageLinkEnd}."

View File

@ -130,6 +130,10 @@ module QA
has_css?(".active", text: 'Standard')
end
def has_arkose_labs_token?
has_css?('[name="arkose_labs_token"][value]', visible: false)
end
def switch_to_sign_in_tab
click_element :sign_in_tab
end
@ -174,6 +178,17 @@ module QA
fill_element :login_field, user.username
fill_element :password_field, user.password
if Runtime::Env.running_on_dot_com?
# Arkose only appears in staging.gitlab.com, gitlab.com, etc...
# Wait until the ArkoseLabs challenge has initialized
Support::WaitForRequests.wait_for_requests
Support::Waiter.wait_until(max_duration: 5, reload_page: false, raise_on_failure: false) do
has_arkose_labs_token?
end
end
click_element :sign_in_button
Support::WaitForRequests.wait_for_requests

View File

@ -45,6 +45,14 @@ module QA
element :search_term_field
end
view 'app/views/layouts/_header_search.html.haml' do
element :search_box
end
view 'app/assets/javascripts/header_search/components/app.vue' do
element :search_term_field
end
def go_to_groups
within_groups_menu do
click_element(:menu_item_link, title: 'Your groups')
@ -146,6 +154,7 @@ module QA
end
def search_for(term)
click_element(:search_box)
fill_element :search_term_field, "#{term}\n"
end

View File

@ -75,6 +75,18 @@ RSpec.describe JiraConnect::SubscriptionsController do
expect(json_response).to include('login_path' => nil)
end
end
context 'with context qsh' do
# The JSON endpoint will be requested by frontend using a JWT that Atlassian provides via Javascript.
# This JWT will likely use a context-qsh because Atlassian don't know for which endpoint it will be used.
# Read more about context JWT here: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/
let(:qsh) { 'context-qsh' }
specify do
expect(response).to have_gitlab_http_status(:ok)
end
end
end
end
end
@ -102,7 +114,7 @@ RSpec.describe JiraConnect::SubscriptionsController do
end
context 'with valid JWT' do
let(:claims) { { iss: installation.client_key, sub: 1234 } }
let(:claims) { { iss: installation.client_key, sub: 1234, qsh: 'context-qsh' } }
let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) }
let(:jira_user) { { 'groups' => { 'items' => [{ 'name' => jira_group_name }] } } }
let(:jira_group_name) { 'site-admins' }
@ -158,7 +170,7 @@ RSpec.describe JiraConnect::SubscriptionsController do
.stub_request(:get, "#{installation.base_url}/rest/api/3/user?accountId=1234&expand=groups")
.to_return(body: jira_user.to_json, status: 200, headers: { 'Content-Type' => 'application/json' })
delete :destroy, params: { jwt: jwt, id: subscription.id }
delete :destroy, params: { jwt: jwt, id: subscription.id, format: :json }
end
context 'without JWT' do
@ -170,7 +182,7 @@ RSpec.describe JiraConnect::SubscriptionsController do
end
context 'with valid JWT' do
let(:claims) { { iss: installation.client_key, sub: 1234 } }
let(:claims) { { iss: installation.client_key, sub: 1234, qsh: 'context-qsh' } }
let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) }
it 'deletes the subscription' do

View File

@ -38,8 +38,8 @@ describe('Sort Discussion component', () => {
createComponent();
});
it('has local storage sync', () => {
expect(findLocalStorageSync().exists()).toBe(true);
it('has local storage sync with the correct props', () => {
expect(findLocalStorageSync().props('asString')).toBe(true);
});
it('calls setDiscussionSortDirection when update is emitted', () => {

View File

@ -73,7 +73,6 @@ describe('Package Search', () => {
mountComponent();
expect(findLocalStorageSync().props()).toMatchObject({
asJson: true,
storageKey: 'package_registry_list_sorting',
value: {
orderBy: LIST_KEY_CREATED_AT,

View File

@ -30,6 +30,7 @@ import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
import * as sentryUtils from '~/pipelines/utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { mockRunningPipelineHeaderData } from '../mock_data';
import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
@ -55,6 +56,7 @@ describe('Pipeline graph wrapper', () => {
wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]');
const getViewSelector = () => wrapper.find(GraphViewSelector);
const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert);
const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const createComponent = ({
apolloProvider,
@ -376,6 +378,10 @@ describe('Pipeline graph wrapper', () => {
localStorage.clear();
});
it('sets the asString prop on the LocalStorageSync component', () => {
expect(getLocalStorageSync().props('asString')).toBe(true);
});
it('reads the view type from localStorage when available', () => {
const viewSelectorNeedsSegment = wrapper
.find(GlButtonGroup)

View File

@ -1,31 +1,29 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
const STORAGE_KEY = 'key';
describe('Local Storage Sync', () => {
let wrapper;
const createComponent = ({ props = {}, slots = {} } = {}) => {
const createComponent = ({ value, asString = false, slots = {} } = {}) => {
wrapper = shallowMount(LocalStorageSync, {
propsData: props,
propsData: { storageKey: STORAGE_KEY, value, asString },
slots,
});
};
const setStorageValue = (value) => localStorage.setItem(STORAGE_KEY, value);
const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value);
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
wrapper = null;
wrapper.destroy();
localStorage.clear();
});
it('is a renderless component', () => {
const html = '<div class="test-slot"></div>';
createComponent({
props: {
storageKey: 'key',
},
slots: {
default: html,
},
@ -35,233 +33,136 @@ describe('Local Storage Sync', () => {
});
describe('localStorage empty', () => {
const storageKey = 'issue_list_order';
it('does not emit input event', () => {
createComponent({
props: {
storageKey,
value: 'ascending',
},
});
createComponent({ value: 'ascending' });
expect(wrapper.emitted('input')).toBeFalsy();
expect(wrapper.emitted('input')).toBeUndefined();
});
it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })(
'saves updated value to localStorage',
async (newValue) => {
createComponent({
props: {
storageKey,
value: 'initial',
},
});
it('does not save initial value if it did not change', () => {
createComponent({ value: 'ascending' });
wrapper.setProps({ value: newValue });
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(String(newValue));
},
);
it('does not save default value', () => {
const value = 'ascending';
createComponent({
props: {
storageKey,
value,
},
});
expect(localStorage.getItem(storageKey)).toBe(null);
expect(getStorageValue()).toBeNull();
});
});
describe('localStorage has saved value', () => {
const storageKey = 'issue_list_order_by';
const savedValue = 'last_updated';
beforeEach(() => {
localStorage.setItem(storageKey, savedValue);
setStorageValue(savedValue);
createComponent({ asString: true });
});
it('emits input event with saved value', () => {
createComponent({
props: {
storageKey,
value: 'ascending',
},
});
expect(wrapper.emitted('input')[0][0]).toBe(savedValue);
});
it('does not overwrite localStorage with prop value', () => {
createComponent({
props: {
storageKey,
value: 'created',
},
});
expect(localStorage.getItem(storageKey)).toBe(savedValue);
it('does not overwrite localStorage with initial prop value', () => {
expect(getStorageValue()).toBe(savedValue);
});
it('updating the value updates localStorage', async () => {
createComponent({
props: {
storageKey,
value: 'created',
},
});
const newValue = 'last_updated';
wrapper.setProps({
value: newValue,
});
await wrapper.setProps({ value: newValue });
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(newValue);
});
it('persists the value by default', async () => {
const persistedValue = 'persisted';
createComponent({
props: {
storageKey,
},
});
wrapper.setProps({ value: persistedValue });
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(persistedValue);
});
it('does not save a value if persist is set to false', async () => {
const notPersistedValue = 'notPersisted';
createComponent({
props: {
storageKey,
},
});
wrapper.setProps({ persist: false, value: notPersistedValue });
await nextTick();
expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue);
expect(getStorageValue()).toBe(newValue);
});
});
describe('with "asJson" prop set to "true"', () => {
const storageKey = 'testStorageKey';
describe('persist prop', () => {
it('persists the value by default', async () => {
const persistedValue = 'persisted';
createComponent({ asString: true });
// Sanity check to make sure we start with nothing saved.
expect(getStorageValue()).toBeNull();
describe.each`
value | serializedValue
${null} | ${'null'}
${''} | ${'""'}
${true} | ${'true'}
${false} | ${'false'}
${42} | ${'42'}
${'42'} | ${'"42"'}
${'{ foo: '} | ${'"{ foo: "'}
${['test']} | ${'["test"]'}
${{ foo: 'bar' }} | ${'{"foo":"bar"}'}
`('given $value', ({ value, serializedValue }) => {
describe('is a new value', () => {
beforeEach(async () => {
createComponent({
props: {
storageKey,
value: 'initial',
asJson: true,
},
});
await wrapper.setProps({ value: persistedValue });
wrapper.setProps({ value });
await nextTick();
});
it('serializes the value correctly to localStorage', () => {
expect(localStorage.getItem(storageKey)).toBe(serializedValue);
});
});
describe('is already stored', () => {
beforeEach(() => {
localStorage.setItem(storageKey, serializedValue);
createComponent({
props: {
storageKey,
value: 'initial',
asJson: true,
},
});
});
it('emits an input event with the deserialized value', () => {
expect(wrapper.emitted('input')).toEqual([[value]]);
});
});
expect(getStorageValue()).toBe(persistedValue);
});
describe('with bad JSON in storage', () => {
const badJSON = '{ badJSON';
it('does not save a value if persist is set to false', async () => {
const value = 'saved';
const notPersistedValue = 'notPersisted';
createComponent({ asString: true });
// Save some value so we can test that it's not overwritten.
await wrapper.setProps({ value });
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation();
localStorage.setItem(storageKey, badJSON);
expect(getStorageValue()).toBe(value);
createComponent({
props: {
storageKey,
value: 'initial',
asJson: true,
},
});
});
await wrapper.setProps({ persist: false, value: notPersistedValue });
it('should console warn', () => {
// eslint-disable-next-line no-console
expect(console.warn).toHaveBeenCalledWith(
`[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`,
badJSON,
);
});
expect(getStorageValue()).toBe(value);
});
});
it('should not emit an input event', () => {
expect(wrapper.emitted('input')).toBeUndefined();
});
describe('saving and restoring', () => {
it.each`
value | asString
${'foo'} | ${true}
${'foo'} | ${false}
${'{ a: 1 }'} | ${true}
${'{ a: 1 }'} | ${false}
${3} | ${false}
${['foo', 'bar']} | ${false}
${{ foo: 'bar' }} | ${false}
${null} | ${false}
${' '} | ${false}
${true} | ${false}
${false} | ${false}
${42} | ${false}
${'42'} | ${false}
${'{ foo: '} | ${false}
`('saves and restores the same value', async ({ value, asString }) => {
// Create an initial component to save the value.
createComponent({ asString });
await wrapper.setProps({ value });
wrapper.destroy();
// Create a second component to restore the value. Restore is only done once, when the
// component is first mounted.
createComponent({ asString });
expect(wrapper.emitted('input')[0][0]).toEqual(value);
});
it('shows a warning when trying to save a non-string value when asString prop is true', async () => {
const spy = jest.spyOn(console, 'warn').mockImplementation();
createComponent({ asString: true });
await wrapper.setProps({ value: [] });
expect(spy).toHaveBeenCalled();
});
});
describe('with bad JSON in storage', () => {
const badJSON = '{ badJSON';
let spy;
beforeEach(() => {
spy = jest.spyOn(console, 'warn').mockImplementation();
setStorageValue(badJSON);
createComponent();
});
it('should console warn', () => {
expect(spy).toHaveBeenCalled();
});
it('should not emit an input event', () => {
expect(wrapper.emitted('input')).toBeUndefined();
});
});
it('clears localStorage when clear property is true', async () => {
const storageKey = 'key';
const value = 'initial';
createComponent({ asString: true });
await wrapper.setProps({ value });
createComponent({
props: {
storageKey,
},
});
wrapper.setProps({
value,
});
expect(getStorageValue()).toBe(value);
await nextTick();
await wrapper.setProps({ clear: true });
expect(localStorage.getItem(storageKey)).toBe(value);
wrapper.setProps({
clear: true,
});
await nextTick();
expect(localStorage.getItem(storageKey)).toBe(null);
expect(getStorageValue()).toBeNull();
});
});

View File

@ -36,10 +36,10 @@ describe('Persisted dropdown selection', () => {
});
describe('local storage sync', () => {
it('uses the local storage sync component', () => {
it('uses the local storage sync component with the correct props', () => {
createComponent();
expect(findLocalStorageSync().exists()).toBe(true);
expect(findLocalStorageSync().props('asString')).toBe(true);
});
it('passes the right props', () => {

View File

@ -261,7 +261,10 @@ describe('Web IDE link component', () => {
});
it('should update local storage when selection changes', async () => {
expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key);
expect(findLocalStorageSync().props()).toMatchObject({
asString: true,
value: ACTION_WEB_IDE.key,
});
findActionsButton().vm.$emit('select', ACTION_GITPOD.key);

View File

@ -224,7 +224,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
it_behaves_like 'transitioning from allowed states', %w[default]
it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted]
it_behaves_like 'transitioning to pre_importing'
end

View File

@ -698,10 +698,29 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
context 'when there is a deployment record with success status' do
let!(:deployment) { create(:deployment, :success, environment: environment) }
let!(:old_deployment) { create(:deployment, :success, environment: environment, finished_at: 2.days.ago) }
it 'returns the latest successful deployment' do
is_expected.to eq(deployment)
end
context 'env_last_deployment_by_finished_at feature flag' do
it 'when enabled it returns the deployment with the latest finished_at' do
stub_feature_flags(env_last_deployment_by_finished_at: true)
expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
is_expected.to eq(deployment)
end
it 'when disabled it returns the deployment with the highest id' do
stub_feature_flags(env_last_deployment_by_finished_at: false)
expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
is_expected.to eq(old_deployment)
end
end
end
end
end

View File

@ -118,11 +118,14 @@ RSpec.shared_examples 'not hitting graphql network errors with the container reg
end
RSpec.shared_examples 'reconciling migration_state' do
shared_examples 'no action' do
it 'does nothing' do
expect { subject }.not_to change { repository.reload.migration_state }
shared_examples 'enforcing states coherence to' do |expected_migration_state|
it 'leaves the repository in the expected migration_state' do
expect(repository.gitlab_api_client).not_to receive(:pre_import_repository)
expect(repository.gitlab_api_client).not_to receive(:import_repository)
expect(subject).to eq(nil)
subject
expect(repository.reload.migration_state).to eq(expected_migration_state)
end
end
@ -155,7 +158,7 @@ RSpec.shared_examples 'reconciling migration_state' do
context 'import_in_progress response' do
let(:status) { 'import_in_progress' }
it_behaves_like 'no action'
it_behaves_like 'enforcing states coherence to', 'importing'
end
context 'import_complete response' do
@ -175,7 +178,7 @@ RSpec.shared_examples 'reconciling migration_state' do
context 'pre_import_in_progress response' do
let(:status) { 'pre_import_in_progress' }
it_behaves_like 'no action'
it_behaves_like 'enforcing states coherence to', 'pre_importing'
end
context 'pre_import_complete response' do
@ -194,4 +197,12 @@ RSpec.shared_examples 'reconciling migration_state' do
it_behaves_like 'retrying the pre_import'
end
%w[pre_import_canceled import_canceled].each do |canceled_status|
context "#{canceled_status} response" do
let(:status) { canceled_status }
it_behaves_like 'enforcing states coherence to', 'import_skipped'
end
end
end

View File

@ -10,7 +10,7 @@ RSpec.describe Namespaces::RootStatisticsWorker, '#perform' do
context 'with a namespace' do
it 'executes refresher service' do
expect_any_instance_of(Namespaces::StatisticsRefresherService)
.to receive(:execute)
.to receive(:execute).and_call_original
worker.perform(group.id)
end