Browse Source

Add latest changes from gitlab-org/gitlab@master

tags/v12.4.0-rc42
GitLab Bot 2 months ago
parent
commit
6f9edd1a4c
34 changed files with 679 additions and 615 deletions
  1. +1
    -1
      .github/ISSUE_TEMPLATE.md
  2. +1
    -1
      .github/PULL_REQUEST_TEMPLATE.md
  3. +11
    -11
      .gitlab/ci/qa.gitlab-ci.yml
  4. +3
    -10
      .gitlab/issue_templates/Bug.md
  5. +1
    -1
      .gitlab/issue_templates/Feature Flag Roll Out.md
  6. +2
    -2
      .gitlab/issue_templates/Problem_Validation.md
  7. +1
    -7
      .gitlab/issue_templates/Security Release.md
  8. +2
    -2
      .gitlab/issue_templates/Test plan.md
  9. +4
    -4
      app/assets/javascripts/registry/components/app.vue
  10. +3
    -2
      app/assets/javascripts/registry/components/collapsible_container.vue
  11. +10
    -8
      app/assets/javascripts/registry/components/table_registry.vue
  12. +8
    -4
      app/finders/clusters/kubernetes_namespace_finder.rb
  13. +1
    -1
      app/models/clusters/cluster.rb
  14. +1
    -1
      app/models/clusters/kubernetes_namespace.rb
  15. +1
    -9
      app/models/clusters/platforms/kubernetes.rb
  16. +0
    -0
      changelogs/unreleased/16790-render-xml-artifacts.yml
  17. +5
    -0
      changelogs/unreleased/sh-fix-any-approver-handling.yml
  18. +1
    -1
      doc/development/pipelines.md
  19. +1
    -1
      lib/gitlab/ci/build/prerequisite/kubernetes_namespace.rb
  20. +3
    -3
      spec/finders/clusters/kubernetes_namespace_finder_spec.rb
  21. +121
    -0
      spec/frontend/registry/components/app_spec.js
  22. +89
    -0
      spec/frontend/registry/components/collapsible_container_spec.js
  23. +211
    -0
      spec/frontend/registry/components/table_registry_spec.js
  24. +0
    -0
      spec/frontend/registry/mock_data.js
  25. +189
    -0
      spec/frontend/registry/stores/actions_spec.js
  26. +0
    -0
      spec/frontend/registry/stores/getters_spec.js
  27. +0
    -0
      spec/frontend/registry/stores/mutations_spec.js
  28. +0
    -129
      spec/javascripts/registry/components/app_spec.js
  29. +0
    -87
      spec/javascripts/registry/components/collapsible_container_spec.js
  30. +0
    -189
      spec/javascripts/registry/components/table_registry_spec.js
  31. +0
    -132
      spec/javascripts/registry/stores/actions_spec.js
  32. +1
    -1
      spec/models/clusters/cluster_spec.rb
  33. +7
    -7
      spec/models/clusters/kubernetes_namespace_spec.rb
  34. +1
    -1
      spec/models/clusters/platforms/kubernetes_spec.rb

+ 1
- 1
.github/ISSUE_TEMPLATE.md View File

@@ -1,3 +1,3 @@
We’re closing our issue tracker on GitHub so we can focus on the GitLab.com project and respond to issues more quickly.

