Skip to content
Snippets Groups Projects
Commit 1308dc5e authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent f0707f41
No related branches found
No related tags found
No related merge requests found
Showing
with 896 additions and 116 deletions
doc/install/aws/img/aws_ha_architecture_diagram.png

131 KiB

Loading
Loading
@@ -38,7 +38,7 @@ In addition to having a basic familiarity with [AWS](https://docs.aws.amazon.com
 
Below is a diagram of the recommended architecture.
 
![AWS architecture diagram](img/aws_diagram.png)
![AWS architecture diagram](img/aws_ha_architecture_diagram.png)
 
## AWS costs
 
Loading
Loading
@@ -519,11 +519,34 @@ read the [repository storage paths docs](../../administration/repository_storage
 
### Setting up Gitaly
 
Gitaly is a service that provides high-level RPC access to Git repositories.
It should be enabled and configured in a separate EC2 instance on the
[private VPC](#subnets) we configured previously.
CAUTION: **Caution:** In this architecture, having a single Gitaly server creates a single point of failure. This limitation will be removed once [Gitaly HA](https://gitlab.com/groups/gitlab-org/-/epics/842) is released.
 
Follow the [documentation to set up Gitaly](../../administration/gitaly/index.md).
Gitaly is a service that provides high-level RPC access to Git repositories.
It should be enabled and configured on a separate EC2 instance in one of the
[private subnets](#subnets) we configured previously.
Let's create an EC2 instance where we'll install Gitaly:
1. From the EC2 dashboard, click **Launch instance**.
1. Choose an AMI. In this example, we'll select the **Ubuntu Server 18.04 LTS (HVM), SSD Volume Type**.
1. Choose an instance type. We'll pick a **c5.xlarge**.
1. Click **Configure Instance Details**.
1. In the **Network** dropdown, select `gitlab-vpc`, the VPC we created earlier.
1. In the **Subnet** dropdown, select `gitlab-private-10.0.1.0` from the list of subnets we created earlier.
1. Double check that **Auto-assign Public IP** is set to `Use subnet setting (Disable)`.
1. Click **Add Storage**.
1. Increase the Root volume size to `20 GiB` and change the **Volume Type** to `Provisoned IOPS SSD (io1)`. (This is an arbitrary size. Create a volume big enough for your repository storage requirements.)
1. For **IOPS** set `1000` (20 GiB x 50 IOPS). You can provision up to 50 IOPS per GiB. If you select a larger volume, increase the IOPS accordingly. Workloads where many small files are written in a serialized manner, like `git`, requires performant storage, hence the choice of `Provisoned IOPS SSD (io1)`.
1. Click on **Add Tags** and add your tags. In our case, we'll only set `Key: Name` and `Value: Gitaly`.
1. Click on **Configure Security Group** and let's **Create a new security group**.
1. Give your security group a name and description. We'll use `gitlab-gitaly-sec-group` for both.
1. Create a **Custom TCP** rule and add port `8075` to the **Port Range**. For the **Source**, select the `gitlab-loadbalancer-sec-group`.
1. Click **Review and launch** followed by **Launch** if you're happy with your settings.
1. Finally, acknowledge that you have access to the selected private key file or create a new one. Click **Launch Instances**.
> **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above.
Now that we have our EC2 instance ready, follow the [documentation to install GitLab and set up Gitaly on its own server](../../administration/gitaly/index.md#running-gitaly-on-its-own-server).
 
### Using Amazon S3 object storage
 
Loading
Loading
Loading
Loading
@@ -50,6 +50,8 @@ When you're ready, click the **Create page** and the new page will be created.
 
![New page](img/wiki_create_new_page.png)
 
### Attachment storage
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/33475) in GitLab 11.3.
 
Starting with GitLab 11.3, any file that is uploaded to the wiki via GitLab's
Loading
Loading
@@ -58,6 +60,22 @@ if you clone the wiki repository locally. All uploaded files prior to GitLab
11.3 are stored in GitLab itself. If you want them to be part of the wiki's Git
repository, you will have to upload them again.
 
### Length restrictions for file and directory names
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/24364) in GitLab 12.8.
Many common file systems have a [limit of 255 bytes for file and directory names](https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits), and while Git and GitLab both support paths exceeding those limits, the presence of them makes it impossible for users on those file systems to checkout a wiki repository locally.
To avoid this situation, these limits are enforced when editing pages through the GitLab web interface and API:
- 245 bytes for page titles (reserving 10 bytes for the file extension).
- 255 bytes for directory names.
Please note that:
- Non-ASCII characters take up more than one byte.
- It's still possible to create files and directories exceeding those limits locally through Git, but this might break on other people's machines.
## Editing a wiki page
 
NOTE: **Note:**
Loading
Loading
Loading
Loading
@@ -59,7 +59,7 @@ module API
requires :token, type: String, desc: 'Token to authenticate against Kubernetes'
optional :ca_cert, type: String, desc: 'TLS certificate (needed if API is using a self-signed TLS certificate)'
optional :namespace, type: String, desc: 'Unique namespace related to Group'
optional :authorization_type, type: String, values: Clusters::Platforms::Kubernetes.authorization_types.keys, default: 'rbac', desc: 'Cluster authorization type, defaults to RBAC'
optional :authorization_type, type: String, values: ::Clusters::Platforms::Kubernetes.authorization_types.keys, default: 'rbac', desc: 'Cluster authorization type, defaults to RBAC'
end
use :create_params_ee
end
Loading
Loading
@@ -96,7 +96,7 @@ module API
put ':id/clusters/:cluster_id' do
authorize! :update_cluster, cluster
 
update_service = Clusters::UpdateService.new(current_user, update_cluster_params)
update_service = ::Clusters::UpdateService.new(current_user, update_cluster_params)
 
if update_service.execute(cluster)
present cluster, with: Entities::ClusterGroup
Loading
Loading
Loading
Loading
@@ -62,7 +62,7 @@ module API
requires :token, type: String, desc: 'Token to authenticate against Kubernetes'
optional :ca_cert, type: String, desc: 'TLS certificate (needed if API is using a self-signed TLS certificate)'
optional :namespace, type: String, desc: 'Unique namespace related to Project'
optional :authorization_type, type: String, values: Clusters::Platforms::Kubernetes.authorization_types.keys, default: 'rbac', desc: 'Cluster authorization type, defaults to RBAC'
optional :authorization_type, type: String, values: ::Clusters::Platforms::Kubernetes.authorization_types.keys, default: 'rbac', desc: 'Cluster authorization type, defaults to RBAC'
end
use :create_params_ee
end
Loading
Loading
@@ -100,7 +100,7 @@ module API
put ':id/clusters/:cluster_id' do
authorize! :update_cluster, cluster
 
update_service = Clusters::UpdateService.new(current_user, update_cluster_params)
update_service = ::Clusters::UpdateService.new(current_user, update_cluster_params)
 
if update_service.execute(cluster)
present cluster, with: Entities::ClusterProject
Loading
Loading
Loading
Loading
@@ -10,14 +10,14 @@ module Gitlab
end
 
def match(content)
content.match %r{^/#{all_names.join('|')} ?(.*)$}
content.match %r{^/#{all_names.join('|')}(?![\S]) ?(.*)$}
end
 
def perform_substitution(context, content)
return unless content
 
all_names.each do |a_name|
content = content.gsub(%r{/#{a_name} ?(.*)$}i, execute_block(action_block, context, '\1'))
content = content.gsub(%r{/#{a_name}(?![\S]) ?(.*)$}i, execute_block(action_block, context, '\1'))
end
 
content
Loading
Loading
Loading
Loading
@@ -472,9 +472,6 @@ msgstr ""
msgid "%{total} open issues"
msgstr ""
 
msgid "%{unstaged} unstaged and %{staged} staged changes"
msgstr ""
msgid "%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc."
msgstr ""
 
Loading
Loading
@@ -733,9 +730,6 @@ msgstr ""
msgid "<no scopes selected>"
msgstr ""
 
msgid "<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes"
msgstr ""
msgid "<strong>%{group_name}</strong> group members"
msgstr ""
 
Loading
Loading
@@ -1676,9 +1670,6 @@ msgstr ""
msgid "An error occurred fetching the dropdown data."
msgstr ""
 
msgid "An error occurred loading code navigation"
msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
 
Loading
Loading
@@ -5071,6 +5062,9 @@ msgstr ""
msgid "Container Scanning"
msgstr ""
 
msgid "Container does not exist"
msgstr ""
msgid "Container registry images"
msgstr ""
 
Loading
Loading
@@ -5080,6 +5074,9 @@ msgstr ""
msgid "Container repositories sync capacity"
msgstr ""
 
msgid "ContainerRegistry|%{imageName} tags"
msgstr ""
msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept."
msgstr ""
 
Loading
Loading
@@ -5131,6 +5128,9 @@ msgstr ""
msgid "ContainerRegistry|Last Updated"
msgstr ""
 
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
msgid "ContainerRegistry|Number of tags to retain:"
msgstr ""
 
Loading
Loading
@@ -5196,12 +5196,21 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
 
msgid "ContainerRegistry|You are about to remove %{item} tags. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove %{item}. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?"
msgstr ""
 
msgid "ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?"
msgstr ""
 
msgid "ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted."
msgstr ""
msgid "ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted."
msgstr ""
 
Loading
Loading
@@ -12674,6 +12683,9 @@ msgstr ""
msgid "No connection could be made to a Gitaly Server, please check your logs!"
msgstr ""
 
msgid "No containers available"
msgstr ""
msgid "No contributions"
msgstr ""
 
Loading
Loading
@@ -14020,6 +14032,9 @@ msgstr ""
msgid "Please wait while we import the repository for you. Refresh at will."
msgstr ""
 
msgid "Pod does not exist"
msgstr ""
msgid "Pod logs"
msgstr ""
 
Loading
Loading
@@ -20425,6 +20440,9 @@ msgstr ""
msgid "Unable to collect memory info"
msgstr ""
 
msgid "Unable to connect to Elasticsearch"
msgstr ""
msgid "Unable to connect to Prometheus server"
msgstr ""
 
Loading
Loading
@@ -20494,6 +20512,9 @@ msgstr ""
msgid "Unknown Error"
msgstr ""
 
msgid "Unknown cache key"
msgstr ""
msgid "Unknown encryption strategy: %{encrypted_strategy}!"
msgstr ""
 
Loading
Loading
@@ -22766,6 +22787,12 @@ msgstr ""
msgid "estimateCommand|%{slash_command} will update the estimated time with the latest command."
msgstr ""
 
msgid "exceeds the limit of %{bytes} bytes for directory names"
msgstr ""
msgid "exceeds the limit of %{bytes} bytes for page titles"
msgstr ""
msgid "expired on %{milestone_due_date}"
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -2,35 +2,34 @@
 
module QA
context 'Plan' do
describe 'Close issue' do
include Support::Api
describe 'Issue' do
let(:issue) do
Resource::Issue.fabricate_via_api!
end
 
let(:issue_id) { issue.api_response[:iid] }
 
before do
Flow::Login.sign_in
let(:api_client) { Runtime::API::Client.new(:gitlab) }
 
before do
# Initial commit should be pushed because
# the very first commit to the project doesn't close the issue
# https://gitlab.com/gitlab-org/gitlab-foss/issues/38965
push_commit('Initial commit')
end
 
it 'closes an issue by pushing a commit' do
it 'closes via pushing a commit' do
push_commit("Closes ##{issue_id}", false)
 
issue.visit!
Page::Project::Issue::Show.perform do |show|
reopen_issue_button_visible = show.wait_until(reload: true) do
show.has_element?(:reopen_issue_button, wait: 1.0)
end
expect(reopen_issue_button_visible).to be_truthy
Support::Retrier.retry_until(max_duration: 10, sleep_interval: 1) do
issue_closed?
end
end
 
private
def push_commit(commit_message, new_branch = true)
Resource::Repository::ProjectPush.fabricate! do |push|
push.commit_message = commit_message
Loading
Loading
@@ -39,6 +38,11 @@ module QA
push.project = issue.project
end
end
def issue_closed?
response = get Runtime::API::Request.new(api_client, "/projects/#{issue.project.id}/issues/#{issue_id}").url
parse_body(response)[:state] == 'closed'
end
end
end
end
# frozen_string_literal: true
 
module QA
context 'Plan', :smoke, :reliable do
context 'Plan', :smoke do
describe 'Issue creation' do
before do
Flow::Login.sign_in
end
 
it 'creates an issue' do
it 'creates an issue', :reliable do
issue = Resource::Issue.fabricate_via_browser_ui!
 
Page::Project::Menu.perform(&:click_issues)
Loading
Loading
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import actions from '~/code_navigation/store/actions';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { setCurrentHoverElement, addInteractionClass } from '~/code_navigation/utils';
 
jest.mock('~/flash');
jest.mock('~/code_navigation/utils');
 
describe('Code navigation actions', () => {
Loading
Loading
@@ -25,13 +23,6 @@ describe('Code navigation actions', () => {
describe('requestDataError', () => {
it('commits REQUEST_DATA_ERROR', () =>
testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []));
it('creates a flash message', () =>
testAction(actions.requestDataError, null, {}, [{ type: 'REQUEST_DATA_ERROR' }], []).then(
() => {
expect(createFlash).toHaveBeenCalled();
},
));
});
 
describe('fetchData', () => {
Loading
Loading
Loading
Loading
@@ -534,27 +534,21 @@ describe('IDE store file actions', () => {
.catch(done.fail);
});
 
it('adds a newline to the end of the file if it doesnt already exist', done => {
callAction('content')
.then(() => {
expect(tmpFile.content).toBe('content\n');
done();
it('adds file into stagedFiles array', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.catch(done.fail);
});
it('adds file into changedFiles array', done => {
callAction()
.then(() => {
expect(store.state.changedFiles.length).toBe(1);
expect(store.state.stagedFiles.length).toBe(1);
 
done();
})
.catch(done.fail);
});
 
it('adds file not more than once into changedFiles array', done => {
it('adds file not more than once into stagedFiles array', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
Loading
Loading
@@ -567,7 +561,7 @@ describe('IDE store file actions', () => {
}),
)
.then(() => {
expect(store.state.changedFiles.length).toBe(1);
expect(store.state.stagedFiles.length).toBe(1);
 
done();
})
Loading
Loading
@@ -594,52 +588,6 @@ describe('IDE store file actions', () => {
.catch(done.fail);
});
 
describe('when `gon.feature.stageAllByDefault` is true', () => {
const originalGonFeatures = Object.assign({}, gon.features);
beforeAll(() => {
gon.features = { stageAllByDefault: true };
});
afterAll(() => {
gon.features = originalGonFeatures;
});
it('adds file into stagedFiles array', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() => {
expect(store.state.stagedFiles.length).toBe(1);
done();
})
.catch(done.fail);
});
it('adds file not more than once into stagedFiles array', done => {
store
.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content',
})
.then(() =>
store.dispatch('changeFileContent', {
path: tmpFile.path,
content: 'content 123',
}),
)
.then(() => {
expect(store.state.stagedFiles.length).toBe(1);
done();
})
.catch(done.fail);
});
});
it('bursts unused seal', done => {
store
.dispatch('changeFileContent', {
Loading
Loading
Loading
Loading
@@ -61,19 +61,14 @@ describe('IDE store integration', () => {
store.dispatch('createTempEntry', { name: TEST_PATH, type: 'blob' });
});
 
it('has changed and staged', () => {
expect(store.state.changedFiles).toEqual([
expect.objectContaining({
path: TEST_PATH,
tempFile: true,
deleted: false,
}),
]);
it('is added to staged as modified', () => {
expect(store.state.stagedFiles).toEqual([
expect.objectContaining({
path: TEST_PATH,
deleted: true,
deleted: false,
staged: true,
changed: true,
tempFile: false,
}),
]);
});
Loading
Loading
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Group Empty state to match the default snapshot 1`] = `
<div
class="container-message"
svg-path="foo"
title="There are no container images available in this group"
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.
<gl-link-stub
href="baz"
target="_blank"
>
More Information
</gl-link-stub>
</p>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Project Empty state to match the default snapshot 1`] = `
<div
class="container-message"
svg-path="bazFoo"
title="There are no container images stored for this project"
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images.
<gl-link-stub
href="baz"
target="_blank"
>
More Information
</gl-link-stub>
</p>
<h5>
Quick Start
</h5>
<p
class="js-not-logged-in-to-registry-text"
>
If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have
<gl-link-stub
href="barBaz"
target="_blank"
>
Two-Factor Authentication
</gl-link-stub>
enabled, use a
<gl-link-stub
href="fooBaz"
target="_blank"
>
Personal Access Token
</gl-link-stub>
instead of a password.
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<clipboard-button-stub
class="input-group-text"
cssclass="btn-default"
text="docker login bar"
title="Copy login command"
tooltipplacement="top"
/>
</span>
</div>
<p />
<p>
You can add an image to this registry with the following commands:
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<clipboard-button-stub
class="input-group-text"
cssclass="btn-default"
text="docker build -t foo ."
title="Copy build command"
tooltipplacement="top"
/>
</span>
</div>
<div
class="input-group"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<clipboard-button-stub
class="input-group-text"
cssclass="btn-default"
text="docker push foo"
title="Copy push command"
tooltipplacement="top"
/>
</span>
</div>
</div>
`;
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { GlEmptyState } from '../stubs';
import groupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Registry Group Empty state', () => {
let wrapper;
let store;
beforeEach(() => {
store = new Vuex.Store({
state: {
config: {
noContainersImage: 'foo',
helpPagePath: 'baz',
},
},
});
wrapper = shallowMount(groupEmptyState, {
localVue,
store,
stubs: {
GlEmptyState,
GlSprintf,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { GlEmptyState } from '../stubs';
import projectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Registry Project Empty state', () => {
let wrapper;
let store;
beforeEach(() => {
store = new Vuex.Store({
state: {
config: {
repositoryUrl: 'foo',
registryHostUrlWithPort: 'bar',
helpPagePath: 'baz',
twoFactorAuthHelpLink: 'barBaz',
personalAccessTokensHelpLink: 'fooBaz',
noContainersImage: 'bazFoo',
},
},
});
wrapper = shallowMount(projectEmptyState, {
localVue,
store,
stubs: {
GlEmptyState,
GlSprintf,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
export const headers = {
'X-PER-PAGE': 5,
'X-PAGE': 1,
'X-TOTAL': 13,
'X-TOTAL_PAGES': 1,
'X-NEXT-PAGE': null,
'X-PREVIOUS-PAGE': null,
};
export const reposServerResponse = [
{
destroy_path: 'path',
Loading
Loading
@@ -36,3 +44,46 @@ export const registryServerResponse = [
created_at: 1505828744434,
},
];
export const imagesListResponse = {
data: [
{
path: 'foo',
location: 'location',
destroy_path: 'path',
},
{
path: 'bar',
location: 'location-2',
destroy_path: 'path-2',
},
],
headers,
};
export const tagsListResponse = {
data: [
{
tag: 'centos6',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
short_revision: 'b118ab5b0',
size: 19,
layers: 10,
location: 'location',
path: 'bar',
created_at: 1505828744434,
destroy_path: 'path',
},
{
tag: 'test-image',
revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4',
short_revision: 'b969de599',
size: 19,
layers: 10,
path: 'foo',
location: 'location-2',
created_at: 1505828744434,
},
],
headers,
};
import { mount } from '@vue/test-utils';
import { GlTable, GlPagination, GlLoadingIcon } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
import store from '~/registry/explorer/stores/';
import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
import { tagsListResponse } from '../mock_data';
import { GlModal } from '../stubs';
describe('Details Page', () => {
let wrapper;
let dispatchSpy;
const findDeleteModal = () => wrapper.find(GlModal);
const findPagination = () => wrapper.find(GlPagination);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findTagsTable = () => wrapper.find(GlTable);
const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' });
const findFirstRowItem = ref => wrapper.find({ ref });
const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' });
// findAll and refs seems to no work falling back to class
const findAllDeleteButtons = () => wrapper.findAll('.js-delete-registry');
const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
beforeEach(() => {
wrapper = mount(component, {
store,
stubs: {
...stubChildren(component),
GlModal,
GlSprintf: false,
GlTable: false,
},
mocks: {
$route: {
params: {
id: routeId,
},
},
},
});
dispatchSpy = jest.spyOn(store, 'dispatch');
store.dispatch('receiveTagsListSuccess', tagsListResponse);
jest.spyOn(Tracking, 'event');
});
afterEach(() => {
wrapper.destroy();
});
describe('when isLoading is true', () => {
beforeAll(() => store.commit(SET_MAIN_LOADING, true));
afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('has a loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not have a main content', () => {
expect(findTagsTable().exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
expect(findDeleteModal().exists()).toBe(false);
});
});
describe('table', () => {
it.each([
'rowCheckbox',
'rowName',
'rowShortRevision',
'rowSize',
'rowTime',
'singleDeleteButton',
])('%s exist in the table', element => {
expect(findFirstRowItem(element).exists()).toBe(true);
});
describe('header checkbox', () => {
it('exists', () => {
expect(findMainCheckbox().exists()).toBe(true);
});
it('if selected set selectedItem and allSelected', () => {
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
expect(findCheckedCheckboxes()).toHaveLength(store.state.tags.length);
});
});
it('if deselect unset selectedItem and allSelected', () => {
wrapper.setData({ selectedItems: [1, 2], selectAllChecked: true });
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBe(undefined);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
});
describe('row checkbox', () => {
it('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems).toEqual([1]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
});
});
it('if deselect remove index from selectedItems', () => {
wrapper.setData({ selectedItems: [1] });
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems.length).toBe(0);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined);
});
});
});
describe('header delete button', () => {
it('exists', () => {
expect(findBulkDeleteButton().exists()).toBe(true);
});
it('is disabled if no item is selected', () => {
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
});
it('is enabled if at least one item is selected', () => {
wrapper.setData({ selectedItems: [1] });
return wrapper.vm.$nextTick().then(() => {
expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy();
});
});
describe('on click', () => {
it('when one item is selected', () => {
wrapper.setData({ selectedItems: [1] });
findBulkDeleteButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain(
'You are about to remove <b>foo</b>. Are you sure?',
);
expect(GlModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
it('when multiple items are selected', () => {
wrapper.setData({ selectedItems: [0, 1] });
findBulkDeleteButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain(
'You are about to remove <b>2</b> tags. Are you sure?',
);
expect(GlModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
});
});
});
});
});
describe('row delete button', () => {
it('exists', () => {
expect(
findAllDeleteButtons()
.at(0)
.exists(),
).toBe(true);
});
it('is disabled if the item has no destroy_path', () => {
expect(
findAllDeleteButtons()
.at(1)
.attributes('disabled'),
).toBe('true');
});
it('on click', () => {
findAllDeleteButtons()
.at(0)
.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain(
'You are about to remove <b>bar</b>. Are you sure?',
);
expect(GlModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
});
});
describe('pagination', () => {
it('exists', () => {
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(store.state.tagsPagination.perPage);
expect(pagination.props('totalItems')).toBe(store.state.tagsPagination.total);
expect(pagination.props('value')).toBe(store.state.tagsPagination.page);
});
it('fetch the data from the API when the v-model changes', () => {
dispatchSpy.mockResolvedValue();
wrapper.setData({ currentPage: 2 });
expect(store.dispatch).toHaveBeenCalledWith('requestTagsList', {
id: wrapper.vm.$route.params.id,
pagination: { page: 2 },
});
});
});
describe('modal', () => {
it('exists', () => {
expect(findDeleteModal().exists()).toBe(true);
});
describe('when ok event is emitted', () => {
beforeEach(() => {
dispatchSpy.mockResolvedValue();
});
it('tracks confirm_delete', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'confirm_delete', {
label: 'registry_tag_delete',
});
});
});
it('when only one element is selected', () => {
const deleteModal = findDeleteModal();
wrapper.setData({ itemsToBeDeleted: [0] });
deleteModal.vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
imageId: wrapper.vm.$route.params.id,
});
// itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
it('when multiple elements are selected', () => {
const deleteModal = findDeleteModal();
wrapper.setData({ itemsToBeDeleted: [0, 1] });
deleteModal.vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
imageId: wrapper.vm.$route.params.id,
});
// itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
});
it('tracks cancel_delete when cancel event is emitted', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
return wrapper.vm.$nextTick().then(() => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
label: 'registry_tag_delete',
});
});
});
});
});
import VueRouter from 'vue-router';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
import store from '~/registry/explorer/stores/';
import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
import { imagesListResponse } from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
const localVue = createLocalVue();
localVue.use(VueRouter);
describe('List Page', () => {
let wrapper;
let dispatchSpy;
const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findImagesList = () => wrapper.find({ ref: 'imagesList' });
const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
const findEmptyState = () => wrapper.find(GlEmptyState);
const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' });
const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' });
const findPagination = () => wrapper.find(GlPagination);
beforeEach(() => {
wrapper = shallowMount(component, {
localVue,
store,
stubs: {
GlModal,
GlEmptyState,
GlSprintf,
},
});
dispatchSpy = jest.spyOn(store, 'dispatch');
store.dispatch('receiveImagesListSuccess', imagesListResponse);
});
afterEach(() => {
wrapper.destroy();
});
describe('connection error', () => {
const config = {
characterError: true,
containersErrorImage: 'foo',
helpPagePath: 'bar',
};
beforeAll(() => {
store.dispatch('setInitialState', config);
});
afterAll(() => {
store.dispatch('setInitialState', {});
});
it('should show an empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('empty state should have an svg-path', () => {
expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage);
});
it('empty state should have a description', () => {
expect(findEmptyState().html()).toContain('connection error');
});
it('should not show the loading or default state', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findImagesList().exists()).toBe(false);
});
});
describe('when isLoading is true', () => {
beforeAll(() => store.commit(SET_MAIN_LOADING, true));
afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('shows the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('imagesList is not visible', () => {
expect(findImagesList().exists()).toBe(false);
});
});
describe('list', () => {
describe('listElement', () => {
let listElements;
let firstElement;
beforeEach(() => {
listElements = findRowItems();
[firstElement] = store.state.images;
});
it('contains one list element for each image', () => {
expect(listElements.length).toBe(store.state.images.length);
});
it('contains a link to the details page', () => {
const link = findDetailsLink();
expect(link.html()).toContain(firstElement.path);
expect(link.props('to').name).toBe('details');
});
it('contains a clipboard button', () => {
const button = findClipboardButton();
expect(button.exists()).toBe(true);
expect(button.props('text')).toBe(firstElement.location);
expect(button.props('title')).toBe(firstElement.location);
});
describe('delete image', () => {
it('should be possible to delete a repo', () => {
const deleteBtn = findDeleteBtn();
expect(deleteBtn.exists()).toBe(true);
});
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
const itemToDelete = wrapper.vm.images[0];
wrapper.setData({ itemToDelete });
findDeleteModal().vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
itemToDelete.destroy_path,
);
});
});
});
describe('pagination', () => {
it('exists', () => {
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(store.state.pagination.perPage);
expect(pagination.props('totalItems')).toBe(store.state.pagination.total);
expect(pagination.props('value')).toBe(store.state.pagination.page);
});
it('fetch the data from the API when the v-model changes', () => {
dispatchSpy.mockReturnValue();
wrapper.setData({ currentPage: 2 });
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 });
});
});
});
});
describe('modal', () => {
it('exists', () => {
expect(findDeleteModal().exists()).toBe(true);
});
it('contains a description with the path of the item to delete', () => {
wrapper.setData({ itemToDelete: { path: 'foo' } });
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain('foo');
});
});
});
describe('tracking', () => {
const testTrackingCall = action => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
label: 'registry_repository_delete',
});
};
beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatchSpy.mockReturnValue();
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteBtn();
deleteBtn.vm.$emit('click');
testTrackingCall('click_button');
});
it('send an event when cancel is pressed on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
testTrackingCall('cancel_delete');
});
it('send an event when confirm is clicked on modal', () => {
dispatchSpy.mockReturnValue();
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
testTrackingCall('confirm_delete');
});
});
});
});
Loading
Loading
@@ -120,14 +120,15 @@ describe('Actions RegistryExplorer Store', () => {
});
 
describe('fetch tags list', () => {
const url = window.btoa(`${endpoint}/1}`);
const url = `${endpoint}/1}`;
const path = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}` }));
 
it('sets the tagsList', done => {
mock.onGet(window.atob(url)).replyOnce(200, registryServerResponse, {});
mock.onGet(url).replyOnce(200, registryServerResponse, {});
 
testAction(
actions.requestTagsList,
{ id: url },
{ id: path },
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
Loading
Loading
@@ -146,7 +147,7 @@ describe('Actions RegistryExplorer Store', () => {
it('should create flash on error', done => {
testAction(
actions.requestTagsList,
{ id: url },
{ id: path },
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment