Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
27f3465d8a
commit
93b0b77287
|
@ -238,7 +238,7 @@ export default {
|
|||
: '';
|
||||
},
|
||||
statusIcon() {
|
||||
return this.isClosed ? 'mobile-issue-close' : 'issue-open-m';
|
||||
return this.isClosed ? 'issue-close' : 'issue-open-m';
|
||||
},
|
||||
statusText() {
|
||||
return IssuableStatusText[this.issuableStatus];
|
||||
|
|
|
@ -1,146 +0,0 @@
|
|||
<script>
|
||||
/* eslint-disable vue/require-default-prop */
|
||||
import { __ } from '~/locale';
|
||||
|
||||
export default {
|
||||
name: 'DeprecatedModal', // use GlModal instead
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
hideFooter: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
kind: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'primary',
|
||||
},
|
||||
modalDialogClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
closeKind: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'default',
|
||||
},
|
||||
closeButtonLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: __('Cancel'),
|
||||
},
|
||||
primaryButtonLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
secondaryButtonLabel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
submitDisabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
btnKindClass() {
|
||||
return {
|
||||
[`btn-${this.kind}`]: true,
|
||||
};
|
||||
},
|
||||
btnCancelKindClass() {
|
||||
return {
|
||||
[`btn-${this.closeKind}`]: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
emitCancel(event) {
|
||||
this.$emit('cancel', event);
|
||||
},
|
||||
emitSubmit(event) {
|
||||
this.$emit('submit', event);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-open">
|
||||
<div :id="id" :class="id ? '' : 'd-block'" class="modal" role="dialog" tabindex="-1">
|
||||
<div :class="modalDialogClass" class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<slot name="header">
|
||||
<h4 class="modal-title float-left">{{ title }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="close float-right"
|
||||
data-dismiss="modal"
|
||||
:aria-label="__('Close')"
|
||||
@click="emitCancel($event)"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot :text="text" name="body">
|
||||
<p>{{ text }}</p>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="!hideFooter" class="modal-footer">
|
||||
<button
|
||||
:class="btnCancelKindClass"
|
||||
type="button"
|
||||
class="btn"
|
||||
data-dismiss="modal"
|
||||
@click="emitCancel($event)"
|
||||
>
|
||||
{{ closeButtonLabel }}
|
||||
</button>
|
||||
|
||||
<slot v-if="secondaryButtonLabel" name="secondary-button">
|
||||
<button v-if="secondaryButtonLabel" type="button" class="btn" data-dismiss="modal">
|
||||
{{ secondaryButtonLabel }}
|
||||
</button>
|
||||
</slot>
|
||||
|
||||
<button
|
||||
v-if="primaryButtonLabel"
|
||||
:disabled="submitDisabled"
|
||||
:class="btnKindClass"
|
||||
type="button"
|
||||
class="btn js-primary-button"
|
||||
data-dismiss="modal"
|
||||
data-qa-selector="save_changes_button"
|
||||
@click="emitSubmit($event)"
|
||||
>
|
||||
{{ primaryButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!id" class="modal-backdrop fade show"></div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,21 +0,0 @@
|
|||
import createEventHub from '~/helpers/event_hub_factory';
|
||||
|
||||
// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
|
||||
export const callbackName = 'recaptchaDialogCallback';
|
||||
|
||||
export const eventHub = createEventHub();
|
||||
|
||||
const throwDuplicateCallbackError = () => {
|
||||
throw new Error(`${callbackName} is already defined!`);
|
||||
};
|
||||
|
||||
if (window[callbackName]) {
|
||||
throwDuplicateCallbackError();
|
||||
}
|
||||
|
||||
const callback = () => eventHub.$emit('submit');
|
||||
|
||||
Object.defineProperty(window, callbackName, {
|
||||
get: () => callback,
|
||||
set: throwDuplicateCallbackError,
|
||||
});
|
|
@ -1,90 +0,0 @@
|
|||
<script>
|
||||
/* eslint-disable vue/no-v-html */
|
||||
import DeprecatedModal from './deprecated_modal.vue';
|
||||
import { eventHub } from './recaptcha_eventhub';
|
||||
|
||||
export default {
|
||||
name: 'RecaptchaModal',
|
||||
|
||||
components: {
|
||||
DeprecatedModal,
|
||||
},
|
||||
|
||||
props: {
|
||||
html: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
script: {},
|
||||
scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js',
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
html() {
|
||||
this.appendRecaptchaScript();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
eventHub.$on('submit', this.submit);
|
||||
|
||||
if (this.html) {
|
||||
this.appendRecaptchaScript();
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
eventHub.$off('submit', this.submit);
|
||||
},
|
||||
|
||||
methods: {
|
||||
appendRecaptchaScript() {
|
||||
this.removeRecaptchaScript();
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = this.scriptSrc;
|
||||
script.classList.add('js-recaptcha-script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
|
||||
this.script = script;
|
||||
|
||||
document.body.appendChild(script);
|
||||
},
|
||||
|
||||
removeRecaptchaScript() {
|
||||
if (this.script instanceof Element) this.script.remove();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.removeRecaptchaScript();
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.$el.querySelector('form').submit();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<deprecated-modal
|
||||
:hide-footer="true"
|
||||
:title="__('Please solve the reCAPTCHA')"
|
||||
kind="warning"
|
||||
class="recaptcha-modal js-recaptcha-modal"
|
||||
@cancel="close"
|
||||
>
|
||||
<div slot="body">
|
||||
<p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
|
||||
<div ref="recaptcha" v-html="html"></div>
|
||||
</div>
|
||||
</deprecated-modal>
|
||||
</template>
|
|
@ -1,36 +0,0 @@
|
|||
import recaptchaModal from '../components/recaptcha_modal.vue';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showRecaptcha: false,
|
||||
recaptchaHTML: '',
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
recaptchaModal,
|
||||
},
|
||||
|
||||
methods: {
|
||||
openRecaptcha() {
|
||||
this.showRecaptcha = true;
|
||||
},
|
||||
|
||||
closeRecaptcha() {
|
||||
this.showRecaptcha = false;
|
||||
},
|
||||
|
||||
checkForSpam(data) {
|
||||
if (!data.recaptcha_html) return data;
|
||||
|
||||
this.recaptchaHTML = data.recaptcha_html;
|
||||
|
||||
const spamError = new Error(data.error_message);
|
||||
spamError.name = 'SpamError';
|
||||
spamError.message = 'SpamError';
|
||||
|
||||
throw spamError;
|
||||
},
|
||||
},
|
||||
};
|
|
@ -124,7 +124,7 @@ class GitlabSchema < GraphQL::Schema
|
|||
|
||||
raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab ID." unless gid
|
||||
|
||||
if expected_type && !gid.model_class.ancestors.include?(expected_type)
|
||||
if expected_type && gid.model_class.ancestors.exclude?(expected_type)
|
||||
vars = { global_id: global_id, expected_type: expected_type }
|
||||
msg = _('%{global_id} is not a valid ID for %{expected_type}.') % vars
|
||||
raise Gitlab::Graphql::Errors::ArgumentError, msg
|
||||
|
|
|
@ -39,9 +39,7 @@ module Resolvers
|
|||
as_single << block
|
||||
|
||||
# Have we been called after defining the single version of this resolver?
|
||||
if @single.present?
|
||||
@single.instance_exec(&block)
|
||||
end
|
||||
@single.instance_exec(&block) if @single.present?
|
||||
end
|
||||
|
||||
def self.as_single
|
||||
|
@ -90,7 +88,7 @@ module Resolvers
|
|||
|
||||
def self.last
|
||||
parent = self
|
||||
@last ||= Class.new(self.single) do
|
||||
@last ||= Class.new(single) do
|
||||
type parent.singular_type, null: true
|
||||
|
||||
def select_result(results)
|
||||
|
|
|
@ -9,9 +9,9 @@ module Resolvers
|
|||
type ::Types::MergeRequestType, null: true
|
||||
|
||||
argument :iid, GraphQL::STRING_TYPE,
|
||||
required: true,
|
||||
as: :iids,
|
||||
description: 'IID of the merge request, for example `1`.'
|
||||
required: true,
|
||||
as: :iids,
|
||||
description: 'IID of the merge request, for example `1`.'
|
||||
|
||||
def no_results_possible?(args)
|
||||
project.nil?
|
||||
|
|
|
@ -10,35 +10,41 @@ module Resolvers
|
|||
|
||||
def self.accept_assignee
|
||||
argument :assignee_username, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'Username of the assignee.'
|
||||
required: false,
|
||||
description: 'Username of the assignee.'
|
||||
end
|
||||
|
||||
def self.accept_author
|
||||
argument :author_username, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'Username of the author.'
|
||||
required: false,
|
||||
description: 'Username of the author.'
|
||||
end
|
||||
|
||||
def self.accept_reviewer
|
||||
argument :reviewer_username, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'Username of the reviewer.'
|
||||
required: false,
|
||||
description: 'Username of the reviewer.'
|
||||
end
|
||||
|
||||
argument :iids, [GraphQL::STRING_TYPE],
|
||||
required: false,
|
||||
description: 'Array of IIDs of merge requests, for example `[1, 2]`.'
|
||||
required: false,
|
||||
description: 'Array of IIDs of merge requests, for example `[1, 2]`.'
|
||||
|
||||
argument :source_branches, [GraphQL::STRING_TYPE],
|
||||
required: false,
|
||||
as: :source_branch,
|
||||
description: 'Array of source branch names. All resolved merge requests will have one of these branches as their source.'
|
||||
description: <<~DESC
|
||||
Array of source branch names.
|
||||
All resolved merge requests will have one of these branches as their source.
|
||||
DESC
|
||||
|
||||
argument :target_branches, [GraphQL::STRING_TYPE],
|
||||
required: false,
|
||||
as: :target_branch,
|
||||
description: 'Array of target branch names. All resolved merge requests will have one of these branches as their target.'
|
||||
description: <<~DESC
|
||||
Array of target branch names.
|
||||
All resolved merge requests will have one of these branches as their target.
|
||||
DESC
|
||||
|
||||
argument :state, ::Types::MergeRequestStateEnum,
|
||||
required: false,
|
||||
|
|
|
@ -4,13 +4,21 @@ module Resolvers
|
|||
class UserMergeRequestsResolverBase < MergeRequestsResolver
|
||||
include ResolvesProject
|
||||
|
||||
argument :project_path, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'The full-path of the project the authored merge requests should be in. Incompatible with projectId.'
|
||||
argument :project_path,
|
||||
type: GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: <<~DESC
|
||||
The full-path of the project the authored merge requests should be in.
|
||||
Incompatible with projectId.
|
||||
DESC
|
||||
|
||||
argument :project_id, ::Types::GlobalIDType[::Project],
|
||||
required: false,
|
||||
description: 'The global ID of the project the authored merge requests should be in. Incompatible with projectPath.'
|
||||
argument :project_id,
|
||||
type: ::Types::GlobalIDType[::Project],
|
||||
required: false,
|
||||
description: <<~DESC
|
||||
The global ID of the project the authored merge requests should be in.
|
||||
Incompatible with projectPath.
|
||||
DESC
|
||||
|
||||
attr_reader :project
|
||||
alias_method :user, :object
|
||||
|
@ -22,8 +30,7 @@ module Resolvers
|
|||
load_project(project_path, project_id)
|
||||
return early_return unless can_read_project?
|
||||
elsif args[:iids].present?
|
||||
raise ::Gitlab::Graphql::Errors::ArgumentError,
|
||||
'iids requires projectPath or projectId'
|
||||
raise ::Gitlab::Graphql::Errors::ArgumentError, 'iids requires projectPath or projectId'
|
||||
end
|
||||
|
||||
super(**args)
|
||||
|
|
|
@ -21,7 +21,10 @@ module Types
|
|||
graphql_name(enum_mod.name) if use_name
|
||||
description(enum_mod.description) if use_description
|
||||
|
||||
enum_mod.definition.each { |key, content| value(key.to_s.upcase, **content) }
|
||||
enum_mod.definition.each do |key, content|
|
||||
desc = content.delete(:description)
|
||||
value(key.to_s.upcase, description: desc, **content)
|
||||
end
|
||||
end
|
||||
|
||||
def value(*args, **kwargs, &block)
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
%strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.new_path, container: 'body' } }
|
||||
= new_path
|
||||
- else
|
||||
%strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.file_path, container: 'body' } }
|
||||
%strong.file-title-name.has-tooltip.gl-word-break-all{ data: { title: diff_file.file_path, container: 'body', qa_selector: 'file_name_content' } }
|
||||
= diff_file.file_path
|
||||
|
||||
- if diff_file.deleted_file?
|
||||
|
@ -37,3 +37,4 @@
|
|||
|
||||
- if diff_file.stored_externally? && diff_file.external_storage == :lfs
|
||||
%span.badge.label-lfs.gl-mr-2 LFS
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
Pipelines
|
||||
%span.badge.badge-pill= @pipelines.size
|
||||
%li.diffs-tab
|
||||
= link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue'} do
|
||||
= link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do
|
||||
Changes
|
||||
%span.badge.badge-pill= @merge_request.diff_size
|
||||
|
||||
|
|
|
@ -19,9 +19,10 @@
|
|||
|
||||
= render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do
|
||||
= render 'shared/zen', f: form, attr: :description,
|
||||
classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description',
|
||||
classes: 'note-textarea rspec-issuable-form-description',
|
||||
placeholder: placeholder,
|
||||
supports_quick_actions: supports_quick_actions
|
||||
supports_quick_actions: supports_quick_actions,
|
||||
qa_selector: 'issuable_form_description'
|
||||
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
|
||||
.clearfix
|
||||
.error-alert
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
.detail-page-header
|
||||
.detail-page-header-body
|
||||
.issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(issuable, status_box: :closed) }
|
||||
= sprite_icon('mobile-issue-close', css_class: 'gl-display-block gl-sm-display-none!')
|
||||
= sprite_icon('issue-close', css_class: 'gl-display-block gl-sm-display-none!')
|
||||
.gl-display-none.gl-sm-display-block!
|
||||
= issue_closed_text(issuable, current_user)
|
||||
.issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(issuable, status_box: :open) }
|
||||
|
|
|
@ -22993,9 +22993,6 @@ msgstr ""
|
|||
msgid "Please solve the captcha"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please solve the reCAPTCHA"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please try again"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ module QA
|
|||
|
||||
view 'app/assets/javascripts/boards/components/board_form.vue' do
|
||||
element :board_name_field
|
||||
element :save_changes_button
|
||||
end
|
||||
|
||||
view 'app/assets/javascripts/boards/components/board_list.vue' do
|
||||
|
@ -23,10 +24,6 @@ module QA
|
|||
element :create_new_board_button
|
||||
end
|
||||
|
||||
view 'app/assets/javascripts/vue_shared/components/deprecated_modal.vue' do
|
||||
element :save_changes_button
|
||||
end
|
||||
|
||||
view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue' do
|
||||
element :labels_dropdown_content
|
||||
end
|
||||
|
|
|
@ -8,8 +8,33 @@ module QA
|
|||
element :issuable_create_button, required: true
|
||||
end
|
||||
|
||||
view 'app/views/shared/form_elements/_description.html.haml' do
|
||||
element :issuable_form_description
|
||||
end
|
||||
|
||||
view 'app/views/projects/merge_requests/show.html.haml' do
|
||||
element :diffs_tab
|
||||
end
|
||||
|
||||
view 'app/assets/javascripts/diffs/components/diff_file_header.vue' do
|
||||
element :file_name_content
|
||||
end
|
||||
|
||||
def create_merge_request
|
||||
click_element :issuable_create_button, Page::MergeRequest::Show
|
||||
click_element(:issuable_create_button, Page::MergeRequest::Show)
|
||||
end
|
||||
|
||||
def has_description?(description)
|
||||
has_element?(:issuable_form_description, text: description)
|
||||
end
|
||||
|
||||
def click_diffs_tab
|
||||
click_element(:diffs_tab)
|
||||
click_element(:dismiss_popover_button) if has_element?(:dismiss_popover_button, wait: 1)
|
||||
end
|
||||
|
||||
def has_file?(file_name)
|
||||
has_element?(:file_name_content, text: file_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import mountComponent from 'helpers/vue_mount_component_helper';
|
||||
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
|
||||
|
||||
const modalComponent = Vue.extend(DeprecatedModal);
|
||||
|
||||
describe('DeprecatedModal', () => {
|
||||
let vm;
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('props', () => {
|
||||
describe('without primaryButtonLabel', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(modalComponent, {
|
||||
primaryButtonLabel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render a primary button', () => {
|
||||
expect(vm.$el.querySelector('.js-primary-button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with id', () => {
|
||||
describe('does not render a primary button', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(modalComponent, {
|
||||
id: 'my-modal',
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns the id to the modal', () => {
|
||||
expect(vm.$el.querySelector('#my-modal.modal')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not show the modal immediately', () => {
|
||||
expect(vm.$el.querySelector('#my-modal.modal')).not.toHaveClass('show');
|
||||
});
|
||||
|
||||
it('does not show a backdrop', () => {
|
||||
expect(vm.$el.querySelector('modal-backdrop')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('works with data-toggle="modal"', () => {
|
||||
setFixtures(`
|
||||
<button id="modal-button" data-toggle="modal" data-target="#my-modal"></button>
|
||||
<div id="modal-container"></div>
|
||||
`);
|
||||
|
||||
const modalContainer = document.getElementById('modal-container');
|
||||
const modalButton = document.getElementById('modal-button');
|
||||
vm = mountComponent(
|
||||
modalComponent,
|
||||
{
|
||||
id: 'my-modal',
|
||||
},
|
||||
modalContainer,
|
||||
);
|
||||
const modalElement = vm.$el.querySelector('#my-modal');
|
||||
|
||||
expect(modalElement).not.toHaveClass('show');
|
||||
|
||||
modalButton.click();
|
||||
|
||||
expect(modalElement).toHaveClass('show');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,21 +0,0 @@
|
|||
import { eventHub, callbackName } from '~/vue_shared/components/recaptcha_eventhub';
|
||||
|
||||
describe('reCAPTCHA event hub', () => {
|
||||
// the following test case currently crashes
|
||||
// see https://gitlab.com/gitlab-org/gitlab/issues/29192#note_217840035
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('throws an error for overriding the callback', () => {
|
||||
expect(() => {
|
||||
window[callbackName] = 'something';
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('triggering callback emits a submit event', () => {
|
||||
const eventHandler = jest.fn();
|
||||
eventHub.$once('submit', eventHandler);
|
||||
|
||||
window[callbackName]();
|
||||
|
||||
expect(eventHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -1,35 +0,0 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import { eventHub } from '~/vue_shared/components/recaptcha_eventhub';
|
||||
|
||||
import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue';
|
||||
|
||||
describe('RecaptchaModal', () => {
|
||||
const recaptchaFormId = 'recaptcha-form';
|
||||
const recaptchaHtml = `<form id="${recaptchaFormId}"></form>`;
|
||||
|
||||
let wrapper;
|
||||
|
||||
const findRecaptchaForm = () => wrapper.find(`#${recaptchaFormId}`).element;
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = shallowMount(RecaptchaModal, {
|
||||
propsData: {
|
||||
html: recaptchaHtml,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('submits the form if event hub emits submit event', () => {
|
||||
const form = findRecaptchaForm();
|
||||
jest.spyOn(form, 'submit').mockImplementation();
|
||||
|
||||
eventHub.$emit('submit');
|
||||
|
||||
expect(form.submit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -84,9 +84,9 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
|||
permissions = permission_collection
|
||||
query_factory do |qt|
|
||||
qt.field :item, type,
|
||||
null: true,
|
||||
resolver: new_resolver(test_object),
|
||||
authorize: permissions
|
||||
null: true,
|
||||
resolver: new_resolver(test_object),
|
||||
authorize: permissions
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -123,8 +123,9 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
|||
let(:type) do
|
||||
permissions = permission_collection
|
||||
type_factory do |type|
|
||||
type.field :name, GraphQL::STRING_TYPE, null: true,
|
||||
authorize: permissions
|
||||
type.field :name, GraphQL::STRING_TYPE,
|
||||
null: true,
|
||||
authorize: permissions
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -201,9 +202,10 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
|||
|
||||
let(:query_type) do
|
||||
query_factory do |query|
|
||||
query.field :item, type, null: true,
|
||||
resolver: resolver,
|
||||
authorize: permission_2
|
||||
query.field :item, type,
|
||||
null: true,
|
||||
resolver: resolver,
|
||||
authorize: permission_2
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -288,8 +290,12 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
|||
let(:query_string) { '{ item(first: 1) { edges { node { name } } } }' }
|
||||
|
||||
it 'only checks permissions for the first object' do
|
||||
expect(Ability).to receive(:allowed?).with(user, permission_single, test_object) { true }
|
||||
expect(Ability).not_to receive(:allowed?).with(user, permission_single, second_test_object)
|
||||
expect(Ability)
|
||||
.to receive(:allowed?)
|
||||
.with(user, permission_single, test_object)
|
||||
.and_return(true)
|
||||
expect(Ability)
|
||||
.not_to receive(:allowed?).with(user, permission_single, second_test_object)
|
||||
|
||||
expect(subject.size).to eq(1)
|
||||
end
|
||||
|
@ -330,10 +336,12 @@ RSpec.describe 'DeclarativePolicy authorization in GraphQL ' do
|
|||
end
|
||||
|
||||
let(:project_type) do |type|
|
||||
issues = Issue.where(project: [visible_project, other_project]).order(id: :asc)
|
||||
type_factory do |type|
|
||||
type.graphql_name 'FakeProjectType'
|
||||
type.field :test_issues, issue_type.connection_type, null: false,
|
||||
resolver: new_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc))
|
||||
type.field :test_issues, issue_type.connection_type,
|
||||
null: false,
|
||||
resolver: new_resolver(issues)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -56,10 +56,10 @@ RSpec.describe Mutations::DesignManagement::Upload do
|
|||
.map { |f| RenameableUpload.unique_file(f) }
|
||||
end
|
||||
|
||||
def creates_designs
|
||||
def creates_designs(&block)
|
||||
prior_count = DesignManagement::Design.count
|
||||
|
||||
expect { yield }.not_to raise_error
|
||||
expect(&block).not_to raise_error
|
||||
|
||||
expect(DesignManagement::Design.count).to eq(prior_count + files.size)
|
||||
end
|
||||
|
|
|
@ -7,6 +7,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
include SortingHelper
|
||||
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
let_it_be(:other_project) { create(:project, :repository) }
|
||||
let_it_be(:milestone) { create(:milestone, project: project) }
|
||||
let_it_be(:current_user) { create(:user) }
|
||||
let_it_be(:other_user) { create(:user) }
|
||||
|
@ -16,10 +17,17 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
let_it_be(:merge_request_3) { create(:merge_request, :unique_branches, **common_attrs) }
|
||||
let_it_be(:merge_request_4) { create(:merge_request, :unique_branches, :locked, **common_attrs) }
|
||||
let_it_be(:merge_request_5) { create(:merge_request, :simple, :locked, **common_attrs) }
|
||||
let_it_be(:merge_request_6) { create(:labeled_merge_request, :unique_branches, labels: create_list(:label, 2, project: project), **common_attrs) }
|
||||
let_it_be(:merge_request_with_milestone) { create(:merge_request, :unique_branches, **common_attrs, milestone: milestone) }
|
||||
let_it_be(:other_project) { create(:project, :repository) }
|
||||
let_it_be(:other_merge_request) { create(:merge_request, source_project: other_project, target_project: other_project) }
|
||||
let_it_be(:merge_request_6) do
|
||||
create(:labeled_merge_request, :unique_branches, **common_attrs, labels: create_list(:label, 2, project: project))
|
||||
end
|
||||
|
||||
let_it_be(:merge_request_with_milestone) do
|
||||
create(:merge_request, :unique_branches, **common_attrs, milestone: milestone)
|
||||
end
|
||||
|
||||
let_it_be(:other_merge_request) do
|
||||
create(:merge_request, source_project: other_project, target_project: other_project)
|
||||
end
|
||||
|
||||
let(:iid_1) { merge_request_1.iid }
|
||||
let(:iid_2) { merge_request_2.iid }
|
||||
|
@ -43,11 +51,14 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
# SELECT "project_features".* FROM "project_features" WHERE "project_features"."project_id" = 2
|
||||
let(:queries_per_project) { 4 }
|
||||
|
||||
context 'no arguments' do
|
||||
context 'without arguments' do
|
||||
it 'returns all merge requests' do
|
||||
result = resolve_mr(project)
|
||||
|
||||
expect(result).to contain_exactly(merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone)
|
||||
expect(result).to contain_exactly(
|
||||
merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5,
|
||||
merge_request_6, merge_request_with_milestone
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns only merge requests that the current user can see' do
|
||||
|
@ -57,7 +68,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by iid alone' do
|
||||
context 'with iid alone' do
|
||||
it 'batch-resolves by target project full path and individual IID', :request_store do
|
||||
# 1 query for project_authorizations, and 1 for merge_requests
|
||||
result = batch_sync(max_queries: queries_per_project) do
|
||||
|
@ -83,7 +94,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
expect(result).to contain_exactly(merge_request_1, merge_request_2, merge_request_3)
|
||||
end
|
||||
|
||||
it 'can batch-resolve merge requests from different projects', :request_store, :use_clean_rails_memory_store_caching do
|
||||
it 'can batch-resolve merge requests from different projects', :request_store do
|
||||
# 2 queries for project_authorizations, and 2 for merge_requests
|
||||
results = batch_sync(max_queries: queries_per_project * 2) do
|
||||
a = resolve_mr(project, iids: [iid_1])
|
||||
|
@ -121,7 +132,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by source branches' do
|
||||
context 'with source branches argument' do
|
||||
it 'takes one argument' do
|
||||
result = resolve_mr(project, source_branches: [merge_request_3.source_branch])
|
||||
|
||||
|
@ -131,13 +142,13 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
it 'takes more than one argument' do
|
||||
mrs = [merge_request_3, merge_request_4]
|
||||
branches = mrs.map(&:source_branch)
|
||||
result = resolve_mr(project, source_branches: branches )
|
||||
result = resolve_mr(project, source_branches: branches)
|
||||
|
||||
expect(result).to match_array(mrs)
|
||||
end
|
||||
end
|
||||
|
||||
context 'by target branches' do
|
||||
context 'with target branches argument' do
|
||||
it 'takes one argument' do
|
||||
result = resolve_mr(project, target_branches: [merge_request_3.target_branch])
|
||||
|
||||
|
@ -153,7 +164,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by state' do
|
||||
context 'with state argument' do
|
||||
it 'takes one argument' do
|
||||
result = resolve_mr(project, state: 'locked')
|
||||
|
||||
|
@ -161,7 +172,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by label' do
|
||||
context 'with label argument' do
|
||||
let_it_be(:label) { merge_request_6.labels.first }
|
||||
let_it_be(:with_label) { create(:labeled_merge_request, :closed, labels: [label], **common_attrs) }
|
||||
|
||||
|
@ -178,7 +189,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by merged_after and merged_before' do
|
||||
context 'with merged_after and merged_before arguments' do
|
||||
before do
|
||||
merge_request_1.metrics.update!(merged_at: 10.days.ago)
|
||||
end
|
||||
|
@ -196,7 +207,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'by milestone' do
|
||||
context 'with milestone argument' do
|
||||
it 'filters merge requests by milestone title' do
|
||||
result = resolve_mr(project, milestone_title: milestone.title)
|
||||
|
||||
|
@ -212,7 +223,7 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
|
||||
describe 'combinations' do
|
||||
it 'requires all filters' do
|
||||
create(:merge_request, :closed, source_project: project, target_project: project, source_branch: merge_request_4.source_branch)
|
||||
create(:merge_request, :closed, **common_attrs, source_branch: merge_request_4.source_branch)
|
||||
|
||||
result = resolve_mr(project, source_branches: [merge_request_4.source_branch], state: 'locked')
|
||||
|
||||
|
|
|
@ -48,14 +48,14 @@ RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do
|
|||
end
|
||||
end
|
||||
|
||||
context 'group integration' do
|
||||
describe 'a group integration' do
|
||||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:integration) { create(:prometheus_service, project: nil, group: group) }
|
||||
|
||||
# Since it is impossible to authorize the parent here, given that the
|
||||
# project is nil, all fields should be redacted:
|
||||
|
||||
described_class.fields.keys.each do |field_name|
|
||||
described_class.fields.each_key do |field_name|
|
||||
context "field: #{field_name}" do
|
||||
it 'is redacted' do
|
||||
expect do
|
||||
|
|
|
@ -196,20 +196,20 @@ RSpec.describe Types::BaseObject do
|
|||
end
|
||||
|
||||
# For example a batchloaded association
|
||||
context 'a lazy list' do
|
||||
describe 'a lazy list' do
|
||||
it_behaves_like 'array member redaction', %w[lazyListOfYs]
|
||||
end
|
||||
|
||||
# For example using a batchloader to map over a set of IDs
|
||||
context 'a list of lazy items' do
|
||||
describe 'a list of lazy items' do
|
||||
it_behaves_like 'array member redaction', %w[listOfLazyYs]
|
||||
end
|
||||
|
||||
context 'an array connection of items' do
|
||||
describe 'an array connection of items' do
|
||||
it_behaves_like 'array member redaction', %w[arrayYsConn nodes]
|
||||
end
|
||||
|
||||
context 'an array connection of items, selecting edges' do
|
||||
describe 'an array connection of items, selecting edges' do
|
||||
it_behaves_like 'array member redaction', %w[arrayYsConn edges node]
|
||||
end
|
||||
|
||||
|
@ -222,7 +222,7 @@ RSpec.describe Types::BaseObject do
|
|||
}
|
||||
}
|
||||
|
||||
doc = ->(after) do
|
||||
doc = lambda do |after|
|
||||
GraphQL.parse(<<~GQL)
|
||||
query {
|
||||
x {
|
||||
|
@ -238,9 +238,7 @@ RSpec.describe Types::BaseObject do
|
|||
}
|
||||
GQL
|
||||
end
|
||||
returned_items = ->(ids) do
|
||||
ids.to_a.map { |id| eq({ 'id' => id }) }
|
||||
end
|
||||
returned_items = ->(ids) { ids.to_a.map { |id| eq({ 'id' => id }) } }
|
||||
|
||||
query = GraphQL::Query.new(test_schema, document: doc[nil], context: data)
|
||||
result = query.result.to_h
|
||||
|
|
|
@ -106,7 +106,8 @@ RSpec.describe GitlabSchema.types['Project'] do
|
|||
expect(secure_analyzers_prefix['type']).to eq('string')
|
||||
expect(secure_analyzers_prefix['field']).to eq('SECURE_ANALYZERS_PREFIX')
|
||||
expect(secure_analyzers_prefix['label']).to eq('Image prefix')
|
||||
expect(secure_analyzers_prefix['defaultValue']).to eq('registry.gitlab.com/gitlab-org/security-products/analyzers')
|
||||
expect(secure_analyzers_prefix['defaultValue'])
|
||||
.to eq('registry.gitlab.com/gitlab-org/security-products/analyzers')
|
||||
expect(secure_analyzers_prefix['value']).to eq('registry.gitlab.com/gitlab-org/security-products/analyzers')
|
||||
expect(secure_analyzers_prefix['size']).to eq('LARGE')
|
||||
expect(secure_analyzers_prefix['options']).to be_nil
|
||||
|
@ -184,9 +185,11 @@ RSpec.describe GitlabSchema.types['Project'] do
|
|||
|
||||
context 'when repository is accessible only by team members' do
|
||||
it "returns no configuration" do
|
||||
project.project_feature.update!(merge_requests_access_level: ProjectFeature::DISABLED,
|
||||
builds_access_level: ProjectFeature::DISABLED,
|
||||
repository_access_level: ProjectFeature::PRIVATE)
|
||||
project.project_feature.update!(
|
||||
merge_requests_access_level: ProjectFeature::DISABLED,
|
||||
builds_access_level: ProjectFeature::DISABLED,
|
||||
repository_access_level: ProjectFeature::PRIVATE
|
||||
)
|
||||
|
||||
secure_analyzers_prefix = subject.dig('data', 'project', 'sastCiConfiguration')
|
||||
expect(secure_analyzers_prefix).to be_nil
|
||||
|
@ -342,8 +345,13 @@ RSpec.describe GitlabSchema.types['Project'] do
|
|||
let_it_be(:project) { create(:project, :public) }
|
||||
|
||||
context 'when project has Jira imports' do
|
||||
let_it_be(:jira_import1) { create(:jira_import_state, :finished, project: project, jira_project_key: 'AA', created_at: 2.days.ago) }
|
||||
let_it_be(:jira_import2) { create(:jira_import_state, :finished, project: project, jira_project_key: 'BB', created_at: 5.days.ago) }
|
||||
let_it_be(:jira_import1) do
|
||||
create(:jira_import_state, :finished, project: project, jira_project_key: 'AA', created_at: 2.days.ago)
|
||||
end
|
||||
|
||||
let_it_be(:jira_import2) do
|
||||
create(:jira_import_state, :finished, project: project, jira_project_key: 'BB', created_at: 5.days.ago)
|
||||
end
|
||||
|
||||
it 'retrieves the imports' do
|
||||
expect(subject).to contain_exactly(jira_import1, jira_import2)
|
||||
|
|
|
@ -12,7 +12,8 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeResource do
|
|||
authorize :read_the_thing
|
||||
|
||||
def initialize(user, found_object)
|
||||
@user, @found_object = user, found_object
|
||||
@user = user
|
||||
@found_object = found_object
|
||||
end
|
||||
|
||||
def find_object
|
||||
|
@ -40,16 +41,12 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeResource do
|
|||
|
||||
before do
|
||||
# don't allow anything by default
|
||||
allow(Ability).to receive(:allowed?) do
|
||||
false
|
||||
end
|
||||
allow(Ability).to receive(:allowed?).and_return(false)
|
||||
end
|
||||
|
||||
context 'when the user is allowed to perform the action' do
|
||||
before do
|
||||
allow(Ability).to receive(:allowed?).with(user, :read_the_thing, project) do
|
||||
true
|
||||
end
|
||||
allow(Ability).to receive(:allowed?).with(user, :read_the_thing, project).and_return(true)
|
||||
end
|
||||
|
||||
describe '#authorized_find!' do
|
||||
|
|
|
@ -3,10 +3,11 @@ require 'spec_helper'
|
|||
|
||||
RSpec.describe 'GraphQL' do
|
||||
include GraphqlHelpers
|
||||
include AfterNextHelpers
|
||||
|
||||
let(:query) { graphql_query_for('echo', text: 'Hello world' ) }
|
||||
let(:query) { graphql_query_for('echo', text: 'Hello world') }
|
||||
|
||||
context 'logging' do
|
||||
describe 'logging' do
|
||||
shared_examples 'logging a graphql query' do
|
||||
let(:expected_params) do
|
||||
{
|
||||
|
@ -51,19 +52,25 @@ RSpec.describe 'GraphQL' do
|
|||
|
||||
context 'when there is an error in the logger' do
|
||||
before do
|
||||
allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer).to receive(:process_variables).and_raise(StandardError.new("oh noes!"))
|
||||
logger_analyzer = GitlabSchema.query_analyzers.find do |qa|
|
||||
qa.is_a? Gitlab::Graphql::QueryAnalyzers::LoggerAnalyzer
|
||||
end
|
||||
allow(logger_analyzer).to receive(:process_variables)
|
||||
.and_raise(StandardError.new("oh noes!"))
|
||||
end
|
||||
|
||||
it 'logs the exception in Sentry and continues with the request' do
|
||||
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).at_least(:once)
|
||||
expect(Gitlab::GraphqlLogger).to receive(:info)
|
||||
expect(Gitlab::ErrorTracking)
|
||||
.to receive(:track_and_raise_for_dev_exception).at_least(:once)
|
||||
expect(Gitlab::GraphqlLogger)
|
||||
.to receive(:info)
|
||||
|
||||
post_graphql(query, variables: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'invalid variables' do
|
||||
context 'with invalid variables' do
|
||||
it 'returns an error' do
|
||||
post_graphql(query, variables: "This is not JSON")
|
||||
|
||||
|
@ -72,7 +79,7 @@ RSpec.describe 'GraphQL' do
|
|||
end
|
||||
end
|
||||
|
||||
context 'authentication', :allow_forgery_protection do
|
||||
describe 'authentication', :allow_forgery_protection do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
it 'allows access to public data without authentication' do
|
||||
|
@ -99,7 +106,7 @@ RSpec.describe 'GraphQL' do
|
|||
expect(graphql_data['echo']).to eq("\"#{user.username}\" says: Hello world")
|
||||
end
|
||||
|
||||
context 'token authentication' do
|
||||
context 'with token authentication' do
|
||||
let(:token) { create(:personal_access_token) }
|
||||
|
||||
before do
|
||||
|
@ -119,7 +126,7 @@ RSpec.describe 'GraphQL' do
|
|||
|
||||
context 'when the personal access token has no api scope' do
|
||||
it 'does not log the user in' do
|
||||
token.update(scopes: [:read_user])
|
||||
token.update!(scopes: [:read_user])
|
||||
|
||||
post_graphql(query, headers: { 'PRIVATE-TOKEN' => token.token })
|
||||
|
||||
|
@ -136,7 +143,11 @@ RSpec.describe 'GraphQL' do
|
|||
let(:user) { create(:user) }
|
||||
|
||||
let(:query) do
|
||||
graphql_query_for('project', { 'fullPath' => project.full_path }, %w(id))
|
||||
graphql_query_for(
|
||||
:project,
|
||||
{ full_path: project.full_path },
|
||||
'id'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
|
@ -202,8 +213,8 @@ RSpec.describe 'GraphQL' do
|
|||
let_it_be(:issues) { create_list(:issue, 10, project: project, created_at: Time.now.change(usec: 200)) }
|
||||
|
||||
let(:page_size) { 6 }
|
||||
let(:issues_edges) { %w(data project issues edges) }
|
||||
let(:end_cursor) { %w(data project issues pageInfo endCursor) }
|
||||
let(:issues_edges) { %w[project issues edges] }
|
||||
let(:end_cursor) { %w[project issues pageInfo endCursor] }
|
||||
let(:query) do
|
||||
<<~GRAPHQL
|
||||
query project($fullPath: ID!, $first: Int, $after: String) {
|
||||
|
@ -217,16 +228,10 @@ RSpec.describe 'GraphQL' do
|
|||
GRAPHQL
|
||||
end
|
||||
|
||||
# TODO: Switch this to use `post_graphql`
|
||||
# This is not performing an actual GraphQL request because the
|
||||
# variables end up being strings when passed through the `post_graphql`
|
||||
# helper.
|
||||
#
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/222432
|
||||
def execute_query(after: nil)
|
||||
GitlabSchema.execute(
|
||||
post_graphql(
|
||||
query,
|
||||
context: { current_user: nil },
|
||||
current_user: nil,
|
||||
variables: {
|
||||
fullPath: project.full_path,
|
||||
first: page_size,
|
||||
|
@ -240,14 +245,16 @@ RSpec.describe 'GraphQL' do
|
|||
expect(Gitlab::Graphql::Pagination::Keyset::QueryBuilder)
|
||||
.to receive(:new).with(anything, anything, hash_including('created_at'), anything).and_call_original
|
||||
|
||||
first_page = execute_query
|
||||
execute_query
|
||||
first_page = graphql_data
|
||||
edges = first_page.dig(*issues_edges)
|
||||
cursor = first_page.dig(*end_cursor)
|
||||
|
||||
expect(edges.count).to eq(6)
|
||||
expect(edges.last['node']['iid']).to eq(issues[4].iid.to_s)
|
||||
|
||||
second_page = execute_query(after: cursor)
|
||||
execute_query(after: cursor)
|
||||
second_page = graphql_data
|
||||
edges = second_page.dig(*issues_edges)
|
||||
|
||||
expect(edges.count).to eq(4)
|
||||
|
|
Loading…
Reference in New Issue