We encourage you to open an issue on the [GitLab.com issue tracker](https://gitlab.com/gitlab-org/gitlab-ce/issues). You can log into GitLab.com using your GitHub account.
We encourage you to open an issue on the [GitLab.com issue tracker](https://gitlab.com/gitlab-org/gitlab/issues). You can log into GitLab.com using your GitHub account.

+ 1
- 1
.github/PULL_REQUEST_TEMPLATE.md View File

@@ -1,3 +1,3 @@
Thank you for taking the time to contribute back to GitLab!

Please open a merge request [on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests), we look forward to reviewing your contribution! You can log into GitLab.com using your GitHub account.
Please open a merge request [on GitLab.com](https://gitlab.com/gitlab-org/gitlab/merge_requests), we look forward to reviewing your contribution! You can log into GitLab.com using your GitHub account.

+ 11
- 11
.gitlab/ci/qa.gitlab-ci.yml View File

@@ -24,17 +24,6 @@ package-and-qa-manual:
when: manual
needs: ["build-qa-image", "gitlab:assets:compile pull-cache"]

package-and-qa-manual:master:
extends:
- .package-and-qa-base
- .only-code-qa-changes
only:
refs:
- master@gitlab-org/gitlab-foss
- master@gitlab-org/gitlab
when: manual
needs: ["build-qa-image", "gitlab:assets:compile"]

package-and-qa:
extends:
- .package-and-qa-base
@@ -44,3 +33,14 @@ package-and-qa:
- master
needs: ["build-qa-image", "gitlab:assets:compile pull-cache"]
allow_failure: true

schedule:package-and-qa:
extends:
- .package-and-qa-base
- .only-code-qa-changes
only:
refs:
- schedules@gitlab-org/gitlab
- schedules@gitlab-org/gitlab-foss
needs: ["build-qa-image", "gitlab:assets:compile"]
allow_failure: true

+ 3
- 10
.gitlab/issue_templates/Bug.md View File

@@ -2,17 +2,10 @@
Please read this!

Before opening a new issue, make sure to search for keywords in the issues
filtered by the "regression" or "bug" label.
filtered by the "regression" or "bug" label:

For the Community Edition issue tracker:

- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=regression
- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=bug

For the Enterprise Edition issue tracker:

- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=regression
- https://gitlab.com/gitlab-org/gitlab-ee/issues?label_name%5B%5D=bug
- https://gitlab.com/gitlab-org/gitlab/issues?label_name%5B%5D=regression
- https://gitlab.com/gitlab-org/gitlab/issues?label_name%5B%5D=bug

and verify the issue you're about to submit isn't a duplicate.
--->

+ 1
- 1
.gitlab/issue_templates/Feature Flag Roll Out.md View File

@@ -24,7 +24,7 @@ Remove the `:feature_name` feature flag ...

If applicable, any groups/projects that are happy to have this feature turned on early. Some organizations may wish to test big changes they are interested in with a small subset of users ahead of time for example.

- `gitlab-org/gitlab-ce`/`gitlab-org/gitlab-ee` projects
- `gitlab-org/gitlab` project
- `gitlab-org`/`gitlab-com` groups
- ...


+ 2
- 2
.gitlab/issue_templates/Problem_Validation.md View File

@@ -26,7 +26,7 @@

## Confidence

<!-- How do we know this is a problem? Please provide and link to any supporting information (e.g. data, customer verbatims) and use this basis to provide a numerical assessment on our confidence level in this problem's severity:
<!-- How do we know this is a problem? Please provide and link to any supporting information (e.g. data, customer verbatims) and use this basis to provide a numerical assessment on our confidence level in this problem's severity:

100% = High confidence
80% = Medium confidence
@@ -34,7 +34,7 @@

## Effort

<!-- How much effort do we think it will be to solve this problem? Please include all counterparts (Product, UX, Engineering, etc) in your assessment and quantify the number of person-months needed to dedicate to the effort.
<!-- How much effort do we think it will be to solve this problem? Please include all counterparts (Product, UX, Engineering, etc) in your assessment and quantify the number of person-months needed to dedicate to the effort.

For example, if the solution will take a product manager, designer, and engineer two weeks of effort - you may quantify this as 1.5 (based on 0.5 months x 3 people). -->


+ 1
- 7
.gitlab/issue_templates/Security Release.md View File

@@ -18,13 +18,7 @@ Set the title to: `Security Release: 12.2.X, 12.1.X, and 12.0.X`

## Security Issues:

### CE

* {https://gitlab.com/gitlab-org/gitlab-ce/issues link}

### EE

* {https://gitlab.com/gitlab-org/gitlab-ee/issues link}
* {https://gitlab.com/gitlab-org/gitlab/issues link}

## Security Issues in dev.gitlab.org:


+ 2
- 2
.gitlab/issue_templates/Test plan.md View File

@@ -2,7 +2,7 @@

<!-- This issue outlines testing activities related to a particular issue or epic.

[Here is an example test plan](https://gitlab.com/gitlab-org/gitlab-ce/issues/50353)
[Here is an example test plan](https://gitlab.com/gitlab-org/gitlab-foss/issues/50353)

This and other comments should be removed as you write the plan -->

@@ -63,7 +63,7 @@ intersection of Components and Attributes.
Some features might be simple enough that they only involve one Component, while
more complex features could involve multiple or even all.

Example (from https://gitlab.com/gitlab-org/gitlab-ce/issues/50353):
Example (from https://gitlab.com/gitlab-org/gitlab-foss/issues/50353):
* Repository is
* Intuitive
* It's easy to select the desired file template

+ 4
- 4
app/assets/javascripts/registry/components/app.vue View File

@@ -47,7 +47,7 @@ export default {
dockerConnectionErrorText() {
return sprintf(
s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
issue with your project name or path.
issue with your project name or path.
%{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`,
@@ -58,8 +58,8 @@ export default {
},
introText() {
return sprintf(
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
project can have its own space to store its Docker images.
s__(`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}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
@@ -109,7 +109,7 @@ export default {
:svg-path="containersErrorImage"
>
<template #description>
<p v-html="dockerConnectionErrorText"></p>
<p class="js-character-error-text" v-html="dockerConnectionErrorText"></p>
</template>
</gl-empty-state>


+ 3
- 2
app/assets/javascripts/registry/components/collapsible_container.vue View File

@@ -49,7 +49,7 @@ export default {
}
},
handleDeleteRepository() {
this.deleteItem(this.repo)
return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
@@ -67,7 +67,8 @@ export default {
<div class="container-image">
<div class="container-image-head">
<gl-button class="js-toggle-repo btn-link align-baseline" @click="toggleRepo">
<icon :name="iconName" /> {{ repo.name }}
<icon :name="iconName" />
{{ repo.name }}
</gl-button>

<clipboard-button

+ 10
- 8
app/assets/javascripts/registry/components/table_registry.vue View File

@@ -198,8 +198,9 @@ export default {
:title="s__('ContainerRegistry|Remove selected images')"
:aria-label="s__('ContainerRegistry|Remove selected images')"
@click="deleteMultipleItems()"
><icon name="remove"
/></gl-button>
>
<icon name="remove" />
</gl-button>
</th>
</tr>
</thead>
@@ -223,9 +224,9 @@ export default {
/>
</td>
<td>
<span v-gl-tooltip.bottom class="monospace" :title="item.revision">
{{ item.shortRevision }}
</span>
<span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{
item.shortRevision
}}</span>
</td>
<td>
{{ formatSize(item.size) }}
@@ -236,9 +237,9 @@ export default {
</td>

<td>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">
{{ timeFormated(item.createdAt) }}
</span>
<span v-gl-tooltip.bottom :title="tooltipTitle(item.createdAt)">{{
timeFormated(item.createdAt)
}}</span>
</td>

<td class="content action-buttons">
@@ -262,6 +263,7 @@ export default {
v-if="shouldRenderPagination"
:change="onPageChange"
:page-info="repo.pagination"
class="js-registry-pagination"
/>

<gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">

+ 8
- 4
app/finders/clusters/kubernetes_namespace_finder.rb View File

@@ -2,12 +2,12 @@

module Clusters
class KubernetesNamespaceFinder
attr_reader :cluster, :project, :environment_slug
attr_reader :cluster, :project, :environment_name

def initialize(cluster, project:, environment_slug:, allow_blank_token: false)
def initialize(cluster, project:, environment_name:, allow_blank_token: false)
@cluster = cluster
@project = project
@environment_slug = environment_slug
@environment_name = environment_name
@allow_blank_token = allow_blank_token
end

@@ -20,7 +20,11 @@ module Clusters
attr_reader :allow_blank_token

def find_namespace(with_environment:)
relation = with_environment ? namespaces.with_environment_slug(environment_slug) : namespaces
relation = if with_environment
namespaces.with_environment_name(environment_name)
else
namespaces
end

relation.find_by_project_id(project.id)
end

+ 1
- 1
app/models/clusters/cluster.rb View File

@@ -172,7 +172,7 @@ module Clusters
persisted_namespace = Clusters::KubernetesNamespaceFinder.new(
self,
project: project,
environment_slug: environment.slug
environment_name: environment.name
).execute

persisted_namespace&.namespace || Gitlab::Kubernetes::DefaultNamespace.new(self, project: project).from_environment_slug(environment.slug)

+ 1
- 1
app/models/clusters/kubernetes_namespace.rb View File

@@ -27,7 +27,7 @@ module Clusters
algorithm: 'aes-256-cbc'

scope :has_service_account_token, -> { where.not(encrypted_service_account_token: nil) }
scope :with_environment_slug, -> (slug) { joins(:environment).where(environments: { slug: slug }) }
scope :with_environment_name, -> (name) { joins(:environment).where(environments: { name: name }) }

def token_name
"#{namespace}-token"

+ 1
- 9
app/models/clusters/platforms/kubernetes.rb View File

@@ -105,19 +105,11 @@ module Clusters

private

##
# Environment slug can be predicted given an environment
# name, so even if the environment isn't persisted yet we
# still know what to look for.
def environment_slug(name)
Gitlab::Slug::Environment.new(name).generate
end

def find_persisted_namespace(project, environment_name:)
Clusters::KubernetesNamespaceFinder.new(
cluster,
project: project,
environment_slug: environment_slug(environment_name)
environment_name: environment_name
).execute
end


16790-render-xml-artifacts.yml → changelogs/unreleased/16790-render-xml-artifacts.yml View File


+ 5
- 0
changelogs/unreleased/sh-fix-any-approver-handling.yml View File

@@ -0,0 +1,5 @@
---
title: Fix bug that caused a merge to show an error message
merge_request: 17466
author:
type: fixed

+ 1
- 1
doc/development/pipelines.md View File

@@ -134,7 +134,7 @@ graph RL;
M[coverage];
N[pages];
O[static-analysis];
P["package-and-qa-manual:master<br/>(master schedule only)"];
P["schedule:package-and-qa<br/>(master schedule only)"];
Q[package-and-qa];
R[package-and-qa-manual];


+ 1
- 1
lib/gitlab/ci/build/prerequisite/kubernetes_namespace.rb View File

@@ -36,7 +36,7 @@ module Gitlab
Clusters::KubernetesNamespaceFinder.new(
deployment_cluster,
project: environment.project,
environment_slug: environment.slug,
environment_name: environment.name,
allow_blank_token: true
).execute
end

+ 3
- 3
spec/finders/clusters/kubernetes_namespace_finder_spec.rb View File

@@ -7,7 +7,7 @@ RSpec.describe Clusters::KubernetesNamespaceFinder do
described_class.new(
cluster,
project: project,
environment_slug: 'production',
environment_name: 'production',
allow_blank_token: allow_blank_token
)
end
@@ -22,8 +22,8 @@ RSpec.describe Clusters::KubernetesNamespaceFinder do
end

describe '#execute' do
let(:production) { create(:environment, project: project, slug: 'production') }
let(:staging) { create(:environment, project: project, slug: 'staging') }
let(:production) { create(:environment, project: project, name: 'production') }
let(:staging) { create(:environment, project: project, name: 'staging') }

let(:cluster) { create(:cluster, :group, :provided_by_user) }
let(:project) { create(:project) }

+ 121
- 0
spec/frontend/registry/components/app_spec.js View File

@@ -0,0 +1,121 @@
import registry from '~/registry/components/app.vue';
import { mount } from '@vue/test-utils';
import { TEST_HOST } from '../../helpers/test_constants';
import { reposServerResponse, parsedReposServerResponse } from '../mock_data';

describe('Registry List', () => {
let wrapper;

const findCollapsibleContainer = w => w.findAll({ name: 'CollapsibeContainerRegisty' });
const findNoContainerImagesText = w => w.find('.js-no-container-images-text');
const findSpinner = w => w.find('.gl-spinner');
const findCharacterErrorText = w => w.find('.js-character-error-text');

const propsData = {
endpoint: `${TEST_HOST}/foo`,
helpPagePath: 'foo',
noContainersImage: 'foo',
containersErrorImage: 'foo',
repositoryUrl: 'foo',
};

const setMainEndpoint = jest.fn();
const fetchRepos = jest.fn();

const methods = {
setMainEndpoint,
fetchRepos,
};

beforeEach(() => {
wrapper = mount(registry, {
propsData,
computed: {
repos() {
return parsedReposServerResponse;
},
},
methods,
});
});

describe('with data', () => {
it('should render a list of CollapsibeContainerRegisty', () => {
const containers = findCollapsibleContainer(wrapper);
expect(wrapper.vm.repos.length).toEqual(reposServerResponse.length);
expect(containers.length).toEqual(reposServerResponse.length);
});
});

describe('without data', () => {
let localWrapper;
beforeEach(() => {
localWrapper = mount(registry, {
propsData,
computed: {
repos() {
return [];
},
},
methods,
});
});

it('should render empty message', () => {
const noContainerImagesText = findNoContainerImagesText(localWrapper);
expect(noContainerImagesText.text()).toEqual(
'With the Container Registry, every project can have its own space to store its Docker images. More Information',
);
});
});

describe('while loading data', () => {
let localWrapper;

beforeEach(() => {
localWrapper = mount(registry, {
propsData,
computed: {
repos() {
return [];
},
isLoading() {
return true;
},
},
methods,
});
});

it('should render a loading spinner', () => {
const spinner = findSpinner(localWrapper);
expect(spinner.exists()).toBe(true);
});
});

describe('invalid characters in path', () => {
let localWrapper;

beforeEach(() => {
localWrapper = mount(registry, {
propsData: {
...propsData,
characterError: true,
},
computed: {
repos() {
return [];
},
},
methods,
});
});

it('should render invalid characters error message', () => {
const characterErrorText = findCharacterErrorText(localWrapper);
expect(characterErrorText.text()).toEqual(
'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More Information',
);
});
});
});

+ 89
- 0
spec/frontend/registry/components/collapsible_container_spec.js View File

@@ -0,0 +1,89 @@
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import { repoPropsData } from '../mock_data';
import createFlash from '~/flash';

jest.mock('~/flash.js');

describe('collapsible registry container', () => {
let wrapper;

const findDeleteBtn = w => w.find('.js-remove-repo');
const findContainerImageTags = w => w.find('.container-image-tags');
const findToggleRepos = w => w.findAll('.js-toggle-repo');

beforeEach(() => {
createFlash.mockClear();
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(collapsibleComponent, {
propsData: {
repo: repoPropsData,
},
});
});

afterEach(() => {
Vue.config.silent = false;
});

describe('toggle', () => {
beforeEach(() => {
const fetchList = jest.fn();
wrapper.setMethods({ fetchList });
});

const expectIsClosed = () => {
const container = findContainerImageTags(wrapper);
expect(container.exists()).toBe(false);
expect(wrapper.vm.iconName).toEqual('angle-right');
};

it('should be closed by default', () => {
expectIsClosed();
});
it('should be open when user clicks on closed repo', () => {
const toggleRepos = findToggleRepos(wrapper);
toggleRepos.at(0).trigger('click');
const container = findContainerImageTags(wrapper);
expect(container.exists()).toBe(true);
expect(wrapper.vm.fetchList).toHaveBeenCalled();
});
it('should be closed when the user clicks on an opened repo', done => {
const toggleRepos = findToggleRepos(wrapper);
toggleRepos.at(0).trigger('click');
Vue.nextTick(() => {
toggleRepos.at(0).trigger('click');
Vue.nextTick(() => {
expectIsClosed();
done();
});
});
});
});

describe('delete repo', () => {
it('should be possible to delete a repo', () => {
const deleteBtn = findDeleteBtn(wrapper);
expect(deleteBtn.exists()).toBe(true);
});

it('should call deleteItem when confirming deletion', () => {
const deleteItem = jest.fn().mockResolvedValue();
const fetchRepos = jest.fn().mockResolvedValue();
wrapper.setMethods({ deleteItem, fetchRepos });
wrapper.vm.handleDeleteRepository();
expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(wrapper.vm.repo);
});

it('should show an error when there is API error', () => {
const deleteItem = jest.fn().mockRejectedValue('error');
wrapper.setMethods({ deleteItem });
return wrapper.vm.handleDeleteRepository().then(() => {
expect(createFlash).toHaveBeenCalled();
});
});
});
});

+ 211
- 0
spec/frontend/registry/components/table_registry_spec.js View File

@@ -0,0 +1,211 @@
import Vue from 'vue';
import tableRegistry from '~/registry/components/table_registry.vue';
import { mount } from '@vue/test-utils';
import { repoPropsData } from '../mock_data';

const [firstImage, secondImage] = repoPropsData.list;

describe('table registry', () => {
let wrapper;

const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
const findDeleteButton = w => w.find('.js-delete-registry');
const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row');
const findPagination = w => w.find('.js-registry-pagination');
const bulkDeletePath = 'path';

beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(tableRegistry, {
propsData: {
repo: repoPropsData,
},
});
});

afterEach(() => {
Vue.config.silent = false;
});

describe('rendering', () => {
it('should render a table with the registry list', () => {
expect(wrapper.findAll('.registry-image-row').length).toEqual(repoPropsData.list.length);
});

it('should render registry tag', () => {
const tds = wrapper.findAll('.registry-image-row td');
expect(tds.at(0).classes()).toContain('check');
expect(tds.at(1).html()).toContain(repoPropsData.list[0].tag);
expect(tds.at(2).html()).toContain(repoPropsData.list[0].shortRevision);
expect(tds.at(3).html()).toContain(repoPropsData.list[0].layers);
expect(tds.at(3).html()).toContain(repoPropsData.list[0].size);
expect(tds.at(4).html()).toContain(wrapper.vm.timeFormated(repoPropsData.list[0].createdAt));
});
});

describe('multi select', () => {
it('selecting a row should enable delete button', done => {
const deleteBtn = findDeleteButton(wrapper);
const checkboxes = findSelectCheckboxes(wrapper);

expect(deleteBtn.attributes('disabled')).toBe('disabled');

checkboxes.at(0).trigger('click');
Vue.nextTick(() => {
expect(deleteBtn.attributes('disabled')).toEqual(undefined);
done();
});
});

it('selecting all checkbox should select all rows and enable delete button', done => {
const selectAll = findSelectAllCheckbox(wrapper);
const checkboxes = findSelectCheckboxes(wrapper);
selectAll.trigger('click');

Vue.nextTick(() => {
const checked = checkboxes.filter(w => w.element.checked);
expect(checked.length).toBe(checkboxes.length);
done();
});
});

it('deselecting select all checkbox should deselect all rows and disable delete button', done => {
const checkboxes = findSelectCheckboxes(wrapper);
const selectAll = findSelectAllCheckbox(wrapper);
selectAll.trigger('click');
selectAll.trigger('click');

Vue.nextTick(() => {
const checked = checkboxes.filter(w => !w.element.checked);
expect(checked.length).toBe(checkboxes.length);
done();
});
});

it('should delete multiple items when multiple items are selected', done => {
const multiDeleteItems = jest.fn().mockResolvedValue();
wrapper.setMethods({ multiDeleteItems });
const selectAll = findSelectAllCheckbox(wrapper);
selectAll.trigger('click');

Vue.nextTick(() => {
const deleteBtn = findDeleteButton(wrapper);
expect(wrapper.vm.itemsToBeDeleted).toEqual([0, 1]);
expect(deleteBtn.attributes('disabled')).toEqual(undefined);
wrapper.vm.handleMultipleDelete();

Vue.nextTick(() => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath,
items: [firstImage.tag, secondImage.tag],
});
done();
});
});
});

it('should show an error message if bulkDeletePath is not set', () => {
const showError = jest.fn();
wrapper.setMethods({ showError });
wrapper.setProps({
repo: {
...repoPropsData,
tagsPath: null,
},
});
wrapper.vm.handleMultipleDelete();
expect(wrapper.vm.showError).toHaveBeenCalled();
});
});

describe('delete registry', () => {
beforeEach(() => {
wrapper.setData({ itemsToBeDeleted: [0] });
});

it('should be possible to delete a registry', () => {
const deleteBtn = findDeleteButton(wrapper);
const deleteBtns = findDeleteButtonsRow(wrapper);
expect(wrapper.vm.itemsToBeDeleted).toEqual([0]);
expect(deleteBtn).toBeDefined();
expect(deleteBtn.attributes('disable')).toBe(undefined);
expect(deleteBtns.is('button')).toBe(true);
});

it('should allow deletion row by row', () => {
const deleteBtns = findDeleteButtonsRow(wrapper);
const deleteSingleItem = jest.fn();
const deleteItem = jest.fn().mockResolvedValue();
wrapper.setMethods({ deleteSingleItem, deleteItem });
deleteBtns.at(0).trigger('click');
expect(wrapper.vm.deleteSingleItem).toHaveBeenCalledWith(0);
wrapper.vm.handleSingleDelete(1);
expect(wrapper.vm.deleteItem).toHaveBeenCalledWith(1);
});
});

describe('pagination', () => {
let localWrapper = null;
const repo = {
repoPropsData,
pagination: {
total: 20,
perPage: 2,
nextPage: 2,
},
};

beforeEach(() => {
localWrapper = mount(tableRegistry, {
propsData: {
repo,
},
});
});

it('should exist', () => {
const pagination = findPagination(localWrapper);
expect(pagination.exists()).toBe(true);
});
it('should be visible when pagination is needed', () => {
const pagination = findPagination(localWrapper);
expect(pagination.isVisible()).toBe(true);
localWrapper.setProps({
repo: {
pagination: {
total: 0,
perPage: 10,
},
},
});
expect(localWrapper.vm.shouldRenderPagination).toBe(false);
});
it('should have a change function that update the list when run', () => {
const fetchList = jest.fn().mockResolvedValue();
localWrapper.setMethods({ fetchList });
localWrapper.vm.onPageChange(1);
expect(localWrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
});
});

describe('modal content', () => {
it('should show the singular title and image name when deleting a single image', () => {
wrapper.setData({ itemsToBeDeleted: [1] });
wrapper.vm.setModalDescription(0);
expect(wrapper.vm.modalTitle).toBe('Remove image');
expect(wrapper.vm.modalDescription).toContain(firstImage.tag);
});

it('should show the plural title and image count when deleting more than one image', () => {
wrapper.setData({ itemsToBeDeleted: [1, 2] });
wrapper.vm.setModalDescription();

expect(wrapper.vm.modalTitle).toBe('Remove images');
expect(wrapper.vm.modalDescription).toContain('<b>2</b> images');
});
});
});

spec/javascripts/registry/mock_data.js → spec/frontend/registry/mock_data.js View File


+ 189
- 0
spec/frontend/registry/stores/actions_spec.js View File

@@ -0,0 +1,189 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/stores/actions';
import * as types from '~/registry/stores/mutation_types';
import { TEST_HOST } from '../../helpers/test_constants';
import testAction from '../../helpers/vuex_action_helper';
import createFlash from '~/flash';

import {
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';

jest.mock('~/flash.js');

describe('Actions Registry Store', () => {
let mock;
let state;

beforeEach(() => {
mock = new MockAdapter(axios);
state = {
endpoint: `${TEST_HOST}/endpoint.json`,
};
});

afterEach(() => {
mock.restore();
});

describe('fetchRepos', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});

it('should set receveived repos', done => {
testAction(
actions.fetchRepos,
null,
state,
[
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.SET_REPOS_LIST, payload: reposServerResponse },
],
[],
done,
);
});

it('should create flash on API error', done => {
testAction(
actions.fetchRepos,
null,
{
endpoint: null,
},
[{ type: types.TOGGLE_MAIN_LOADING }, { type: types.TOGGLE_MAIN_LOADING }],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});

describe('fetchList', () => {
let repo;
beforeEach(() => {
state.repos = parsedReposServerResponse;
[, repo] = state.repos;
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});

it('should set received list', done => {
testAction(
actions.fetchList,
{ repo },
state,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{
type: types.SET_REGISTRY_LIST,
payload: {
repo,
resp: registryServerResponse,
headers: expect.anything(),
},
},
],
[],
done,
);
});

it('should create flash on API error', done => {
const updatedRepo = {
...repo,
tagsPath: null,
};
testAction(
actions.fetchList,
{
repo: updatedRepo,
},
state,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: updatedRepo },
],
[],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});

describe('setMainEndpoint', () => {
it('should commit set main endpoint', done => {
testAction(
actions.setMainEndpoint,
'endpoint',
state,
[{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }],
[],
done,
);
});
});

describe('toggleLoading', () => {
it('should commit toggle main loading', done => {
testAction(
actions.toggleLoading,
null,
state,
[{ type: types.TOGGLE_MAIN_LOADING }],
[],
done,
);
});
});

describe('deleteItem and multiDeleteItems', () => {
let deleted;
const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;

const expectDelete = done => {
expect(mock.history.delete.length).toBe(1);
expect(deleted).toBe(true);
done();
};

beforeEach(() => {
deleted = false;
mock.onDelete(destroyPath).replyOnce(() => {
deleted = true;
return [200];
});
});

it('deleteItem should perform DELETE request on destroyPath', done => {
testAction(
actions.deleteItem,
{
destroyPath,
},
state,
)
.then(() => {
expectDelete(done);
})
.catch(done.fail);
});

it('multiDeleteItems should perform DELETE request on path', done => {
testAction(actions.multiDeleteItems, { path: destroyPath, items: [1] }, state)
.then(() => {
expectDelete(done);
})
.catch(done.fail);
});
});
});

spec/frontend/registry/getters_spec.js → spec/frontend/registry/stores/getters_spec.js View File


spec/javascripts/registry/stores/mutations_spec.js → spec/frontend/registry/stores/mutations_spec.js View File


+ 0
- 129
spec/javascripts/registry/components/app_spec.js View File

@@ -1,129 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import registry from '~/registry/components/app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { TEST_HOST } from 'spec/test_constants';
import { reposServerResponse } from '../mock_data';

describe('Registry List', () => {
const Component = Vue.extend(registry);
const props = {
endpoint: `${TEST_HOST}/foo`,
helpPagePath: 'foo',
noContainersImage: 'foo',
containersErrorImage: 'foo',
repositoryUrl: 'foo',
};
let vm;
let mock;

beforeEach(() => {
mock = new MockAdapter(axios);
});

afterEach(() => {
mock.restore();
vm.$destroy();
});

describe('with data', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, reposServerResponse);

vm = mountComponent(Component, { ...props });
});

it('should render a list of repos', done => {
setTimeout(() => {
expect(vm.$store.state.repos.length).toEqual(reposServerResponse.length);

Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('.container-image').length).toEqual(
reposServerResponse.length,
);
done();
});
}, 0);
});

describe('delete repository', () => {
it('should be possible to delete a repo', done => {
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-head .js-remove-repo')).toBeDefined();
done();
});
}, 0);
});
});

describe('toggle repository', () => {
it('should open the container', done => {
setTimeout(() => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-toggle-repo use').getAttribute('xlink:href'),
).toContain('angle-up');
done();
});
});
}, 0);
});
});
});

describe('without data', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);

vm = mountComponent(Component, { ...props });
});

it('should render empty message', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-no-container-images-text').textContent).toEqual(
'With the Container Registry, every project can have its own space to store its Docker images. More Information',
);
done();
}, 0);
});
});

describe('while loading data', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);

vm = mountComponent(Component, { ...props });
});

it('should render a loading spinner', done => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.gl-spinner')).not.toBe(null);
done();
});
});
});

describe('invalid characters in path', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);

vm = mountComponent(Component, {
...props,
characterError: true,
});
});

it('should render invalid characters error message', done => {
setTimeout(() => {
expect(vm.$el.querySelector('p')).not.toContain(
'We are having trouble connecting to Docker, which could be due to an issue with your project name or path. More information',
);
done();
});
});
});
});

+ 0
- 87
spec/javascripts/registry/components/collapsible_container_spec.js View File

@@ -1,87 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import store from '~/registry/stores';
import * as types from '~/registry/stores/mutation_types';

import { repoPropsData, registryServerResponse, reposServerResponse } from '../mock_data';

describe('collapsible registry container', () => {
let vm;
let mock;
const Component = Vue.extend(collapsibleComponent);

const findDeleteBtn = () => vm.$el.querySelector('.js-remove-repo');

beforeEach(() => {
mock = new MockAdapter(axios);

mock.onGet(repoPropsData.tagsPath).replyOnce(200, registryServerResponse, {});

store.commit(types.SET_REPOS_LIST, reposServerResponse);

vm = new Component({
store,
propsData: {
repo: repoPropsData,
},
}).$mount();
});

afterEach(() => {
mock.restore();
vm.$destroy();
});

describe('toggle', () => {
it('should be closed by default', () => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
expect(vm.iconName).toEqual('angle-right');
});

it('should be open when user clicks on closed repo', done => {
vm.$el.querySelector('.js-toggle-repo').click();

Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-tags')).not.toBeNull();
expect(vm.iconName).toEqual('angle-up');

done();
});
});

it('should be closed when the user clicks on an opened repo', done => {
vm.$el.querySelector('.js-toggle-repo').click();

Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
expect(vm.iconName).toEqual('angle-right');
done();
});
});
});
});
});

describe('delete repo', () => {
it('should be possible to delete a repo', () => {
expect(findDeleteBtn()).not.toBeNull();
});

it('should call deleteItem when confirming deletion', done => {
findDeleteBtn().click();
spyOn(vm, 'deleteItem').and.returnValue(Promise.resolve());

Vue.nextTick(() => {
document.querySelector(`#${vm.modalId} .btn-danger`).click();

expect(vm.deleteItem).toHaveBeenCalledWith(vm.repo);
done();
});
});
});
});

+ 0
- 189
spec/javascripts/registry/components/table_registry_spec.js View File

@@ -1,189 +0,0 @@
import Vue from 'vue';
import tableRegistry from '~/registry/components/table_registry.vue';
import store from '~/registry/stores';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { repoPropsData } from '../mock_data';

const [firstImage, secondImage] = repoPropsData.list;

describe('table registry', () => {
let vm;
const Component = Vue.extend(tableRegistry);
const bulkDeletePath = 'path';

const findDeleteBtn = () => vm.$el.querySelector('.js-delete-registry');
const findDeleteBtnRow = () => vm.$el.querySelector('.js-delete-registry-row');
const findSelectAllCheckbox = () => vm.$el.querySelector('.js-select-all-checkbox > input');
const findAllRowCheckboxes = () =>
Array.from(vm.$el.querySelectorAll('.js-select-checkbox input'));
const confirmationModal = (child = '') => document.querySelector(`#${vm.modalId} ${child}`);

const createComponent = () => {
vm = mountComponentWithStore(Component, {
store,
props: {
repo: repoPropsData,
},
});
};

const selectAllCheckboxes = () => vm.selectAll();
const deselectAllCheckboxes = () => vm.deselectAll();

beforeEach(() => {
createComponent();
});

afterEach(() => {
vm.$destroy();
});

describe('rendering', () => {
it('should render a table with the registry list', () => {
expect(vm.$el.querySelectorAll('table tbody tr').length).toEqual(repoPropsData.list.length);
});

it('should render registry tag', () => {
const textRendered = vm.$el
.querySelector('.table tbody tr')
.textContent.trim()
// replace additional whitespace characters (e.g. new lines) with a single empty space
.replace(/\s\s+/g, ' ');

expect(textRendered).toContain(repoPropsData.list[0].tag);
expect(textRendered).toContain(repoPropsData.list[0].shortRevision);
expect(textRendered).toContain(repoPropsData.list[0].layers);
expect(textRendered).toContain(repoPropsData.list[0].size);
});
});

describe('multi select', () => {
it('should support multiselect and selecting a row should enable delete button', done => {
findSelectAllCheckbox().click();
selectAllCheckboxes();

expect(findSelectAllCheckbox().checked).toBe(true);

Vue.nextTick(() => {
expect(findDeleteBtn().disabled).toBe(false);
done();
});
});

it('selecting all checkbox should select all rows and enable delete button', done => {
selectAllCheckboxes();

Vue.nextTick(() => {
const checkedValues = findAllRowCheckboxes().filter(x => x.checked);

expect(checkedValues.length).toBe(repoPropsData.list.length);
done();
});
});

it('deselecting select all checkbox should deselect all rows and disable delete button', done => {
selectAllCheckboxes();
deselectAllCheckboxes();

Vue.nextTick(() => {
const checkedValues = findAllRowCheckboxes().filter(x => x.checked);

expect(checkedValues.length).toBe(0);
done();
});
});

it('should delete multiple items when multiple items are selected', done => {
selectAllCheckboxes();

Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([0, 1]);
expect(findDeleteBtn().disabled).toBe(false);

findDeleteBtn().click();
spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve());

Vue.nextTick(() => {
const modal = confirmationModal();
confirmationModal('.btn-danger').click();

expect(modal).toExist();

Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([]);
expect(vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath,
items: [firstImage.tag, secondImage.tag],
});
done();
});
});
});
});
});

describe('delete registry', () => {
beforeEach(() => {
vm.itemsToBeDeleted = [0];
});

it('should be possible to delete a registry', done => {
Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([0]);
expect(findDeleteBtn()).toBeDefined();
expect(findDeleteBtn().disabled).toBe(false);
expect(findDeleteBtnRow()).toBeDefined();
done();
});
});

it('should call deleteItems and reset itemsToBeDeleted when confirming deletion', done => {
Vue.nextTick(() => {
expect(vm.itemsToBeDeleted).toEqual([0]);
expect(findDeleteBtn().disabled).toBe(false);
findDeleteBtn().click();
spyOn(vm, 'multiDeleteItems').and.returnValue(Promise.resolve());

Vue.nextTick(() => {
confirmationModal('.btn-danger').click();

expect(vm.itemsToBeDeleted).toEqual([]);
expect(vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath,
items: [firstImage.tag],
});
done();
});
});
});
});

describe('pagination', () => {
it('should be possible to change the page', () => {
expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
});
});

describe('modal content', () => {
it('should show the singular title and image name when deleting a single image', done => {
findDeleteBtnRow().click();

Vue.nextTick(() => {
expect(vm.modalTitle).toBe('Remove image');
expect(vm.modalDescription).toContain(firstImage.tag);
done();
});
});

it('should show the plural title and image count when deleting more than one image', done => {
selectAllCheckboxes();
vm.setModalDescription();

Vue.nextTick(() => {
expect(vm.modalTitle).toBe('Remove images');
expect(vm.modalDescription).toContain('<b>2</b> images');
done();
});
});
});
});

+ 0
- 132
spec/javascripts/registry/stores/actions_spec.js View File

@@ -1,132 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/stores/actions';
import * as types from '~/registry/stores/mutation_types';
import state from '~/registry/stores/state';
import { TEST_HOST } from 'spec/test_constants';
import testAction from '../../helpers/vuex_action_helper';
import {
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';

describe('Actions Registry Store', () => {
let mockedState;
let mock;

beforeEach(() => {
mockedState = state();
mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});

afterEach(() => {
mock.restore();
});

describe('server requests', () => {
describe('fetchRepos', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});

it('should set receveived repos', done => {
testAction(
actions.fetchRepos,
null,
mockedState,
[
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.TOGGLE_MAIN_LOADING },
{ type: types.SET_REPOS_LIST, payload: reposServerResponse },
],
[],
done,
);
});
});

describe('fetchList', () => {
let repo;
beforeEach(() => {
mockedState.repos = parsedReposServerResponse;
[, repo] = mockedState.repos;

mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});

it('should set received list', done => {
testAction(
actions.fetchList,
{ repo },
mockedState,
[
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{ type: types.TOGGLE_REGISTRY_LIST_LOADING, payload: repo },
{
type: types.SET_REGISTRY_LIST,
payload: {
repo,
resp: registryServerResponse,
headers: jasmine.anything(),
},
},
],
[],
done,
);
});
});
});

describe('setMainEndpoint', () => {
it('should commit set main endpoint', done => {
testAction(
actions.setMainEndpoint,
'endpoint',
mockedState,
[{ type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }],
[],
done,
);
});
});

describe('toggleLoading', () => {
it('should commit toggle main loading', done => {
testAction(
actions.toggleLoading,
null,
mockedState,
[{ type: types.TOGGLE_MAIN_LOADING }],
[],
done,
);
});
});

describe('deleteItem', () => {
it('should perform DELETE request on destroyPath', done => {
const destroyPath = `${TEST_HOST}/mygroup/myproject/container_registry/1.json`;
let deleted = false;
mock.onDelete(destroyPath).replyOnce(() => {
deleted = true;
return [200];
});
testAction(
actions.deleteItem,
{
destroyPath,
},
mockedState,
)
.then(() => {
expect(mock.history.delete.length).toBe(1);
expect(deleted).toBe(true);
done();
})
.catch(done.fail);
});
});
});

+ 1
- 1
spec/models/clusters/cluster_spec.rb View File

@@ -546,7 +546,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do

before do
expect(Clusters::KubernetesNamespaceFinder).to receive(:new)
.with(cluster, project: environment.project, environment_slug: environment.slug)
.with(cluster, project: environment.project, environment_name: environment.name)
.and_return(double(execute: persisted_namespace))
end


+ 7
- 7
spec/models/clusters/kubernetes_namespace_spec.rb View File

@@ -24,13 +24,13 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do
end
end

describe '.with_environment_slug' do
describe '.with_environment_name' do
let(:cluster) { create(:cluster, :group) }
let(:environment) { create(:environment, slug: slug) }
let(:environment) { create(:environment, name: name) }

let(:slug) { 'production' }
let(:name) { 'production' }

subject { described_class.with_environment_slug(slug) }
subject { described_class.with_environment_name(name) }

context 'there is no associated environment' do
let!(:namespace) { create(:cluster_kubernetes_namespace, cluster: cluster, project: environment.project) }
@@ -48,12 +48,12 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do
)
end

context 'with a matching slug' do
context 'with a matching name' do
it { is_expected.to eq [namespace] }
end

context 'without a matching slug' do
let(:environment) { create(:environment, slug: 'staging') }
context 'without a matching name' do
let(:environment) { create(:environment, name: 'staging') }

it { is_expected.to be_empty }
end

+ 1
- 1
spec/models/clusters/platforms/kubernetes_spec.rb View File

@@ -218,7 +218,7 @@ describe Clusters::Platforms::Kubernetes do

before do
allow(Clusters::KubernetesNamespaceFinder).to receive(:new)
.with(cluster, project: project, environment_slug: environment_slug)
.with(cluster, project: project, environment_name: environment_name)
.and_return(double(execute: persisted_namespace))
end


Loading…
Cancel
Save