summaryrefslogtreecommitdiff
path: root/spec/frontend/packages
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-08-20 18:42:06 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-08-20 18:42:06 +0000
commit6e4e1050d9dba2b7b2523fdd1768823ab85feef4 (patch)
tree78be5963ec075d80116a932011d695dd33910b4e /spec/frontend/packages
parent1ce776de4ae122aba3f349c02c17cebeaa8ecf07 (diff)
downloadgitlab-ce-6e4e1050d9dba2b7b2523fdd1768823ab85feef4.tar.gz
Add latest changes from gitlab-org/gitlab@13-3-stable-ee
Diffstat (limited to 'spec/frontend/packages')
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap46
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap49
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap34
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap38
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap69
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap69
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap49
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap172
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap50
-rw-r--r--spec/frontend/packages/details/components/additional_metadata_spec.js119
-rw-r--r--spec/frontend/packages/details/components/app_spec.js281
-rw-r--r--spec/frontend/packages/details/components/code_instruction_spec.js110
-rw-r--r--spec/frontend/packages/details/components/composer_installation_spec.js95
-rw-r--r--spec/frontend/packages/details/components/conan_installation_spec.js68
-rw-r--r--spec/frontend/packages/details/components/dependency_row_spec.js62
-rw-r--r--spec/frontend/packages/details/components/history_element_spec.js57
-rw-r--r--spec/frontend/packages/details/components/installations_commands_spec.js57
-rw-r--r--spec/frontend/packages/details/components/maven_installation_spec.js91
-rw-r--r--spec/frontend/packages/details/components/npm_installation_spec.js99
-rw-r--r--spec/frontend/packages/details/components/nuget_installation_spec.js75
-rw-r--r--spec/frontend/packages/details/components/package_history_spec.js106
-rw-r--r--spec/frontend/packages/details/components/package_title_spec.js168
-rw-r--r--spec/frontend/packages/details/components/pypi_installation_spec.js60
-rw-r--r--spec/frontend/packages/details/mock_data.js47
-rw-r--r--spec/frontend/packages/details/store/actions_spec.js76
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js237
-rw-r--r--spec/frontend/packages/details/store/mutations_spec.js31
-rw-r--r--spec/frontend/packages/details/utils_spec.js24
-rw-r--r--spec/frontend/packages/list/coming_soon/helpers_spec.js36
-rw-r--r--spec/frontend/packages/list/coming_soon/mock_data.js90
-rw-r--r--spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js138
-rw-r--r--spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap14
-rw-r--r--spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap457
-rw-r--r--spec/frontend/packages/list/components/packages_filter_spec.js50
-rw-r--r--spec/frontend/packages/list/components/packages_list_app_spec.js148
-rw-r--r--spec/frontend/packages/list/components/packages_list_spec.js219
-rw-r--r--spec/frontend/packages/list/components/packages_sort_spec.js92
-rw-r--r--spec/frontend/packages/list/stores/actions_spec.js240
-rw-r--r--spec/frontend/packages/list/stores/getters_spec.js36
-rw-r--r--spec/frontend/packages/list/stores/mutations_spec.js95
-rw-r--r--spec/frontend/packages/list/utils_spec.js39
-rw-r--r--spec/frontend/packages/mock_data.js170
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap101
-rw-r--r--spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap39
-rw-r--r--spec/frontend/packages/shared/components/package_list_row_spec.js106
-rw-r--r--spec/frontend/packages/shared/components/package_tags_spec.js115
-rw-r--r--spec/frontend/packages/shared/components/packages_list_loader_spec.js42
-rw-r--r--spec/frontend/packages/shared/components/publish_method_spec.js50
-rw-r--r--spec/frontend/packages/shared/utils_spec.js66
49 files changed, 4782 insertions, 0 deletions
diff --git a/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap
new file mode 100644
index 00000000000..172b8919673
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Package code instruction multiline to match the snapshot 1`] = `
+<div>
+ <pre
+ class="js-instruction-pre"
+ >
+ this is some
+multiline text
+ </pre>
+</div>
+`;
+
+exports[`Package code instruction single line to match the default snapshot 1`] = `
+<div
+ class="input-group gl-mb-3"
+>
+ <input
+ class="form-control monospace js-instruction-input"
+ readonly="readonly"
+ type="text"
+ />
+
+ <span
+ class="input-group-append js-instruction-button"
+ >
+ <button
+ class="btn input-group-text btn-secondary btn-md btn-default"
+ data-clipboard-text="npm i @my-package"
+ title="Copy npm install command"
+ type="button"
+ >
+ <!---->
+
+ <svg
+ class="gl-icon s16"
+ data-testid="copy-to-clipboard-icon"
+ >
+ <use
+ href="#copy-to-clipboard"
+ />
+ </svg>
+ </button>
+ </span>
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
new file mode 100644
index 00000000000..852292e084b
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap
@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConanInstallation renders all the messages 1`] = `
+<div>
+ <h3
+ class="gl-font-lg"
+ >
+ Installation
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+
+ Conan Command
+
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy Conan Command"
+ instruction="foo/command"
+ trackingaction="copy_conan_command"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+
+ Add Conan Remote
+
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy Conan Setup Command"
+ instruction="foo/setup"
+ trackingaction="copy_conan_setup_command"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap
new file mode 100644
index 00000000000..28b7ca442eb
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap
@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DependencyRow renders full dependency 1`] = `
+<div
+ class="gl-responsive-table-row"
+>
+ <div
+ class="table-section section-50"
+ >
+ <strong
+ class="gl-text-body"
+ >
+ Test.Dependency
+ </strong>
+
+ <span
+ data-testid="target-framework"
+ >
+ (.NETStandard2.0)
+ </span>
+ </div>
+
+ <div
+ class="table-section section-50 gl-display-flex justify-content-md-end"
+ data-testid="version-pattern"
+ >
+ <span
+ class="gl-text-body"
+ >
+ 2.3.7
+ </span>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap
new file mode 100644
index 00000000000..a1751d69c70
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap
@@ -0,0 +1,38 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`History Element renders the correct markup 1`] = `
+<li
+ class="timeline-entry system-note note-wrapper gl-mb-6!"
+>
+ <div
+ class="timeline-entry-inner"
+ >
+ <div
+ class="timeline-icon"
+ >
+ <gl-icon-stub
+ name="pencil"
+ size="16"
+ />
+ </div>
+
+ <div
+ class="timeline-content"
+ >
+ <div
+ class="note-header"
+ >
+ <span>
+ <div
+ data-testid="default-slot"
+ />
+ </span>
+ </div>
+
+ <div
+ class="note-body"
+ />
+ </div>
+ </div>
+</li>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
new file mode 100644
index 00000000000..10e54500797
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MavenInstallation renders all the messages 1`] = `
+<div>
+ <h3
+ class="gl-font-lg"
+ >
+ Installation
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+
+ Maven XML
+
+ </h4>
+
+ <p>
+ <gl-sprintf-stub
+ message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block."
+ />
+ </p>
+
+ <code-instruction-stub
+ copytext="Copy Maven XML"
+ instruction="foo/xml"
+ multiline="true"
+ trackingaction="copy_maven_xml"
+ />
+
+ <h4
+ class="gl-font-base"
+ >
+
+ Maven Command
+
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy Maven command"
+ instruction="foo/command"
+ trackingaction="copy_maven_command"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <p>
+ <gl-sprintf-stub
+ message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file."
+ />
+ </p>
+
+ <code-instruction-stub
+ copytext="Copy Maven registry XML"
+ instruction="foo/setup"
+ multiline="true"
+ trackingaction="copy_maven_setup_xml"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
new file mode 100644
index 00000000000..58a509e6847
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NpmInstallation renders all the messages 1`] = `
+<div>
+ <h3
+ class="gl-font-lg"
+ >
+ Installation
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+ npm command
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy npm command"
+ instruction="npm i @Test/package"
+ trackingaction="copy_npm_install_command"
+ />
+
+ <h4
+ class="gl-font-base"
+ >
+ yarn command
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy yarn command"
+ instruction="yarn add @Test/package"
+ trackingaction="copy_yarn_install_command"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+ npm command
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy npm setup command"
+ instruction="echo @Test:registry=undefined >> .npmrc"
+ trackingaction="copy_npm_setup_command"
+ />
+
+ <h4
+ class="gl-font-base"
+ >
+ yarn command
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy yarn setup command"
+ instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined\\\\\\" >> .yarnrc"
+ trackingaction="copy_yarn_setup_command"
+ />
+
+ <gl-sprintf-stub
+ message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
new file mode 100644
index 00000000000..67810290c62
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap
@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`NugetInstallation renders all the messages 1`] = `
+<div>
+ <h3
+ class="gl-font-lg"
+ >
+ Installation
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+
+ NuGet Command
+
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy NuGet Command"
+ instruction="foo/command"
+ trackingaction="copy_nuget_install_command"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+
+ Add NuGet Source
+
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy NuGet Setup Command"
+ instruction="foo/setup"
+ trackingaction="copy_nuget_setup_command"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
new file mode 100644
index 00000000000..bdcd4a9e077
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap
@@ -0,0 +1,172 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PackageTitle renders with tags 1`] = `
+<div
+ class="gl-flex-direction-column"
+>
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+
+ <div
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ >
+
+ Test package
+
+ </h1>
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <gl-icon-stub
+ class="gl-text-gray-500 gl-mr-3"
+ name="package"
+ size="16"
+ />
+
+ <span
+ class="gl-font-weight-bold"
+ data-testid="package-type"
+ >
+ maven
+ </span>
+ </div>
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <package-tags-stub
+ tagdisplaylimit="1"
+ tags="[object Object],[object Object],[object Object],[object Object]"
+ />
+ </div>
+
+ <!---->
+
+ <!---->
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <gl-icon-stub
+ class="gl-text-gray-500 gl-mr-3"
+ name="disk"
+ size="16"
+ />
+
+ <span
+ class="gl-font-weight-bold"
+ data-testid="package-size"
+ >
+ 300 bytes
+ </span>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`PackageTitle renders without tags 1`] = `
+<div
+ class="gl-flex-direction-column"
+>
+ <div
+ class="gl-display-flex"
+ >
+ <!---->
+
+ <div
+ class="gl-display-flex gl-flex-direction-column"
+ >
+ <h1
+ class="gl-font-size-h1 gl-mt-3 gl-mb-2"
+ >
+
+ Test package
+
+ </h1>
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ >
+ <gl-icon-stub
+ class="gl-mr-3"
+ name="eye"
+ size="16"
+ />
+
+ <gl-sprintf-stub
+ message="v%{version} published %{timeAgo}"
+ />
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <gl-icon-stub
+ class="gl-text-gray-500 gl-mr-3"
+ name="package"
+ size="16"
+ />
+
+ <span
+ class="gl-font-weight-bold"
+ data-testid="package-type"
+ >
+ maven
+ </span>
+ </div>
+
+ <!---->
+
+ <!---->
+
+ <!---->
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-mr-5"
+ >
+ <gl-icon-stub
+ class="gl-text-gray-500 gl-mr-3"
+ name="disk"
+ size="16"
+ />
+
+ <span
+ class="gl-font-weight-bold"
+ data-testid="package-size"
+ >
+ 300 bytes
+ </span>
+ </div>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
new file mode 100644
index 00000000000..5c1e74d73af
--- /dev/null
+++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap
@@ -0,0 +1,50 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PypiInstallation renders all the messages 1`] = `
+<div>
+ <h3
+ class="gl-font-lg"
+ >
+ Installation
+ </h3>
+
+ <h4
+ class="gl-font-base"
+ >
+
+ Pip Command
+
+ </h4>
+
+ <code-instruction-stub
+ copytext="Copy Pip command"
+ data-testid="pip-command"
+ instruction="pip install"
+ trackingaction="copy_pip_install_command"
+ />
+
+ <h3
+ class="gl-font-lg"
+ >
+ Registry setup
+ </h3>
+
+ <p>
+ <gl-sprintf-stub
+ message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file."
+ />
+ </p>
+
+ <code-instruction-stub
+ copytext="Copy .pypirc content"
+ data-testid="pypi-setup-content"
+ instruction="python setup"
+ multiline="true"
+ trackingaction="copy_pypi_setup_command"
+ />
+
+ <gl-sprintf-stub
+ message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}."
+ />
+</div>
+`;
diff --git a/spec/frontend/packages/details/components/additional_metadata_spec.js b/spec/frontend/packages/details/components/additional_metadata_spec.js
new file mode 100644
index 00000000000..b2337b86740
--- /dev/null
+++ b/spec/frontend/packages/details/components/additional_metadata_spec.js
@@ -0,0 +1,119 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import DetailsRow from '~/registry/shared/components/details_row.vue';
+import component from '~/packages/details/components/additional_metadata.vue';
+
+import { mavenPackage, conanPackage, nugetPackage, npmPackage } from '../../mock_data';
+
+describe('Package Additional Metadata', () => {
+ let wrapper;
+ const defaultProps = {
+ packageEntity: { ...mavenPackage },
+ };
+
+ const mountComponent = props => {
+ wrapper = shallowMount(component, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ DetailsRow,
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTitle = () => wrapper.find('[data-testid="title"]');
+ const findMainArea = () => wrapper.find('[data-testid="main"]');
+ const findNugetSource = () => wrapper.find('[data-testid="nuget-source"]');
+ const findNugetLicense = () => wrapper.find('[data-testid="nuget-license"]');
+ const findConanRecipe = () => wrapper.find('[data-testid="conan-recipe"]');
+ const findMavenApp = () => wrapper.find('[data-testid="maven-app"]');
+ const findMavenGroup = () => wrapper.find('[data-testid="maven-group"]');
+ const findElementLink = container => container.find(GlLink);
+
+ it('has the correct title', () => {
+ mountComponent();
+
+ const title = findTitle();
+
+ expect(title.exists()).toBe(true);
+ expect(title.text()).toBe('Additional Metadata');
+ });
+
+ describe.each`
+ packageEntity | visible | metadata
+ ${mavenPackage} | ${true} | ${'maven_metadatum'}
+ ${conanPackage} | ${true} | ${'conan_metadatum'}
+ ${nugetPackage} | ${true} | ${'nuget_metadatum'}
+ ${npmPackage} | ${false} | ${null}
+ `('Component visibility', ({ packageEntity, visible, metadata }) => {
+ it(`Is ${visible} that the component markup is visible when the package is ${packageEntity.package_type}`, () => {
+ mountComponent({ packageEntity });
+
+ expect(findTitle().exists()).toBe(visible);
+ expect(findMainArea().exists()).toBe(visible);
+ });
+
+ it(`The component is hidden if ${metadata} is missing`, () => {
+ mountComponent({ packageEntity: { ...packageEntity, [metadata]: null } });
+
+ expect(findTitle().exists()).toBe(false);
+ expect(findMainArea().exists()).toBe(false);
+ });
+ });
+
+ describe('nuget metadata', () => {
+ beforeEach(() => {
+ mountComponent({ packageEntity: nugetPackage });
+ });
+
+ it.each`
+ name | finderFunction | text | link | icon
+ ${'source'} | ${findNugetSource} | ${'Source project located at project-foo-url'} | ${'project_url'} | ${'project'}
+ ${'license'} | ${findNugetLicense} | ${'License information located at license-foo-url'} | ${'license_url'} | ${'license'}
+ `('$name element', ({ finderFunction, text, link, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ expect(findElementLink(element).attributes('href')).toBe(nugetPackage.nuget_metadatum[link]);
+ });
+ });
+
+ describe('conan metadata', () => {
+ beforeEach(() => {
+ mountComponent({ packageEntity: conanPackage });
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'recipe'} | ${findConanRecipe} | ${'Recipe: conan-package/1.0.0@conan+conan-package/stable'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+ });
+
+ describe('maven metadata', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it.each`
+ name | finderFunction | text | icon
+ ${'app'} | ${findMavenApp} | ${'App name: test-app'} | ${'information-o'}
+ ${'group'} | ${findMavenGroup} | ${'App group: com.test.app'} | ${'information-o'}
+ `('$name element', ({ finderFunction, text, icon }) => {
+ const element = finderFunction();
+ expect(element.exists()).toBe(true);
+ expect(element.text()).toBe(text);
+ expect(element.props('icon')).toBe(icon);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js
new file mode 100644
index 00000000000..f535f3f5744
--- /dev/null
+++ b/spec/frontend/packages/details/components/app_spec.js
@@ -0,0 +1,281 @@
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { GlEmptyState, GlModal } from '@gitlab/ui';
+import stubChildren from 'helpers/stub_children';
+import Tracking from '~/tracking';
+import * as getters from '~/packages/details/store/getters';
+import PackagesApp from '~/packages/details/components/app.vue';
+import PackageTitle from '~/packages/details/components/package_title.vue';
+
+import * as SharedUtils from '~/packages/shared/utils';
+import { TrackingActions } from '~/packages/shared/constants';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import PackageListRow from '~/packages/shared/components/package_list_row.vue';
+
+import DependencyRow from '~/packages/details/components/dependency_row.vue';
+import PackageHistory from '~/packages/details/components/package_history.vue';
+import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue';
+import InstallationCommands from '~/packages/details/components/installation_commands.vue';
+
+import {
+ composerPackage,
+ conanPackage,
+ mavenPackage,
+ mavenFiles,
+ npmPackage,
+ npmFiles,
+ nugetPackage,
+} from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('PackagesApp', () => {
+ let wrapper;
+ let store;
+ const fetchPackageVersions = jest.fn();
+
+ function createComponent({
+ packageEntity = mavenPackage,
+ packageFiles = mavenFiles,
+ isLoading = false,
+ oneColumnView = false,
+ } = {}) {
+ store = new Vuex.Store({
+ state: {
+ isLoading,
+ packageEntity,
+ packageFiles,
+ canDelete: true,
+ destroyPath: 'destroy-package-path',
+ emptySvgPath: 'empty-illustration',
+ npmPath: 'foo',
+ npmHelpPath: 'foo',
+ projectName: 'bar',
+ oneColumnView,
+ },
+ actions: {
+ fetchPackageVersions,
+ },
+ getters,
+ });
+
+ wrapper = mount(PackagesApp, {
+ localVue,
+ store,
+ stubs: {
+ ...stubChildren(PackagesApp),
+ GlButton: false,
+ GlModal: false,
+ GlTab: false,
+ GlTabs: false,
+ GlTable: false,
+ },
+ });
+ }
+
+ const packageTitle = () => wrapper.find(PackageTitle);
+ const emptyState = () => wrapper.find(GlEmptyState);
+ const allFileRows = () => wrapper.findAll('.js-file-row');
+ const firstFileDownloadLink = () => wrapper.find('.js-file-download');
+ const deleteButton = () => wrapper.find('.js-delete-button');
+ const deleteModal = () => wrapper.find(GlModal);
+ const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' });
+ const versionsTab = () => wrapper.find('.js-versions-tab > a');
+ const packagesLoader = () => wrapper.find(PackagesListLoader);
+ const packagesVersionRows = () => wrapper.findAll(PackageListRow);
+ const noVersionsMessage = () => wrapper.find('[data-testid="no-versions-message"]');
+ const dependenciesTab = () => wrapper.find('.js-dependencies-tab > a');
+ const dependenciesCountBadge = () => wrapper.find('[data-testid="dependencies-badge"]');
+ const noDependenciesMessage = () => wrapper.find('[data-testid="no-dependencies-message"]');
+ const dependencyRows = () => wrapper.findAll(DependencyRow);
+ const findPackageHistory = () => wrapper.find(PackageHistory);
+ const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata);
+ const findInstallationCommands = () => wrapper.find(InstallationCommands);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the app and displays the package title', () => {
+ createComponent();
+
+ expect(packageTitle()).toExist();
+ });
+
+ it('renders an empty state component when no an invalid package is passed as a prop', () => {
+ createComponent({
+ packageEntity: {},
+ });
+
+ expect(emptyState()).toExist();
+ });
+
+ it('package history has the right props', () => {
+ createComponent();
+ expect(findPackageHistory().exists()).toBe(true);
+ expect(findPackageHistory().props('packageEntity')).toEqual(wrapper.vm.packageEntity);
+ expect(findPackageHistory().props('projectName')).toEqual(wrapper.vm.projectName);
+ });
+
+ it('additional metadata has the right props', () => {
+ createComponent();
+ expect(findAdditionalMetadata().exists()).toBe(true);
+ expect(findAdditionalMetadata().props('packageEntity')).toEqual(wrapper.vm.packageEntity);
+ });
+
+ it('installation commands has the right props', () => {
+ createComponent();
+ expect(findInstallationCommands().exists()).toBe(true);
+ expect(findInstallationCommands().props('packageEntity')).toEqual(wrapper.vm.packageEntity);
+ });
+
+ it('hides the files table if package type is COMPOSER', () => {
+ createComponent({ packageEntity: composerPackage });
+ expect(allFileRows().exists()).toBe(false);
+ });
+
+ it('renders a single file for an npm package as they only contain one file', () => {
+ createComponent({ packageEntity: npmPackage, packageFiles: npmFiles });
+
+ expect(allFileRows()).toExist();
+ expect(allFileRows()).toHaveLength(1);
+ });
+
+ it('renders multiple files for a package that contains more than one file', () => {
+ createComponent();
+
+ expect(allFileRows()).toExist();
+ expect(allFileRows()).toHaveLength(2);
+ });
+
+ it('allows the user to download a package file by rendering a download link', () => {
+ createComponent();
+
+ expect(allFileRows()).toExist();
+ expect(firstFileDownloadLink().vm.$attrs.href).toContain('download');
+ });
+
+ describe('deleting packages', () => {
+ beforeEach(() => {
+ createComponent();
+ deleteButton().trigger('click');
+ });
+
+ it('shows the delete confirmation modal when delete is clicked', () => {
+ expect(deleteModal()).toExist();
+ });
+ });
+
+ describe('versions', () => {
+ describe('api call', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('makes api request on first click of tab', () => {
+ versionsTab().trigger('click');
+
+ expect(fetchPackageVersions).toHaveBeenCalled();
+ });
+ });
+
+ it('displays the loader when state is loading', () => {
+ createComponent({ isLoading: true });
+
+ expect(packagesLoader().exists()).toBe(true);
+ });
+
+ it('displays the correct version count when the package has versions', () => {
+ createComponent({ packageEntity: npmPackage });
+
+ expect(packagesVersionRows()).toHaveLength(npmPackage.versions.length);
+ });
+
+ it('displays the no versions message when there are none', () => {
+ createComponent();
+
+ expect(noVersionsMessage().exists()).toBe(true);
+ });
+ });
+
+ describe('dependency links', () => {
+ it('does not show the dependency links for a non nuget package', () => {
+ createComponent();
+
+ expect(dependenciesTab().exists()).toBe(false);
+ });
+
+ it('shows the dependencies tab with 0 count when a nuget package with no dependencies', () => {
+ createComponent({
+ packageEntity: {
+ ...nugetPackage,
+ dependency_links: [],
+ },
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const dependenciesBadge = dependenciesCountBadge();
+
+ expect(dependenciesTab().exists()).toBe(true);
+ expect(dependenciesBadge.exists()).toBe(true);
+ expect(dependenciesBadge.text()).toBe('0');
+ expect(noDependenciesMessage().exists()).toBe(true);
+ });
+ });
+
+ it('renders the correct number of dependency rows for a nuget package', () => {
+ createComponent({ packageEntity: nugetPackage });
+
+ return wrapper.vm.$nextTick(() => {
+ const dependenciesBadge = dependenciesCountBadge();
+
+ expect(dependenciesTab().exists()).toBe(true);
+ expect(dependenciesBadge.exists()).toBe(true);
+ expect(dependenciesBadge.text()).toBe(nugetPackage.dependency_links.length.toString());
+ expect(dependencyRows()).toHaveLength(nugetPackage.dependency_links.length);
+ });
+ });
+ });
+
+ describe('tracking', () => {
+ let eventSpy;
+ let utilSpy;
+ const category = 'foo';
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
+ });
+
+ it('tracking category calls packageTypeToTrackCategory', () => {
+ createComponent({ packageEntity: conanPackage });
+ expect(wrapper.vm.tracking.category).toBe(category);
+ expect(utilSpy).toHaveBeenCalledWith('conan');
+ });
+
+ it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => {
+ createComponent({ packageEntity: conanPackage });
+ deleteButton().trigger('click');
+ return wrapper.vm.$nextTick().then(() => {
+ modalDeleteButton().trigger('click');
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ TrackingActions.DELETE_PACKAGE,
+ expect.any(Object),
+ );
+ });
+ });
+
+ it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => {
+ createComponent({ packageEntity: conanPackage });
+
+ firstFileDownloadLink().vm.$emit('click');
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ TrackingActions.PULL_PACKAGE,
+ expect.any(Object),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/code_instruction_spec.js b/spec/frontend/packages/details/components/code_instruction_spec.js
new file mode 100644
index 00000000000..724eddb9070
--- /dev/null
+++ b/spec/frontend/packages/details/components/code_instruction_spec.js
@@ -0,0 +1,110 @@
+import { mount } from '@vue/test-utils';
+import CodeInstruction from '~/packages/details/components/code_instruction.vue';
+import { TrackingLabels } from '~/packages/details/constants';
+import Tracking from '~/tracking';
+
+describe('Package code instruction', () => {
+ let wrapper;
+
+ const defaultProps = {
+ instruction: 'npm i @my-package',
+ copyText: 'Copy npm install command',
+ };
+
+ function createComponent(props = {}) {
+ wrapper = mount(CodeInstruction, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ }
+
+ const findInstructionInput = () => wrapper.find('.js-instruction-input');
+ const findInstructionPre = () => wrapper.find('.js-instruction-pre');
+ const findInstructionButton = () => wrapper.find('.js-instruction-button');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('single line', () => {
+ beforeEach(() => createComponent());
+
+ it('to match the default snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('multiline', () => {
+ beforeEach(() =>
+ createComponent({
+ instruction: 'this is some\nmultiline text',
+ copyText: 'Copy the command',
+ multiline: true,
+ }),
+ );
+
+ it('to match the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('tracking', () => {
+ let eventSpy;
+ const trackingAction = 'test_action';
+ const label = TrackingLabels.CODE_INSTRUCTION;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ });
+
+ it('should not track when no trackingAction is provided', () => {
+ createComponent();
+ findInstructionButton().trigger('click');
+
+ expect(eventSpy).toHaveBeenCalledTimes(0);
+ });
+
+ describe('when trackingAction is provided for single line', () => {
+ beforeEach(() =>
+ createComponent({
+ trackingAction,
+ }),
+ );
+
+ it('should track when copying from the input', () => {
+ findInstructionInput().trigger('copy');
+
+ expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, {
+ label,
+ });
+ });
+
+ it('should track when the copy button is pressed', () => {
+ findInstructionButton().trigger('click');
+
+ expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, {
+ label,
+ });
+ });
+ });
+
+ describe('when trackingAction is provided for multiline', () => {
+ beforeEach(() =>
+ createComponent({
+ trackingAction,
+ multiline: true,
+ }),
+ );
+
+ it('should track when copying from the multiline pre element', () => {
+ findInstructionPre().trigger('copy');
+
+ expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, {
+ label,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js
new file mode 100644
index 00000000000..7679d721391
--- /dev/null
+++ b/spec/frontend/packages/details/components/composer_installation_spec.js
@@ -0,0 +1,95 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data';
+import { composerPackage as packageEntity } from 'jest/packages/mock_data';
+import ComposerInstallation from '~/packages/details/components/composer_installation.vue';
+import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import { TrackingActions } from '~/packages/details/constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ComposerInstallation', () => {
+ let wrapper;
+
+ const composerRegistryIncludeStr = 'foo/registry';
+ const composerPackageIncludeStr = 'foo/package';
+
+ const store = new Vuex.Store({
+ state: {
+ packageEntity,
+ composerHelpPath,
+ },
+ getters: {
+ composerRegistryInclude: () => composerRegistryIncludeStr,
+ composerPackageInclude: () => composerPackageIncludeStr,
+ },
+ });
+
+ const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+ const findRegistryIncludeTitle = () => wrapper.find('[data-testid="registry-include-title"]');
+ const findPackageIncludeTitle = () => wrapper.find('[data-testid="package-include-title"]');
+ const findHelpText = () => wrapper.find('[data-testid="help-text"]');
+ const findHelpLink = () => wrapper.find(GlLink);
+
+ function createComponent() {
+ wrapper = shallowMount(ComposerInstallation, {
+ localVue,
+ store,
+ stubs: {
+ GlSprintf,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('registry include command', () => {
+ it('uses code_instructions', () => {
+ const registryIncludeCommand = findCodeInstructions().at(0);
+ expect(registryIncludeCommand.exists()).toBe(true);
+ expect(registryIncludeCommand.props()).toMatchObject({
+ instruction: composerRegistryIncludeStr,
+ copyText: 'Copy registry include',
+ trackingAction: TrackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND,
+ });
+ });
+
+ it('has the correct title', () => {
+ expect(findRegistryIncludeTitle().text()).toBe('composer.json registry include');
+ });
+ });
+
+ describe('package include command', () => {
+ it('uses code_instructions', () => {
+ const registryIncludeCommand = findCodeInstructions().at(1);
+ expect(registryIncludeCommand.exists()).toBe(true);
+ expect(registryIncludeCommand.props()).toMatchObject({
+ instruction: composerPackageIncludeStr,
+ copyText: 'Copy require package include',
+ trackingAction: TrackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND,
+ });
+ });
+
+ it('has the correct title', () => {
+ expect(findPackageIncludeTitle().text()).toBe('composer.json require package include');
+ });
+
+ it('has the correct help text', () => {
+ expect(findHelpText().text()).toBe(
+ 'For more information on Composer packages in GitLab, see the documentation.',
+ );
+ expect(findHelpLink().attributes()).toMatchObject({
+ href: composerHelpPath,
+ target: '_blank',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js
new file mode 100644
index 00000000000..5b31e38dad5
--- /dev/null
+++ b/spec/frontend/packages/details/components/conan_installation_spec.js
@@ -0,0 +1,68 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import ConanInstallation from '~/packages/details/components/conan_installation.vue';
+import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import { conanPackage as packageEntity } from '../../mock_data';
+import { registryUrl as conanPath } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ConanInstallation', () => {
+ let wrapper;
+
+ const conanInstallationCommandStr = 'foo/command';
+ const conanSetupCommandStr = 'foo/setup';
+
+ const store = new Vuex.Store({
+ state: {
+ packageEntity,
+ conanPath,
+ },
+ getters: {
+ conanInstallationCommand: () => conanInstallationCommandStr,
+ conanSetupCommand: () => conanSetupCommandStr,
+ },
+ });
+
+ const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+
+ function createComponent() {
+ wrapper = shallowMount(ConanInstallation, {
+ localVue,
+ store,
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct command', () => {
+ expect(
+ findCodeInstructions()
+ .at(0)
+ .props('instruction'),
+ ).toBe(conanInstallationCommandStr);
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct command', () => {
+ expect(
+ findCodeInstructions()
+ .at(1)
+ .props('instruction'),
+ ).toBe(conanSetupCommandStr);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/dependency_row_spec.js b/spec/frontend/packages/details/components/dependency_row_spec.js
new file mode 100644
index 00000000000..7d3ee92908d
--- /dev/null
+++ b/spec/frontend/packages/details/components/dependency_row_spec.js
@@ -0,0 +1,62 @@
+import { shallowMount } from '@vue/test-utils';
+import DependencyRow from '~/packages/details/components/dependency_row.vue';
+import { dependencyLinks } from '../../mock_data';
+
+describe('DependencyRow', () => {
+ let wrapper;
+
+ const { withoutFramework, withoutVersion, fullLink } = dependencyLinks;
+
+ function createComponent({ dependencyLink = fullLink } = {}) {
+ wrapper = shallowMount(DependencyRow, {
+ propsData: {
+ dependency: dependencyLink,
+ },
+ });
+ }
+
+ const dependencyVersion = () => wrapper.find('[data-testid="version-pattern"]');
+ const dependencyFramework = () => wrapper.find('[data-testid="target-framework"]');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('renders', () => {
+ it('full dependency', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('version', () => {
+ it('does not render any version information when not supplied', () => {
+ createComponent({ dependencyLink: withoutVersion });
+
+ expect(dependencyVersion().exists()).toBe(false);
+ });
+
+ it('does render version info when it exists', () => {
+ createComponent();
+
+ expect(dependencyVersion().exists()).toBe(true);
+ expect(dependencyVersion().text()).toBe(fullLink.version_pattern);
+ });
+ });
+
+ describe('target framework', () => {
+ it('does not render any framework information when not supplied', () => {
+ createComponent({ dependencyLink: withoutFramework });
+
+ expect(dependencyFramework().exists()).toBe(false);
+ });
+
+ it('does render framework info when it exists', () => {
+ createComponent();
+
+ expect(dependencyFramework().exists()).toBe(true);
+ expect(dependencyFramework().text()).toBe(`(${fullLink.target_framework})`);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/history_element_spec.js b/spec/frontend/packages/details/components/history_element_spec.js
new file mode 100644
index 00000000000..e8746fc93f5
--- /dev/null
+++ b/spec/frontend/packages/details/components/history_element_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import component from '~/packages/details/components/history_element.vue';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+
+describe('History Element', () => {
+ let wrapper;
+ const defaultProps = {
+ icon: 'pencil',
+ };
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ propsData: { ...defaultProps },
+ stubs: {
+ TimelineEntryItem,
+ },
+ slots: {
+ default: '<div data-testid="default-slot"></div>',
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTimelineEntry = () => wrapper.find(TimelineEntryItem);
+ const findGlIcon = () => wrapper.find(GlIcon);
+ const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
+
+ it('renders the correct markup', () => {
+ mountComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('has a default slot', () => {
+ mountComponent();
+
+ expect(findDefaultSlot().exists()).toBe(true);
+ });
+ it('has a timeline entry', () => {
+ mountComponent();
+
+ expect(findTimelineEntry().exists()).toBe(true);
+ });
+ it('has an icon', () => {
+ mountComponent();
+
+ const icon = findGlIcon();
+
+ expect(icon.exists()).toBe(true);
+ expect(icon.attributes('name')).toBe(defaultProps.icon);
+ });
+});
diff --git a/spec/frontend/packages/details/components/installations_commands_spec.js b/spec/frontend/packages/details/components/installations_commands_spec.js
new file mode 100644
index 00000000000..60da34ebcd9
--- /dev/null
+++ b/spec/frontend/packages/details/components/installations_commands_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+import InstallationCommands from '~/packages/details/components/installation_commands.vue';
+
+import NpmInstallation from '~/packages/details/components/npm_installation.vue';
+import MavenInstallation from '~/packages/details/components/maven_installation.vue';
+import ConanInstallation from '~/packages/details/components/conan_installation.vue';
+import NugetInstallation from '~/packages/details/components/nuget_installation.vue';
+import PypiInstallation from '~/packages/details/components/pypi_installation.vue';
+import ComposerInstallation from '~/packages/details/components/composer_installation.vue';
+
+import {
+ conanPackage,
+ mavenPackage,
+ npmPackage,
+ nugetPackage,
+ pypiPackage,
+ composerPackage,
+} from '../../mock_data';
+
+describe('InstallationCommands', () => {
+ let wrapper;
+
+ function createComponent(propsData) {
+ wrapper = shallowMount(InstallationCommands, {
+ propsData,
+ });
+ }
+
+ const npmInstallation = () => wrapper.find(NpmInstallation);
+ const mavenInstallation = () => wrapper.find(MavenInstallation);
+ const conanInstallation = () => wrapper.find(ConanInstallation);
+ const nugetInstallation = () => wrapper.find(NugetInstallation);
+ const pypiInstallation = () => wrapper.find(PypiInstallation);
+ const composerInstallation = () => wrapper.find(ComposerInstallation);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('installation instructions', () => {
+ describe.each`
+ packageEntity | selector
+ ${conanPackage} | ${conanInstallation}
+ ${mavenPackage} | ${mavenInstallation}
+ ${npmPackage} | ${npmInstallation}
+ ${nugetPackage} | ${nugetInstallation}
+ ${pypiPackage} | ${pypiInstallation}
+ ${composerPackage} | ${composerInstallation}
+ `('renders', ({ packageEntity, selector }) => {
+ it(`${packageEntity.package_type} instructions exist`, () => {
+ createComponent({ packageEntity });
+
+ expect(selector()).toExist();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js
new file mode 100644
index 00000000000..5d0007294b6
--- /dev/null
+++ b/spec/frontend/packages/details/components/maven_installation_spec.js
@@ -0,0 +1,91 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { registryUrl as mavenPath } from 'jest/packages/details/mock_data';
+import { mavenPackage as packageEntity } from 'jest/packages/mock_data';
+import MavenInstallation from '~/packages/details/components/maven_installation.vue';
+import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import { TrackingActions } from '~/packages/details/constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('MavenInstallation', () => {
+ let wrapper;
+
+ const xmlCodeBlock = 'foo/xml';
+ const mavenCommandStr = 'foo/command';
+ const mavenSetupXml = 'foo/setup';
+
+ const store = new Vuex.Store({
+ state: {
+ packageEntity,
+ mavenPath,
+ },
+ getters: {
+ mavenInstallationXml: () => xmlCodeBlock,
+ mavenInstallationCommand: () => mavenCommandStr,
+ mavenSetupXml: () => mavenSetupXml,
+ },
+ });
+
+ const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+
+ function createComponent() {
+ wrapper = shallowMount(MavenInstallation, {
+ localVue,
+ store,
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct xml block', () => {
+ expect(
+ findCodeInstructions()
+ .at(0)
+ .props(),
+ ).toMatchObject({
+ instruction: xmlCodeBlock,
+ multiline: true,
+ trackingAction: TrackingActions.COPY_MAVEN_XML,
+ });
+ });
+
+ it('renders the correct maven command', () => {
+ expect(
+ findCodeInstructions()
+ .at(1)
+ .props(),
+ ).toMatchObject({
+ instruction: mavenCommandStr,
+ multiline: false,
+ trackingAction: TrackingActions.COPY_MAVEN_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct xml block', () => {
+ expect(
+ findCodeInstructions()
+ .at(2)
+ .props(),
+ ).toMatchObject({
+ instruction: mavenSetupXml,
+ multiline: true,
+ trackingAction: TrackingActions.COPY_MAVEN_SETUP,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js
new file mode 100644
index 00000000000..f47bac57a66
--- /dev/null
+++ b/spec/frontend/packages/details/components/npm_installation_spec.js
@@ -0,0 +1,99 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { npmPackage as packageEntity } from 'jest/packages/mock_data';
+import { registryUrl as nugetPath } from 'jest/packages/details/mock_data';
+import NpmInstallation from '~/packages/details/components/npm_installation.vue';
+import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import { TrackingActions } from '~/packages/details/constants';
+import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('NpmInstallation', () => {
+ let wrapper;
+
+ const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+
+ function createComponent() {
+ const store = new Vuex.Store({
+ state: {
+ packageEntity,
+ nugetPath,
+ },
+ getters: {
+ npmInstallationCommand,
+ npmSetupCommand,
+ },
+ });
+
+ wrapper = shallowMount(NpmInstallation, {
+ localVue,
+ store,
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct npm command', () => {
+ expect(
+ findCodeInstructions()
+ .at(0)
+ .props(),
+ ).toMatchObject({
+ instruction: 'npm i @Test/package',
+ multiline: false,
+ trackingAction: TrackingActions.COPY_NPM_INSTALL_COMMAND,
+ });
+ });
+
+ it('renders the correct yarn command', () => {
+ expect(
+ findCodeInstructions()
+ .at(1)
+ .props(),
+ ).toMatchObject({
+ instruction: 'yarn add @Test/package',
+ multiline: false,
+ trackingAction: TrackingActions.COPY_YARN_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct npm command', () => {
+ expect(
+ findCodeInstructions()
+ .at(2)
+ .props(),
+ ).toMatchObject({
+ instruction: 'echo @Test:registry=undefined >> .npmrc',
+ multiline: false,
+ trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND,
+ });
+ });
+
+ it('renders the correct yarn command', () => {
+ expect(
+ findCodeInstructions()
+ .at(3)
+ .props(),
+ ).toMatchObject({
+ instruction: 'echo \\"@Test:registry\\" \\"undefined\\" >> .yarnrc',
+ multiline: false,
+ trackingAction: TrackingActions.COPY_YARN_SETUP_COMMAND,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js
new file mode 100644
index 00000000000..a23bf9a18a1
--- /dev/null
+++ b/spec/frontend/packages/details/components/nuget_installation_spec.js
@@ -0,0 +1,75 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nugetPackage as packageEntity } from 'jest/packages/mock_data';
+import { registryUrl as nugetPath } from 'jest/packages/details/mock_data';
+import NugetInstallation from '~/packages/details/components/nuget_installation.vue';
+import CodeInstructions from '~/packages/details/components/code_instruction.vue';
+import { TrackingActions } from '~/packages/details/constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('NugetInstallation', () => {
+ let wrapper;
+
+ const nugetInstallationCommandStr = 'foo/command';
+ const nugetSetupCommandStr = 'foo/setup';
+
+ const store = new Vuex.Store({
+ state: {
+ packageEntity,
+ nugetPath,
+ },
+ getters: {
+ nugetInstallationCommand: () => nugetInstallationCommandStr,
+ nugetSetupCommand: () => nugetSetupCommandStr,
+ },
+ });
+
+ const findCodeInstructions = () => wrapper.findAll(CodeInstructions);
+
+ function createComponent() {
+ wrapper = shallowMount(NugetInstallation, {
+ localVue,
+ store,
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct command', () => {
+ expect(
+ findCodeInstructions()
+ .at(0)
+ .props(),
+ ).toMatchObject({
+ instruction: nugetInstallationCommandStr,
+ trackingAction: TrackingActions.COPY_NUGET_INSTALL_COMMAND,
+ });
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct command', () => {
+ expect(
+ findCodeInstructions()
+ .at(1)
+ .props(),
+ ).toMatchObject({
+ instruction: nugetSetupCommandStr,
+ trackingAction: TrackingActions.COPY_NUGET_SETUP_COMMAND,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages/details/components/package_history_spec.js
new file mode 100644
index 00000000000..e293e119585
--- /dev/null
+++ b/spec/frontend/packages/details/components/package_history_spec.js
@@ -0,0 +1,106 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import component from '~/packages/details/components/package_history.vue';
+
+import { mavenPackage, mockPipelineInfo } from '../../mock_data';
+
+describe('Package History', () => {
+ let wrapper;
+ const defaultProps = {
+ projectName: 'baz project',
+ packageEntity: { ...mavenPackage },
+ };
+
+ const mountComponent = props => {
+ wrapper = shallowMount(component, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ HistoryElement: '<div data-testid="history-element"><slot></slot></div>',
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findHistoryElement = testId => wrapper.find(`[data-testid="${testId}"]`);
+ const findElementLink = container => container.find(GlLink);
+ const findElementTimeAgo = container => container.find(TimeAgoTooltip);
+ const findTitle = () => wrapper.find('[data-testid="title"]');
+ const findTimeline = () => wrapper.find('[data-testid="timeline"]');
+
+ it('has the correct title', () => {
+ mountComponent();
+
+ const title = findTitle();
+
+ expect(title.exists()).toBe(true);
+ expect(title.text()).toBe('History');
+ });
+
+ it('has a timeline container', () => {
+ mountComponent();
+
+ const title = findTimeline();
+
+ expect(title.exists()).toBe(true);
+ expect(title.classes()).toEqual(
+ expect.arrayContaining(['timeline', 'main-notes-list', 'notes']),
+ );
+ });
+
+ describe.each`
+ name | icon | text | timeAgoTooltip | link
+ ${'created-on'} | ${'clock'} | ${'Test package version 1.0.0 was created'} | ${mavenPackage.created_at} | ${null}
+ ${'updated-at'} | ${'pencil'} | ${'Test package version 1.0.0 was updated'} | ${mavenPackage.updated_at} | ${null}
+ ${'commit'} | ${'commit'} | ${'Commit sha-baz on branch branch-name'} | ${null} | ${mockPipelineInfo.project.commit_url}
+ ${'pipeline'} | ${'pipeline'} | ${'Pipeline #1 triggered by foo'} | ${mockPipelineInfo.created_at} | ${mockPipelineInfo.project.pipeline_url}
+ ${'published'} | ${'package'} | ${'Published to the baz project Package Registry'} | ${mavenPackage.created_at} | ${null}
+ `('history element $name', ({ name, icon, text, timeAgoTooltip, link }) => {
+ let element;
+
+ beforeEach(() => {
+ mountComponent({ packageEntity: { ...mavenPackage, pipeline: mockPipelineInfo } });
+ element = findHistoryElement(name);
+ });
+
+ it('has the correct icon', () => {
+ expect(element.props('icon')).toBe(icon);
+ });
+
+ it('has the correct text', () => {
+ expect(element.text()).toBe(text);
+ });
+
+ it('time-ago tooltip', () => {
+ const timeAgo = findElementTimeAgo(element);
+ const exist = Boolean(timeAgoTooltip);
+
+ expect(timeAgo.exists()).toBe(exist);
+ if (exist) {
+ expect(timeAgo.props('time')).toBe(timeAgoTooltip);
+ }
+ });
+
+ it('link', () => {
+ const linkElement = findElementLink(element);
+ const exist = Boolean(link);
+
+ expect(linkElement.exists()).toBe(exist);
+ if (exist) {
+ expect(linkElement.attributes('href')).toBe(link);
+ }
+ });
+ });
+
+ describe('when pipelineInfo is missing', () => {
+ it.each(['commit', 'pipeline'])('%s history element is hidden', name => {
+ mountComponent();
+ expect(findHistoryElement(name).exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js
new file mode 100644
index 00000000000..a30dc4b8aba
--- /dev/null
+++ b/spec/frontend/packages/details/components/package_title_spec.js
@@ -0,0 +1,168 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import PackageTitle from '~/packages/details/components/package_title.vue';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import {
+ conanPackage,
+ mavenFiles,
+ mavenPackage,
+ mockTags,
+ npmFiles,
+ npmPackage,
+ nugetPackage,
+} from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('PackageTitle', () => {
+ let wrapper;
+ let store;
+
+ function createComponent({
+ packageEntity = mavenPackage,
+ packageFiles = mavenFiles,
+ icon = null,
+ } = {}) {
+ store = new Vuex.Store({
+ state: {
+ packageEntity,
+ packageFiles,
+ },
+ getters: {
+ packageTypeDisplay: ({ packageEntity: { package_type: type } }) => type,
+ packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline,
+ packageIcon: () => icon,
+ },
+ });
+
+ wrapper = shallowMount(PackageTitle, {
+ localVue,
+ store,
+ });
+ }
+
+ const packageIcon = () => wrapper.find('[data-testid="package-icon"]');
+ const packageType = () => wrapper.find('[data-testid="package-type"]');
+ const packageSize = () => wrapper.find('[data-testid="package-size"]');
+ const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]');
+ const packageRef = () => wrapper.find('[data-testid="package-ref"]');
+ const packageTags = () => wrapper.find(PackageTags);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('renders', () => {
+ it('without tags', () => {
+ createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('with tags', () => {
+ createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('package icon', () => {
+ const fakeSrc = 'a-fake-src';
+
+ it('shows an icon when provided one from vuex', () => {
+ createComponent({ icon: fakeSrc });
+
+ expect(packageIcon().exists()).toBe(true);
+ });
+
+ it('has the correct src attribute', () => {
+ createComponent({ icon: fakeSrc });
+
+ expect(packageIcon().props('src')).toBe(fakeSrc);
+ });
+
+ it('does not show an icon when not provided one', () => {
+ createComponent();
+
+ expect(packageIcon().exists()).toBe(false);
+ });
+ });
+
+ describe.each`
+ packageEntity | expectedResult
+ ${conanPackage} | ${'conan'}
+ ${mavenPackage} | ${'maven'}
+ ${npmPackage} | ${'npm'}
+ ${nugetPackage} | ${'nuget'}
+ `(`package type`, ({ packageEntity, expectedResult }) => {
+ beforeEach(() => createComponent({ packageEntity }));
+
+ it(`${packageEntity.package_type} should render from Vuex getters ${expectedResult}`, () => {
+ expect(packageType().text()).toBe(expectedResult);
+ });
+ });
+
+ describe('calculates the package size', () => {
+ it('correctly calulates when there is only 1 file', () => {
+ createComponent({ packageEntity: npmPackage, packageFiles: npmFiles });
+
+ expect(packageSize().text()).toBe('200 bytes');
+ });
+
+ it('correctly calulates when there are multiple files', () => {
+ createComponent();
+
+ expect(packageSize().text()).toBe('300 bytes');
+ });
+ });
+
+ describe('package tags', () => {
+ it('displays the package-tags component when the package has tags', () => {
+ createComponent({
+ packageEntity: {
+ ...npmPackage,
+ tags: mockTags,
+ },
+ });
+
+ expect(packageTags().exists()).toBe(true);
+ });
+
+ it('does not display the package-tags component when there are no tags', () => {
+ createComponent();
+
+ expect(packageTags().exists()).toBe(false);
+ });
+ });
+
+ describe('package ref', () => {
+ it('does not display the ref if missing', () => {
+ createComponent();
+
+ expect(packageRef().exists()).toBe(false);
+ });
+
+ it('correctly shows the package ref if there is one', () => {
+ createComponent({ packageEntity: npmPackage });
+
+ expect(packageRef().contains('gl-icon-stub')).toBe(true);
+ expect(packageRef().text()).toBe(npmPackage.pipeline.ref);
+ });
+ });
+
+ describe('pipeline project', () => {
+ it('does not display the project if missing', () => {
+ createComponent();
+
+ expect(pipelineProject().exists()).toBe(false);
+ });
+
+ it('correctly shows the pipeline project if there is one', () => {
+ createComponent({ packageEntity: npmPackage });
+
+ expect(pipelineProject().text()).toBe(npmPackage.pipeline.project.name);
+ expect(pipelineProject().attributes('href')).toBe(npmPackage.pipeline.project.web_url);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js
new file mode 100644
index 00000000000..da30b4ba565
--- /dev/null
+++ b/spec/frontend/packages/details/components/pypi_installation_spec.js
@@ -0,0 +1,60 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { pypiPackage as packageEntity } from 'jest/packages/mock_data';
+import PypiInstallation from '~/packages/details/components/pypi_installation.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('PypiInstallation', () => {
+ let wrapper;
+
+ const pipCommandStr = 'pip install';
+ const pypiSetupStr = 'python setup';
+
+ const store = new Vuex.Store({
+ state: {
+ packageEntity,
+ pypiHelpPath: 'foo',
+ },
+ getters: {
+ pypiPipCommand: () => pipCommandStr,
+ pypiSetupCommand: () => pypiSetupStr,
+ },
+ });
+
+ const pipCommand = () => wrapper.find('[data-testid="pip-command"]');
+ const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]');
+
+ function createComponent() {
+ wrapper = shallowMount(PypiInstallation, {
+ localVue,
+ store,
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders all the messages', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('installation commands', () => {
+ it('renders the correct pip command', () => {
+ expect(pipCommand().props('instruction')).toBe(pipCommandStr);
+ });
+ });
+
+ describe('setup commands', () => {
+ it('renders the correct setup block', () => {
+ expect(setupInstruction().props('instruction')).toBe(pypiSetupStr);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/mock_data.js b/spec/frontend/packages/details/mock_data.js
new file mode 100644
index 00000000000..d43abcedb2e
--- /dev/null
+++ b/spec/frontend/packages/details/mock_data.js
@@ -0,0 +1,47 @@
+export const registryUrl = 'foo/registry';
+
+export const mavenMetadata = {
+ app_group: 'com.test.package.app',
+ app_name: 'test-package-app',
+ app_version: '1.0.0',
+};
+
+export const generateMavenCommand = ({
+ app_group: appGroup = '',
+ app_name: appName = '',
+ app_version: appVersion = '',
+}) => `mvn dependency:get -Dartifact=${appGroup}:${appName}:${appVersion}`;
+
+export const generateXmlCodeBlock = ({
+ app_group: appGroup = '',
+ app_name: appName = '',
+ app_version: appVersion = '',
+}) => `<dependency>
+ <groupId>${appGroup}</groupId>
+ <artifactId>${appName}</artifactId>
+ <version>${appVersion}</version>
+</dependency>`;
+
+export const generateMavenSetupXml = () => `<repositories>
+ <repository>
+ <id>gitlab-maven</id>
+ <url>${registryUrl}</url>
+ </repository>
+</repositories>
+
+<distributionManagement>
+ <repository>
+ <id>gitlab-maven</id>
+ <url>${registryUrl}</url>
+ </repository>
+
+ <snapshotRepository>
+ <id>gitlab-maven</id>
+ <url>${registryUrl}</url>
+ </snapshotRepository>
+</distributionManagement>`;
+
+export const pypiSetupCommandStr = `[gitlab]
+repository = foo
+username = __token__
+password = <your personal access token>`;
diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js
new file mode 100644
index 00000000000..6dfb2b63f85
--- /dev/null
+++ b/spec/frontend/packages/details/store/actions_spec.js
@@ -0,0 +1,76 @@
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import fetchPackageVersions from '~/packages/details/store/actions';
+import * as types from '~/packages/details/store/mutation_types';
+import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants';
+import { npmPackage as packageEntity } from '../../mock_data';
+
+jest.mock('~/flash.js');
+jest.mock('~/api.js');
+
+describe('Actions Package details store', () => {
+ describe('fetchPackageVersions', () => {
+ it('should fetch the package versions', done => {
+ Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity });
+
+ testAction(
+ fetchPackageVersions,
+ undefined,
+ { packageEntity },
+ [
+ { type: types.SET_LOADING, payload: true },
+ { type: types.SET_PACKAGE_VERSIONS, payload: packageEntity.versions },
+ { type: types.SET_LOADING, payload: false },
+ ],
+ [],
+ () => {
+ expect(Api.projectPackage).toHaveBeenCalledWith(
+ packageEntity.project_id,
+ packageEntity.id,
+ );
+ done();
+ },
+ );
+ });
+
+ it("does not set the versions if they don't exist", done => {
+ Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } });
+
+ testAction(
+ fetchPackageVersions,
+ undefined,
+ { packageEntity },
+ [{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }],
+ [],
+ () => {
+ expect(Api.projectPackage).toHaveBeenCalledWith(
+ packageEntity.project_id,
+ packageEntity.id,
+ );
+ done();
+ },
+ );
+ });
+
+ it('should create flash on API error', done => {
+ Api.projectPackage = jest.fn().mockRejectedValue();
+
+ testAction(
+ fetchPackageVersions,
+ undefined,
+ { packageEntity },
+ [{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }],
+ [],
+ () => {
+ expect(Api.projectPackage).toHaveBeenCalledWith(
+ packageEntity.project_id,
+ packageEntity.id,
+ );
+ expect(createFlash).toHaveBeenCalledWith(FETCH_PACKAGE_VERSIONS_ERROR);
+ done();
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js
new file mode 100644
index 00000000000..307976d4124
--- /dev/null
+++ b/spec/frontend/packages/details/store/getters_spec.js
@@ -0,0 +1,237 @@
+import {
+ conanInstallationCommand,
+ conanSetupCommand,
+ packagePipeline,
+ packageTypeDisplay,
+ packageIcon,
+ mavenInstallationXml,
+ mavenInstallationCommand,
+ mavenSetupXml,
+ npmInstallationCommand,
+ npmSetupCommand,
+ nugetInstallationCommand,
+ nugetSetupCommand,
+ pypiPipCommand,
+ pypiSetupCommand,
+ composerRegistryInclude,
+ composerPackageInclude,
+} from '~/packages/details/store/getters';
+import {
+ conanPackage,
+ npmPackage,
+ nugetPackage,
+ mockPipelineInfo,
+ mavenPackage as packageWithoutBuildInfo,
+ pypiPackage,
+} from '../../mock_data';
+import {
+ generateMavenCommand,
+ generateXmlCodeBlock,
+ generateMavenSetupXml,
+ registryUrl,
+ pypiSetupCommandStr,
+} from '../mock_data';
+import { generateConanRecipe } from '~/packages/details/utils';
+import { NpmManager } from '~/packages/details/constants';
+
+describe('Getters PackageDetails Store', () => {
+ let state;
+
+ const defaultState = {
+ packageEntity: packageWithoutBuildInfo,
+ conanPath: registryUrl,
+ mavenPath: registryUrl,
+ npmPath: registryUrl,
+ nugetPath: registryUrl,
+ pypiPath: registryUrl,
+ };
+
+ const setupState = (testState = {}) => {
+ state = {
+ ...defaultState,
+ ...testState,
+ };
+ };
+
+ const recipe = generateConanRecipe(conanPackage);
+ const conanInstallationCommandStr = `conan install ${recipe} --remote=gitlab`;
+ const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`;
+
+ const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum);
+ const mavenInstallationXmlBlock = generateXmlCodeBlock(packageWithoutBuildInfo.maven_metadatum);
+ const mavenSetupXmlBlock = generateMavenSetupXml();
+
+ const npmInstallStr = `npm i ${npmPackage.name}`;
+ const npmSetupStr = `echo @Test:registry=${registryUrl} >> .npmrc`;
+ const yarnInstallStr = `yarn add ${npmPackage.name}`;
+ const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}\\" >> .yarnrc`;
+
+ const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`;
+ const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`;
+
+ const pypiPipCommandStr = `pip install ${pypiPackage.name} --index-url ${registryUrl}`;
+ const composerRegistryIncludeStr = '{"type":"composer","url":"foo"}';
+ const composerPackageIncludeStr = JSON.stringify({
+ [packageWithoutBuildInfo.name]: packageWithoutBuildInfo.version,
+ });
+
+ describe('packagePipeline', () => {
+ it('should return the pipeline info when pipeline exists', () => {
+ setupState({
+ packageEntity: {
+ ...npmPackage,
+ pipeline: mockPipelineInfo,
+ },
+ });
+
+ expect(packagePipeline(state)).toEqual(mockPipelineInfo);
+ });
+
+ it('should return null when build_info does not exist', () => {
+ setupState();
+
+ expect(packagePipeline(state)).toBe(null);
+ });
+ });
+
+ describe('packageTypeDisplay', () => {
+ describe.each`
+ packageEntity | expectedResult
+ ${conanPackage} | ${'Conan'}
+ ${packageWithoutBuildInfo} | ${'Maven'}
+ ${npmPackage} | ${'NPM'}
+ ${nugetPackage} | ${'NuGet'}
+ ${pypiPackage} | ${'PyPi'}
+ `(`package type`, ({ packageEntity, expectedResult }) => {
+ beforeEach(() => setupState({ packageEntity }));
+
+ it(`${packageEntity.package_type} should show as ${expectedResult}`, () => {
+ expect(packageTypeDisplay(state)).toBe(expectedResult);
+ });
+ });
+ });
+
+ describe('packageIcon', () => {
+ describe('nuget packages', () => {
+ it('should return nuget package icon', () => {
+ setupState({ packageEntity: nugetPackage });
+
+ expect(packageIcon(state)).toBe(nugetPackage.nuget_metadatum.icon_url);
+ });
+
+ it('should return null when nuget package does not have an icon', () => {
+ setupState({ packageEntity: { ...nugetPackage, nuget_metadatum: {} } });
+
+ expect(packageIcon(state)).toBe(null);
+ });
+ });
+
+ it('should not find icons for other package types', () => {
+ setupState({ packageEntity: npmPackage });
+
+ expect(packageIcon(state)).toBe(null);
+ });
+ });
+
+ describe('conan string getters', () => {
+ it('gets the correct conanInstallationCommand', () => {
+ setupState({ packageEntity: conanPackage });
+
+ expect(conanInstallationCommand(state)).toBe(conanInstallationCommandStr);
+ });
+
+ it('gets the correct conanSetupCommand', () => {
+ setupState({ packageEntity: conanPackage });
+
+ expect(conanSetupCommand(state)).toBe(conanSetupCommandStr);
+ });
+ });
+
+ describe('maven string getters', () => {
+ it('gets the correct mavenInstallationXml', () => {
+ setupState();
+
+ expect(mavenInstallationXml(state)).toBe(mavenInstallationXmlBlock);
+ });
+
+ it('gets the correct mavenInstallationCommand', () => {
+ setupState();
+
+ expect(mavenInstallationCommand(state)).toBe(mavenCommandStr);
+ });
+
+ it('gets the correct mavenSetupXml', () => {
+ setupState();
+
+ expect(mavenSetupXml(state)).toBe(mavenSetupXmlBlock);
+ });
+ });
+
+ describe('npm string getters', () => {
+ it('gets the correct npmInstallationCommand for NPM', () => {
+ setupState({ packageEntity: npmPackage });
+
+ expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr);
+ });
+
+ it('gets the correct npmSetupCommand for NPM', () => {
+ setupState({ packageEntity: npmPackage });
+
+ expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr);
+ });
+
+ it('gets the correct npmInstallationCommand for Yarn', () => {
+ setupState({ packageEntity: npmPackage });
+
+ expect(npmInstallationCommand(state)(NpmManager.YARN)).toBe(yarnInstallStr);
+ });
+
+ it('gets the correct npmSetupCommand for Yarn', () => {
+ setupState({ packageEntity: npmPackage });
+
+ expect(npmSetupCommand(state)(NpmManager.YARN)).toBe(yarnSetupStr);
+ });
+ });
+
+ describe('nuget string getters', () => {
+ it('gets the correct nugetInstallationCommand', () => {
+ setupState({ packageEntity: nugetPackage });
+
+ expect(nugetInstallationCommand(state)).toBe(nugetInstallationCommandStr);
+ });
+
+ it('gets the correct nugetSetupCommand', () => {
+ setupState({ packageEntity: nugetPackage });
+
+ expect(nugetSetupCommand(state)).toBe(nugetSetupCommandStr);
+ });
+ });
+
+ describe('pypi string getters', () => {
+ it('gets the correct pypiPipCommand', () => {
+ setupState({ packageEntity: pypiPackage });
+
+ expect(pypiPipCommand(state)).toBe(pypiPipCommandStr);
+ });
+
+ it('gets the correct pypiSetupCommand', () => {
+ setupState({ pypiSetupPath: 'foo' });
+
+ expect(pypiSetupCommand(state)).toBe(pypiSetupCommandStr);
+ });
+ });
+
+ describe('composer string getters', () => {
+ it('gets the correct composerRegistryInclude command', () => {
+ setupState({ composerPath: 'foo' });
+
+ expect(composerRegistryInclude(state)).toBe(composerRegistryIncludeStr);
+ });
+
+ it('gets the correct composerPackageInclude command', () => {
+ setupState();
+
+ expect(composerPackageInclude(state)).toBe(composerPackageIncludeStr);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages/details/store/mutations_spec.js
new file mode 100644
index 00000000000..501a56dcdde
--- /dev/null
+++ b/spec/frontend/packages/details/store/mutations_spec.js
@@ -0,0 +1,31 @@
+import mutations from '~/packages/details/store/mutations';
+import * as types from '~/packages/details/store/mutation_types';
+import { npmPackage as packageEntity } from '../../mock_data';
+
+describe('Mutations package details Store', () => {
+ let mockState;
+
+ beforeEach(() => {
+ mockState = {
+ packageEntity,
+ };
+ });
+
+ describe('SET_LOADING', () => {
+ it('should set loading', () => {
+ mutations[types.SET_LOADING](mockState, true);
+
+ expect(mockState.isLoading).toEqual(true);
+ });
+ });
+
+ describe('SET_PACKAGE_VERSIONS', () => {
+ it('should set the package entity versions', () => {
+ const fakeVersions = [1, 2, 3];
+
+ mutations[types.SET_PACKAGE_VERSIONS](mockState, fakeVersions);
+
+ expect(mockState.packageEntity.versions).toEqual(fakeVersions);
+ });
+ });
+});
diff --git a/spec/frontend/packages/details/utils_spec.js b/spec/frontend/packages/details/utils_spec.js
new file mode 100644
index 00000000000..087888016ee
--- /dev/null
+++ b/spec/frontend/packages/details/utils_spec.js
@@ -0,0 +1,24 @@
+import { generateConanRecipe } from '~/packages/details/utils';
+import { conanPackage } from '../mock_data';
+
+describe('Package detail utils', () => {
+ describe('generateConanRecipe', () => {
+ it('correctly generates the conan recipe', () => {
+ const recipe = generateConanRecipe(conanPackage);
+
+ expect(recipe).toEqual(conanPackage.recipe);
+ });
+
+ it('returns an empty recipe when no information is supplied', () => {
+ const recipe = generateConanRecipe({});
+
+ expect(recipe).toEqual('/@/');
+ });
+
+ it('recipe returns empty strings for missing metadata', () => {
+ const recipe = generateConanRecipe({ name: 'foo', version: '0.0.1' });
+
+ expect(recipe).toBe('foo/0.0.1@/');
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/coming_soon/helpers_spec.js b/spec/frontend/packages/list/coming_soon/helpers_spec.js
new file mode 100644
index 00000000000..4a996bfad76
--- /dev/null
+++ b/spec/frontend/packages/list/coming_soon/helpers_spec.js
@@ -0,0 +1,36 @@
+import * as comingSoon from '~/packages/list/coming_soon/helpers';
+import { fakeIssues, asGraphQLResponse, asViewModel } from './mock_data';
+
+jest.mock('~/api.js');
+
+describe('Coming Soon Helpers', () => {
+ const [noLabels, acceptingMergeRequestLabel, workflowLabel] = fakeIssues;
+
+ describe('toViewModel', () => {
+ it('formats a GraphQL response correctly', () => {
+ expect(comingSoon.toViewModel(asGraphQLResponse)).toEqual(asViewModel);
+ });
+ });
+
+ describe('findWorkflowLabel', () => {
+ it('finds a workflow label', () => {
+ expect(comingSoon.findWorkflowLabel(workflowLabel.labels)).toEqual(workflowLabel.labels[0]);
+ });
+
+ it("returns undefined when there isn't one", () => {
+ expect(comingSoon.findWorkflowLabel(noLabels.labels)).toBeUndefined();
+ });
+ });
+
+ describe('findAcceptingContributionsLabel', () => {
+ it('finds the correct label when it exists', () => {
+ expect(comingSoon.findAcceptingContributionsLabel(acceptingMergeRequestLabel.labels)).toEqual(
+ acceptingMergeRequestLabel.labels[0],
+ );
+ });
+
+ it("returns undefined when there isn't one", () => {
+ expect(comingSoon.findAcceptingContributionsLabel(noLabels.labels)).toBeUndefined();
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/coming_soon/mock_data.js b/spec/frontend/packages/list/coming_soon/mock_data.js
new file mode 100644
index 00000000000..bb4568e4bd5
--- /dev/null
+++ b/spec/frontend/packages/list/coming_soon/mock_data.js
@@ -0,0 +1,90 @@
+export const fakeIssues = [
+ {
+ id: 1,
+ iid: 1,
+ title: 'issue one',
+ webUrl: 'foo',
+ },
+ {
+ id: 2,
+ iid: 2,
+ title: 'issue two',
+ labels: [{ title: 'Accepting merge requests', color: '#69d100' }],
+ milestone: {
+ title: '12.10',
+ },
+ webUrl: 'foo',
+ },
+ {
+ id: 3,
+ iid: 3,
+ title: 'issue three',
+ labels: [{ title: 'workflow::In dev', color: '#428bca' }],
+ webUrl: 'foo',
+ },
+ {
+ id: 4,
+ iid: 4,
+ title: 'issue four',
+ labels: [
+ { title: 'Accepting merge requests', color: '#69d100' },
+ { title: 'workflow::In dev', color: '#428bca' },
+ ],
+ webUrl: 'foo',
+ },
+];
+
+export const asGraphQLResponse = {
+ project: {
+ issues: {
+ nodes: fakeIssues.map(x => ({
+ ...x,
+ labels: {
+ nodes: x.labels,
+ },
+ })),
+ },
+ },
+};
+
+export const asViewModel = [
+ {
+ ...fakeIssues[0],
+ labels: [],
+ },
+ {
+ ...fakeIssues[1],
+ labels: [
+ {
+ title: 'Accepting merge requests',
+ color: '#69d100',
+ scoped: false,
+ },
+ ],
+ },
+ {
+ ...fakeIssues[2],
+ labels: [
+ {
+ title: 'workflow::In dev',
+ color: '#428bca',
+ scoped: true,
+ },
+ ],
+ },
+ {
+ ...fakeIssues[3],
+ labels: [
+ {
+ title: 'workflow::In dev',
+ color: '#428bca',
+ scoped: true,
+ },
+ {
+ title: 'Accepting merge requests',
+ color: '#69d100',
+ scoped: false,
+ },
+ ],
+ },
+];
diff --git a/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js
new file mode 100644
index 00000000000..c4cdadc45e6
--- /dev/null
+++ b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js
@@ -0,0 +1,138 @@
+import { GlEmptyState, GlSkeletonLoader, GlLabel } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import VueApollo, { ApolloQuery } from 'vue-apollo';
+import ComingSoon from '~/packages/list/coming_soon/packages_coming_soon.vue';
+import { TrackingActions } from '~/packages/shared/constants';
+import { asViewModel } from './mock_data';
+import Tracking from '~/tracking';
+
+jest.mock('~/packages/list/coming_soon/helpers.js');
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('packages_coming_soon', () => {
+ let wrapper;
+
+ const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
+ const findAllIssues = () => wrapper.findAll('[data-testid="issue-row"]');
+ const findIssuesData = () =>
+ findAllIssues().wrappers.map(x => {
+ const titleLink = x.find('[data-testid="issue-title-link"]');
+ const milestone = x.find('[data-testid="milestone"]');
+ const issueIdLink = x.find('[data-testid="issue-id-link"]');
+ const labels = x.findAll(GlLabel);
+
+ const issueId = Number(issueIdLink.text().substr(1));
+
+ return {
+ id: issueId,
+ iid: issueId,
+ title: titleLink.text(),
+ webUrl: titleLink.attributes('href'),
+ labels: labels.wrappers.map(label => ({
+ color: label.props('backgroundColor'),
+ title: label.props('title'),
+ scoped: label.props('scoped'),
+ })),
+ ...(milestone.exists() ? { milestone: { title: milestone.text() } } : {}),
+ };
+ });
+ const findIssueTitleLink = () => wrapper.find('[data-testid="issue-title-link"]');
+ const findIssueIdLink = () => wrapper.find('[data-testid="issue-id-link"]');
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+
+ const mountComponent = (testParams = {}) => {
+ const $apolloData = {
+ loading: testParams.isLoading || false,
+ };
+
+ wrapper = mount(ComingSoon, {
+ localVue,
+ propsData: {
+ illustration: 'foo',
+ projectPath: 'foo',
+ suggestedContributionsPath: 'foo',
+ },
+ stubs: {
+ ApolloQuery,
+ GlLink: true,
+ },
+ mocks: {
+ $apolloData,
+ },
+ });
+
+ // Mock the GraphQL query result
+ wrapper.find(ApolloQuery).setData({
+ result: {
+ data: testParams.issues || asViewModel,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when loading', () => {
+ beforeEach(() => mountComponent({ isLoading: true }));
+
+ it('renders the skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+ });
+
+ describe('when there are no issues', () => {
+ beforeEach(() => mountComponent({ issues: [] }));
+
+ it('renders the empty state', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ });
+ });
+
+ describe('when there are issues', () => {
+ beforeEach(() => mountComponent());
+
+ it('renders each issue', () => {
+ expect(findIssuesData()).toEqual(asViewModel);
+ });
+ });
+
+ describe('tracking', () => {
+ const firstIssue = asViewModel[0];
+ let eventSpy;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ mountComponent();
+ });
+
+ it('tracks when mounted', () => {
+ expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_REQUESTED, {});
+ });
+
+ it('tracks when an issue title link is clicked', () => {
+ eventSpy.mockClear();
+
+ findIssueTitleLink().vm.$emit('click');
+
+ expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, {
+ label: firstIssue.title,
+ value: firstIssue.iid,
+ });
+ });
+
+ it('tracks when an issue id link is clicked', () => {
+ eventSpy.mockClear();
+
+ findIssueIdLink().vm.$emit('click');
+
+ expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, {
+ label: firstIssue.title,
+ value: firstIssue.iid,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap
new file mode 100644
index 00000000000..ed77f25916f
--- /dev/null
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`packages_filter renders 1`] = `
+<gl-search-box-by-click-stub
+ clearable="true"
+ clearbuttontitle="Clear"
+ clearrecentsearchestext="Clear recent searches"
+ closebuttontitle="Close"
+ norecentsearchestext="You don't have any recent searches"
+ placeholder="Filter by name"
+ recentsearchesheader="Recent searches"
+ value=""
+/>
+`;
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
new file mode 100644
index 00000000000..2b7a4c83bed
--- /dev/null
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -0,0 +1,457 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`packages_list_app renders 1`] = `
+<b-tabs-stub
+ activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
+ class="gl-tabs"
+ contentclass=",gl-tab-content"
+ navclass="gl-tabs-nav"
+ nofade="true"
+ nonavstyle="true"
+ tag="div"
+>
+ <template>
+
+ <b-tab-stub
+ tag="div"
+ title="All"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Composer"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Composer packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no Composer packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Conan"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Conan packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no Conan packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="Maven"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no Maven packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no Maven packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="NPM"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no NPM packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no NPM packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="NuGet"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no NuGet packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no NuGet packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+ <b-tab-stub
+ tag="div"
+ title="PyPi"
+ titlelinkclass="gl-tab-nav-item"
+ >
+ <template>
+ <div>
+ <section
+ class="row empty-state text-center"
+ >
+ <div
+ class="col-12"
+ >
+ <div
+ class="svg-250 svg-content"
+ >
+ <img
+ alt="There are no PyPi packages yet"
+ class="gl-max-w-full"
+ src="helpSvg"
+ />
+ </div>
+ </div>
+
+ <div
+ class="col-12"
+ >
+ <div
+ class="text-content gl-mx-auto gl-my-0 gl-p-5"
+ >
+ <h1
+ class="h4"
+ >
+ There are no PyPi packages yet
+ </h1>
+
+ <p>
+ Learn how to
+ <b-link-stub
+ class="gl-link"
+ event="click"
+ href="helpUrl"
+ routertag="a"
+ target="_blank"
+ >
+ publish and share your packages
+ </b-link-stub>
+ with GitLab.
+ </p>
+
+ <div>
+ <!---->
+
+ <!---->
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </b-tab-stub>
+
+ <!---->
+ </template>
+ <template>
+ <div
+ class="d-flex align-self-center ml-md-auto py-1 py-md-0"
+ >
+ <package-filter-stub
+ class="mr-1"
+ />
+
+ <package-sort-stub />
+ </div>
+ </template>
+</b-tabs-stub>
+`;
diff --git a/spec/frontend/packages/list/components/packages_filter_spec.js b/spec/frontend/packages/list/components/packages_filter_spec.js
new file mode 100644
index 00000000000..b186b5f5e48
--- /dev/null
+++ b/spec/frontend/packages/list/components/packages_filter_spec.js
@@ -0,0 +1,50 @@
+import Vuex from 'vuex';
+import { GlSearchBoxByClick } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import PackagesFilter from '~/packages/list/components/packages_filter.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('packages_filter', () => {
+ let wrapper;
+ let store;
+
+ const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick);
+
+ const mountComponent = () => {
+ store = new Vuex.Store();
+ store.dispatch = jest.fn();
+
+ wrapper = shallowMount(PackagesFilter, {
+ localVue,
+ store,
+ });
+ };
+
+ beforeEach(mountComponent);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('emits events', () => {
+ it('sets the filter value in the store on input', () => {
+ const searchString = 'foo';
+ findGlSearchBox().vm.$emit('input', searchString);
+
+ expect(store.dispatch).toHaveBeenCalledWith('setFilter', searchString);
+ });
+
+ it('emits the filter event when search box is submitted', () => {
+ findGlSearchBox().vm.$emit('submit');
+
+ expect(wrapper.emitted('filter')).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js
new file mode 100644
index 00000000000..31bab3886c1
--- /dev/null
+++ b/spec/frontend/packages/list/components/packages_list_app_spec.js
@@ -0,0 +1,148 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui';
+import PackageListApp from '~/packages/list/components/packages_list_app.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('packages_list_app', () => {
+ let wrapper;
+ let store;
+
+ const PackageList = {
+ name: 'package-list',
+ template: '<div><slot name="empty-state"></slot></div>',
+ };
+ const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' };
+
+ const emptyListHelpUrl = 'helpUrl';
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+ const findListComponent = () => wrapper.find(PackageList);
+ const findTabComponent = (index = 0) => wrapper.findAll(GlTab).at(index);
+
+ const createStore = (filterQuery = '') => {
+ store = new Vuex.Store({
+ state: {
+ isLoading: false,
+ config: {
+ resourceId: 'project_id',
+ emptyListIllustration: 'helpSvg',
+ emptyListHelpUrl,
+ },
+ filterQuery,
+ },
+ });
+ store.dispatch = jest.fn();
+ };
+
+ const mountComponent = () => {
+ wrapper = shallowMount(PackageListApp, {
+ localVue,
+ store,
+ stubs: {
+ GlEmptyState,
+ GlLoadingIcon,
+ PackageList,
+ GlTab,
+ GlTabs,
+ GlSprintf,
+ GlLink,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createStore();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders', () => {
+ mountComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('empty state', () => {
+ it('generate the correct empty list link', () => {
+ mountComponent();
+
+ const link = findListComponent().find(GlLink);
+
+ expect(link.attributes('href')).toBe(emptyListHelpUrl);
+ expect(link.text()).toBe('publish and share your packages');
+ });
+
+ it('includes the right content on the default tab', () => {
+ mountComponent();
+
+ const heading = findEmptyState().find('h1');
+
+ expect(heading.text()).toBe('There are no packages yet');
+ });
+ });
+
+ it('call requestPackagesList on page:changed', () => {
+ mountComponent();
+
+ const list = findListComponent();
+ list.vm.$emit('page:changed', 1);
+ expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 });
+ });
+
+ it('call requestDeletePackage on package:delete', () => {
+ mountComponent();
+
+ const list = findListComponent();
+ list.vm.$emit('package:delete', 'foo');
+ expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo');
+ });
+
+ it('calls requestPackagesList on sort:changed', () => {
+ mountComponent();
+
+ const list = findListComponent();
+ list.vm.$emit('sort:changed');
+ expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
+ });
+
+ it('does not call requestPackagesList two times on render', () => {
+ mountComponent();
+
+ expect(store.dispatch).toHaveBeenCalledTimes(1);
+ });
+
+ describe('tab change', () => {
+ it('calls requestPackagesList when all tab is clicked', () => {
+ mountComponent();
+
+ findTabComponent().trigger('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
+ });
+
+ it('calls requestPackagesList when a package type tab is clicked', () => {
+ mountComponent();
+
+ findTabComponent(1).trigger('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
+ });
+ });
+
+ describe('filter without results', () => {
+ beforeEach(() => {
+ createStore('foo');
+ mountComponent();
+ });
+
+ it('should show specific empty message', () => {
+ expect(findEmptyState().text()).toContain('Sorry, your filter produced no results');
+ expect(findEmptyState().text()).toContain(
+ 'To widen your search, change or remove the filters above',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages/list/components/packages_list_spec.js
new file mode 100644
index 00000000000..a90d5056212
--- /dev/null
+++ b/spec/frontend/packages/list/components/packages_list_spec.js
@@ -0,0 +1,219 @@
+import Vuex from 'vuex';
+import { last } from 'lodash';
+import { GlTable, GlPagination, GlModal } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import stubChildren from 'helpers/stub_children';
+import Tracking from '~/tracking';
+import PackagesList from '~/packages/list/components/packages_list.vue';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
+import * as SharedUtils from '~/packages/shared/utils';
+import { TrackingActions } from '~/packages/shared/constants';
+import { packageList } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('packages_list', () => {
+ let wrapper;
+ let store;
+
+ const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' };
+ const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' };
+
+ const findPackagesListLoader = () => wrapper.find(PackagesListLoader);
+ const findPackageListPagination = () => wrapper.find(GlPagination);
+ const findPackageListDeleteModal = () => wrapper.find(GlModal);
+ const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' });
+ const findPackagesListRow = () => wrapper.find(PackagesListRow);
+
+ const createStore = (isGroupPage, packages, isLoading) => {
+ const state = {
+ isLoading,
+ packages,
+ pagination: {
+ perPage: 1,
+ total: 1,
+ page: 1,
+ },
+ config: {
+ isGroupPage,
+ },
+ sorting: {
+ orderBy: 'version',
+ sort: 'desc',
+ },
+ };
+ store = new Vuex.Store({
+ state,
+ getters: {
+ getList: () => packages,
+ },
+ });
+ store.dispatch = jest.fn();
+ };
+
+ const mountComponent = ({
+ isGroupPage = false,
+ packages = packageList,
+ isLoading = false,
+ ...options
+ } = {}) => {
+ createStore(isGroupPage, packages, isLoading);
+
+ wrapper = mount(PackagesList, {
+ localVue,
+ store,
+ stubs: {
+ ...stubChildren(PackagesList),
+ GlTable,
+ GlSortingItem,
+ GlModal,
+ },
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when is loading', () => {
+ beforeEach(() => {
+ mountComponent({
+ packages: [],
+ isLoading: true,
+ });
+ });
+
+ it('shows skeleton loader when loading', () => {
+ expect(findPackagesListLoader().exists()).toBe(true);
+ });
+ });
+
+ describe('when is not loading', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('does not show skeleton loader when not loading', () => {
+ expect(findPackagesListLoader().exists()).toBe(false);
+ });
+ });
+
+ describe('layout', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('contains a pagination component', () => {
+ const sorting = findPackageListPagination();
+ expect(sorting.exists()).toBe(true);
+ });
+
+ it('contains a modal component', () => {
+ const sorting = findPackageListDeleteModal();
+ expect(sorting.exists()).toBe(true);
+ });
+ });
+
+ describe('when the user can destroy the package', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => {
+ const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show');
+ const item = last(wrapper.vm.list);
+
+ findPackagesListRow().vm.$emit('packageToDelete', item);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.vm.itemToBeDeleted).toEqual(item);
+ expect(mockModalShow).toHaveBeenCalled();
+ });
+ });
+
+ it('deleteItemConfirmation resets itemToBeDeleted', () => {
+ wrapper.setData({ itemToBeDeleted: 1 });
+ wrapper.vm.deleteItemConfirmation();
+ expect(wrapper.vm.itemToBeDeleted).toEqual(null);
+ });
+
+ it('deleteItemConfirmation emit package:delete', () => {
+ const itemToBeDeleted = { id: 2 };
+ wrapper.setData({ itemToBeDeleted });
+ wrapper.vm.deleteItemConfirmation();
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]);
+ });
+ });
+
+ it('deleteItemCanceled resets itemToBeDeleted', () => {
+ wrapper.setData({ itemToBeDeleted: 1 });
+ wrapper.vm.deleteItemCanceled();
+ expect(wrapper.vm.itemToBeDeleted).toEqual(null);
+ });
+ });
+
+ describe('when the list is empty', () => {
+ beforeEach(() => {
+ mountComponent({
+ packages: [],
+ slots: {
+ 'empty-state': EmptySlotStub,
+ },
+ });
+ });
+
+ it('show the empty slot', () => {
+ const emptySlot = findEmptySlot();
+ expect(emptySlot.exists()).toBe(true);
+ });
+ });
+
+ describe('pagination component', () => {
+ let pagination;
+ let modelEvent;
+
+ beforeEach(() => {
+ mountComponent();
+ pagination = findPackageListPagination();
+ // retrieve the event used by v-model, a more sturdy approach than hardcoding it
+ modelEvent = pagination.vm.$options.model.event;
+ });
+
+ it('emits page:changed events when the page changes', () => {
+ pagination.vm.$emit(modelEvent, 2);
+ expect(wrapper.emitted('page:changed')).toEqual([[2]]);
+ });
+ });
+
+ describe('tracking', () => {
+ let eventSpy;
+ let utilSpy;
+ const category = 'foo';
+
+ beforeEach(() => {
+ mountComponent();
+ eventSpy = jest.spyOn(Tracking, 'event');
+ utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
+ wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } });
+ });
+
+ it('tracking category calls packageTypeToTrackCategory', () => {
+ expect(wrapper.vm.tracking.category).toBe(category);
+ expect(utilSpy).toHaveBeenCalledWith('conan');
+ });
+
+ it('deleteItemConfirmation calls event', () => {
+ wrapper.vm.deleteItemConfirmation();
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ TrackingActions.DELETE_PACKAGE,
+ expect.any(Object),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/components/packages_sort_spec.js b/spec/frontend/packages/list/components/packages_sort_spec.js
new file mode 100644
index 00000000000..ff3e8e19413
--- /dev/null
+++ b/spec/frontend/packages/list/components/packages_sort_spec.js
@@ -0,0 +1,92 @@
+import Vuex from 'vuex';
+import { GlSorting } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import stubChildren from 'helpers/stub_children';
+import PackagesSort from '~/packages/list/components/packages_sort.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('packages_sort', () => {
+ let wrapper;
+ let store;
+ let sorting;
+ let sortingItems;
+
+ const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' };
+
+ const findPackageListSorting = () => wrapper.find(GlSorting);
+ const findSortingItems = () => wrapper.findAll(GlSortingItem);
+
+ const createStore = isGroupPage => {
+ const state = {
+ config: {
+ isGroupPage,
+ },
+ sorting: {
+ orderBy: 'version',
+ sort: 'desc',
+ },
+ };
+ store = new Vuex.Store({
+ state,
+ });
+ store.dispatch = jest.fn();
+ };
+
+ const mountComponent = (isGroupPage = false) => {
+ createStore(isGroupPage);
+
+ wrapper = mount(PackagesSort, {
+ localVue,
+ store,
+ stubs: {
+ ...stubChildren(PackagesSort),
+ GlSortingItem,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when is in projects', () => {
+ beforeEach(() => {
+ mountComponent();
+ sorting = findPackageListSorting();
+ sortingItems = findSortingItems();
+ });
+
+ it('has all the sortable items', () => {
+ expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length);
+ });
+
+ it('on sort change set sorting in vuex and emit event', () => {
+ sorting.vm.$emit('sortDirectionChange');
+ expect(store.dispatch).toHaveBeenCalledWith('setSorting', { sort: 'asc' });
+ expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ });
+
+ it('on sort item click set sorting and emit event', () => {
+ const item = sortingItems.at(0);
+ const { orderBy } = wrapper.vm.sortableFields[0];
+ item.vm.$emit('click');
+ expect(store.dispatch).toHaveBeenCalledWith('setSorting', { orderBy });
+ expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ });
+ });
+
+ describe('when is in group', () => {
+ beforeEach(() => {
+ mountComponent(true);
+ sorting = findPackageListSorting();
+ sortingItems = findSortingItems();
+ });
+
+ it('has all the sortable items', () => {
+ expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length);
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js
new file mode 100644
index 00000000000..faa629cc01f
--- /dev/null
+++ b/spec/frontend/packages/list/stores/actions_spec.js
@@ -0,0 +1,240 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import * as actions from '~/packages/list/stores/actions';
+import * as types from '~/packages/list/stores/mutation_types';
+import { MISSING_DELETE_PATH_ERROR, DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/list/constants';
+
+jest.mock('~/flash.js');
+jest.mock('~/api.js');
+
+describe('Actions Package list store', () => {
+ const headers = 'bar';
+ let mock;
+
+ beforeEach(() => {
+ Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo', headers });
+ Api.groupPackages = jest.fn().mockResolvedValue({ data: 'baz', headers });
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('requestPackagesList', () => {
+ const sorting = {
+ sort: 'asc',
+ orderBy: 'version',
+ };
+ it('should fetch the project packages list when isGroupPage is false', done => {
+ testAction(
+ actions.requestPackagesList,
+ undefined,
+ { config: { isGroupPage: false, resourceId: 1 }, sorting },
+ [],
+ [
+ { type: 'setLoading', payload: true },
+ { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } },
+ { type: 'setLoading', payload: false },
+ ],
+ () => {
+ expect(Api.projectPackages).toHaveBeenCalledWith(1, {
+ params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy },
+ });
+ done();
+ },
+ );
+ });
+
+ it('should fetch the group packages list when isGroupPage is true', done => {
+ testAction(
+ actions.requestPackagesList,
+ undefined,
+ { config: { isGroupPage: true, resourceId: 2 }, sorting },
+ [],
+ [
+ { type: 'setLoading', payload: true },
+ { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } },
+ { type: 'setLoading', payload: false },
+ ],
+ () => {
+ expect(Api.groupPackages).toHaveBeenCalledWith(2, {
+ params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy },
+ });
+ done();
+ },
+ );
+ });
+
+ it('should fetch packages of a certain type when selectedType is present', done => {
+ const packageType = 'maven';
+
+ testAction(
+ actions.requestPackagesList,
+ undefined,
+ {
+ config: { isGroupPage: false, resourceId: 1 },
+ sorting,
+ selectedType: { type: packageType },
+ },
+ [],
+ [
+ { type: 'setLoading', payload: true },
+ { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } },
+ { type: 'setLoading', payload: false },
+ ],
+ () => {
+ expect(Api.projectPackages).toHaveBeenCalledWith(1, {
+ params: {
+ page: 1,
+ per_page: 20,
+ sort: sorting.sort,
+ order_by: sorting.orderBy,
+ package_type: packageType,
+ },
+ });
+ done();
+ },
+ );
+ });
+
+ it('should create flash on API error', done => {
+ Api.projectPackages = jest.fn().mockRejectedValue();
+ testAction(
+ actions.requestPackagesList,
+ undefined,
+ { config: { isGroupPage: false, resourceId: 2 }, sorting },
+ [],
+ [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }],
+ () => {
+ expect(createFlash).toHaveBeenCalled();
+ done();
+ },
+ );
+ });
+ });
+
+ describe('receivePackagesListSuccess', () => {
+ it('should set received packages', done => {
+ const data = 'foo';
+
+ testAction(
+ actions.receivePackagesListSuccess,
+ { data, headers },
+ null,
+ [
+ { type: types.SET_PACKAGE_LIST_SUCCESS, payload: data },
+ { type: types.SET_PAGINATION, payload: headers },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setInitialState', () => {
+ it('should commit setInitialState', done => {
+ testAction(
+ actions.setInitialState,
+ '1',
+ null,
+ [{ type: types.SET_INITIAL_STATE, payload: '1' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setLoading', () => {
+ it('should commit set main loading', done => {
+ testAction(
+ actions.setLoading,
+ true,
+ null,
+ [{ type: types.SET_MAIN_LOADING, payload: true }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestDeletePackage', () => {
+ const payload = {
+ _links: {
+ delete_api_path: 'foo',
+ },
+ };
+ it('should perform a delete operation on _links.delete_api_path', done => {
+ mock.onDelete(payload._links.delete_api_path).replyOnce(200);
+ Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' });
+
+ testAction(
+ actions.requestDeletePackage,
+ payload,
+ { pagination: { page: 1 } },
+ [],
+ [
+ { type: 'setLoading', payload: true },
+ { type: 'requestPackagesList', payload: { page: 1 } },
+ ],
+ done,
+ );
+ });
+
+ it('should stop the loading and call create flash on api error', done => {
+ mock.onDelete(payload._links.delete_api_path).replyOnce(400);
+ testAction(
+ actions.requestDeletePackage,
+ payload,
+ null,
+ [],
+ [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }],
+ () => {
+ expect(createFlash).toHaveBeenCalled();
+ done();
+ },
+ );
+ });
+
+ it.each`
+ property | actionPayload
+ ${'_links'} | ${{}}
+ ${'delete_api_path'} | ${{ _links: {} }}
+ `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => {
+ testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch(e => {
+ expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR));
+ expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE);
+ done();
+ });
+ });
+ });
+
+ describe('setSorting', () => {
+ it('should commit SET_SORTING', done => {
+ testAction(
+ actions.setSorting,
+ 'foo',
+ null,
+ [{ type: types.SET_SORTING, payload: 'foo' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setFilter', () => {
+ it('should commit SET_FILTER', done => {
+ testAction(
+ actions.setFilter,
+ 'foo',
+ null,
+ [{ type: types.SET_FILTER, payload: 'foo' }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/stores/getters_spec.js b/spec/frontend/packages/list/stores/getters_spec.js
new file mode 100644
index 00000000000..080bbc21d9f
--- /dev/null
+++ b/spec/frontend/packages/list/stores/getters_spec.js
@@ -0,0 +1,36 @@
+import getList from '~/packages/list/stores/getters';
+import { packageList } from '../../mock_data';
+
+describe('Getters registry list store', () => {
+ let state;
+
+ const setState = ({ isGroupPage = false } = {}) => {
+ state = {
+ packages: packageList,
+ config: {
+ isGroupPage,
+ },
+ };
+ };
+
+ beforeEach(() => setState());
+
+ afterEach(() => {
+ state = null;
+ });
+
+ describe('getList', () => {
+ it('returns a list of packages', () => {
+ const result = getList(state);
+
+ expect(result).toHaveLength(packageList.length);
+ expect(result[0].name).toBe('Test package');
+ });
+
+ it('adds projectPathName', () => {
+ const result = getList(state);
+
+ expect(result[0].projectPathName).toMatchInlineSnapshot(`"foo / bar / baz"`);
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/stores/mutations_spec.js b/spec/frontend/packages/list/stores/mutations_spec.js
new file mode 100644
index 00000000000..563a3dabbb3
--- /dev/null
+++ b/spec/frontend/packages/list/stores/mutations_spec.js
@@ -0,0 +1,95 @@
+import mutations from '~/packages/list/stores/mutations';
+import * as types from '~/packages/list/stores/mutation_types';
+import createState from '~/packages/list/stores/state';
+import * as commonUtils from '~/lib/utils/common_utils';
+import { npmPackage, mavenPackage } from '../../mock_data';
+
+describe('Mutations Registry Store', () => {
+ let mockState;
+ beforeEach(() => {
+ mockState = createState();
+ });
+
+ describe('SET_INITIAL_STATE', () => {
+ it('should set the initial state', () => {
+ const config = {
+ resourceId: '1',
+ pageType: 'groups',
+ userCanDelete: '',
+ emptyListIllustration: 'foo',
+ emptyListHelpUrl: 'baz',
+ comingSoonJson: '{ "project_path": "gitlab-org/gitlab-test" }',
+ };
+
+ const expectedState = {
+ ...mockState,
+ config: {
+ ...config,
+ isGroupPage: true,
+ canDestroyPackage: true,
+ },
+ };
+ mutations[types.SET_INITIAL_STATE](mockState, config);
+
+ expect(mockState.projectId).toEqual(expectedState.projectId);
+ });
+ });
+
+ describe('SET_PACKAGE_LIST_SUCCESS', () => {
+ it('should set a packages list', () => {
+ const payload = [npmPackage, mavenPackage];
+ const expectedState = { ...mockState, packages: payload };
+ mutations[types.SET_PACKAGE_LIST_SUCCESS](mockState, payload);
+
+ expect(mockState.packages).toEqual(expectedState.packages);
+ });
+ });
+
+ describe('SET_MAIN_LOADING', () => {
+ it('should set main loading', () => {
+ mutations[types.SET_MAIN_LOADING](mockState, true);
+
+ expect(mockState.isLoading).toEqual(true);
+ });
+ });
+
+ describe('SET_PAGINATION', () => {
+ const mockPagination = { perPage: 10, page: 1 };
+ beforeEach(() => {
+ commonUtils.normalizeHeaders = jest.fn().mockReturnValue('baz');
+ commonUtils.parseIntPagination = jest.fn().mockReturnValue(mockPagination);
+ });
+ it('should set a parsed pagination', () => {
+ mutations[types.SET_PAGINATION](mockState, 'foo');
+ expect(commonUtils.normalizeHeaders).toHaveBeenCalledWith('foo');
+ expect(commonUtils.parseIntPagination).toHaveBeenCalledWith('baz');
+ expect(mockState.pagination).toEqual(mockPagination);
+ });
+ });
+
+ describe('SET_SORTING', () => {
+ it('should merge the sorting object with sort value', () => {
+ mutations[types.SET_SORTING](mockState, { sort: 'desc' });
+ expect(mockState.sorting).toEqual({ ...mockState.sorting, sort: 'desc' });
+ });
+
+ it('should merge the sorting object with order_by value', () => {
+ mutations[types.SET_SORTING](mockState, { orderBy: 'foo' });
+ expect(mockState.sorting).toEqual({ ...mockState.sorting, orderBy: 'foo' });
+ });
+ });
+
+ describe('SET_SELECTED_TYPE', () => {
+ it('should set the selected type', () => {
+ mutations[types.SET_SELECTED_TYPE](mockState, { type: 'maven' });
+ expect(mockState.selectedType).toEqual({ type: 'maven' });
+ });
+ });
+
+ describe('SET_FILTER', () => {
+ it('should set the filter query', () => {
+ mutations[types.SET_FILTER](mockState, 'foo');
+ expect(mockState.filterQuery).toEqual('foo');
+ });
+ });
+});
diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages/list/utils_spec.js
new file mode 100644
index 00000000000..5bcc3784752
--- /dev/null
+++ b/spec/frontend/packages/list/utils_spec.js
@@ -0,0 +1,39 @@
+import { getNewPaginationPage } from '~/packages/list/utils';
+
+describe('Packages list utils', () => {
+ describe('packageTypeDisplay', () => {
+ it('returns the current page when total items exceeds pagniation', () => {
+ expect(getNewPaginationPage(2, 20, 21)).toBe(2);
+ });
+
+ it('returns the previous page when total items is lower than or equal to pagination', () => {
+ expect(getNewPaginationPage(2, 20, 20)).toBe(1);
+ });
+
+ it('returns the first page when totalItems is lower than or equal to perPage', () => {
+ expect(getNewPaginationPage(4, 20, 20)).toBe(1);
+ });
+
+ describe('works when a different perPage is used', () => {
+ it('returns the current page', () => {
+ expect(getNewPaginationPage(2, 10, 11)).toBe(2);
+ });
+
+ it('returns the previous page', () => {
+ expect(getNewPaginationPage(2, 10, 10)).toBe(1);
+ });
+ });
+
+ describe.each`
+ currentPage | totalItems | expectedResult
+ ${1} | ${20} | ${1}
+ ${2} | ${20} | ${1}
+ ${3} | ${40} | ${2}
+ ${4} | ${60} | ${3}
+ `(`works across numerious pages`, ({ currentPage, totalItems, expectedResult }) => {
+ it(`when currentPage is ${currentPage} return to the previous page ${expectedResult}`, () => {
+ expect(getNewPaginationPage(currentPage, 20, totalItems)).toBe(expectedResult);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js
new file mode 100644
index 00000000000..86205b0744c
--- /dev/null
+++ b/spec/frontend/packages/mock_data.js
@@ -0,0 +1,170 @@
+const _links = {
+ web_path: 'foo',
+ delete_api_path: 'bar',
+};
+
+export const mockPipelineInfo = {
+ id: 1,
+ ref: 'branch-name',
+ sha: 'sha-baz',
+ user: {
+ name: 'foo',
+ },
+ project: {
+ name: 'foo-project',
+ web_url: 'foo-project-link',
+ commit_url: 'foo-commit-link',
+ pipeline_url: 'foo-pipeline-link',
+ },
+ created_at: '2015-12-10',
+};
+
+export const mavenPackage = {
+ created_at: '2015-12-10',
+ id: 1,
+ maven_metadatum: {
+ app_group: 'com.test.app',
+ app_name: 'test-app',
+ app_version: '1.0-SNAPSHOT',
+ },
+ name: 'Test package',
+ package_type: 'maven',
+ project_path: 'foo/bar/baz',
+ project_id: 1,
+ updated_at: '2015-12-10',
+ version: '1.0.0',
+ _links,
+};
+
+export const mavenFiles = [
+ {
+ created_at: '2015-12-10',
+ file_name: 'File one',
+ id: 1,
+ size: 100,
+ download_path: '/-/package_files/1/download',
+ },
+ {
+ created_at: '2015-12-10',
+ file_name: 'File two',
+ id: 2,
+ size: 200,
+ download_path: '/-/package_files/2/download',
+ },
+];
+
+export const npmPackage = {
+ created_at: '2015-12-10',
+ id: 2,
+ name: '@Test/package',
+ package_type: 'npm',
+ project_path: 'foo/bar/baz',
+ project_id: 1,
+ updated_at: '2015-12-10',
+ version: '',
+ versions: [],
+ _links,
+ pipeline: mockPipelineInfo,
+};
+
+export const npmFiles = [
+ {
+ created_at: '2015-12-10',
+ file_name: '@test/test-package-1.0.0.tgz',
+ id: 2,
+ size: 200,
+ download_path: '/-/package_files/2/download',
+ },
+];
+
+export const conanPackage = {
+ conan_metadatum: {
+ package_channel: 'stable',
+ package_username: 'conan+conan-package',
+ },
+ created_at: '2015-12-10',
+ id: 3,
+ name: 'conan-package',
+ project_path: 'foo/bar/baz',
+ package_files: [],
+ package_type: 'conan',
+ project_id: 1,
+ recipe: 'conan-package/1.0.0@conan+conan-package/stable',
+ updated_at: '2015-12-10',
+ version: '1.0.0',
+ _links,
+};
+
+export const dependencyLinks = {
+ withoutFramework: { name: 'Moqi', version_pattern: '2.5.6' },
+ withoutVersion: { name: 'Castle.Core', version_pattern: '' },
+ fullLink: {
+ name: 'Test.Dependency',
+ version_pattern: '2.3.7',
+ target_framework: '.NETStandard2.0',
+ },
+ anotherFullLink: {
+ name: 'Newtonsoft.Json',
+ version_pattern: '12.0.3',
+ target_framework: '.NETStandard2.0',
+ },
+};
+
+export const nugetPackage = {
+ created_at: '2015-12-10',
+ id: 4,
+ name: 'NugetPackage1',
+ package_files: [],
+ package_type: 'nuget',
+ project_id: 1,
+ tags: [],
+ updated_at: '2015-12-10',
+ version: '1.0.0',
+ dependency_links: Object.values(dependencyLinks),
+ nuget_metadatum: {
+ icon_url: 'fake-icon',
+ project_url: 'project-foo-url',
+ license_url: 'license-foo-url',
+ },
+};
+
+export const pypiPackage = {
+ created_at: '2015-12-10',
+ id: 5,
+ name: 'PyPiPackage',
+ package_files: [],
+ package_type: 'pypi',
+ project_id: 1,
+ tags: [],
+ updated_at: '2015-12-10',
+ version: '1.0.0',
+};
+
+export const composerPackage = {
+ created_at: '2015-12-10',
+ id: 5,
+ name: 'ComposerPackage',
+ package_files: [],
+ package_type: 'composer',
+ project_id: 1,
+ tags: [],
+ updated_at: '2015-12-10',
+ version: '1.0.0',
+};
+
+export const mockTags = [
+ {
+ name: 'foo-1',
+ },
+ {
+ name: 'foo-2',
+ },
+ {
+ name: 'foo-3',
+ },
+ {
+ name: 'foo-4',
+ },
+];
+
+export const packageList = [mavenPackage, { ...npmPackage, tags: mockTags }, conanPackage];
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
new file mode 100644
index 00000000000..eab8d7b67cc
--- /dev/null
+++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
@@ -0,0 +1,101 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`packages_list_row renders 1`] = `
+<div
+ class="gl-responsive-table-row"
+ data-qa-selector="packages-row"
+>
+ <div
+ class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap"
+ >
+ <div
+ class="d-flex align-items-center mr-2"
+ >
+ <gl-link-stub
+ class="text-dark font-weight-bold mb-md-1"
+ data-qa-selector="package_link"
+ href="foo"
+ >
+
+ Test package
+
+ </gl-link-stub>
+
+ <!---->
+ </div>
+
+ <div
+ class="d-flex text-secondary text-truncate mt-md-2"
+ >
+ <span>
+ 1.0.0
+ </span>
+
+ <!---->
+
+ <div
+ class="d-flex align-items-center"
+ >
+ <gl-icon-stub
+ class="text-secondary ml-2 mr-1"
+ name="review-list"
+ size="16"
+ />
+
+ <gl-link-stub
+ class="text-secondary"
+ data-testid="packages-row-project"
+ href="/foo/bar/baz"
+ >
+
+ </gl-link-stub>
+ </div>
+
+ <div
+ class="d-flex align-items-center"
+ data-testid="package-type"
+ >
+ <gl-icon-stub
+ class="text-secondary ml-2 mr-1"
+ name="package"
+ size="16"
+ />
+
+ <span>
+ Maven
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="table-section d-flex flex-md-column justify-content-between align-items-md-end section-40"
+ >
+ <publish-method-stub
+ packageentity="[object Object]"
+ />
+
+ <div
+ class="text-secondary order-0 order-md-1 mt-md-2"
+ >
+ <gl-sprintf-stub
+ message="Created %{timestamp}"
+ />
+ </div>
+ </div>
+
+ <div
+ class="table-section section-10 d-flex justify-content-end"
+ >
+ <gl-button-stub
+ aria-label="Remove package"
+ category="primary"
+ data-testid="action-delete"
+ icon="remove"
+ size="medium"
+ title="Remove package"
+ variant="danger"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
new file mode 100644
index 00000000000..5ecca63d41d
--- /dev/null
+++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
@@ -0,0 +1,39 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`publish_method renders 1`] = `
+<div
+ class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1"
+>
+ <gl-icon-stub
+ class="mr-1"
+ name="git-merge"
+ size="16"
+ />
+
+ <strong
+ class="mr-1 text-dark"
+ >
+ branch-name
+ </strong>
+
+ <gl-icon-stub
+ class="mr-1"
+ name="commit"
+ size="16"
+ />
+
+ <gl-link-stub
+ class="mr-1"
+ href="../commit/sha-baz"
+ >
+ sha-baz
+ </gl-link-stub>
+
+ <clipboard-button-stub
+ cssclass="border-0 text-secondary py-0 px-1"
+ text="sha-baz"
+ title="Copy commit SHA"
+ tooltipplacement="top"
+ />
+</div>
+`;
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js
new file mode 100644
index 00000000000..c0ae972d519
--- /dev/null
+++ b/spec/frontend/packages/shared/components/package_list_row_spec.js
@@ -0,0 +1,106 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import { packageList } from '../../mock_data';
+
+describe('packages_list_row', () => {
+ let wrapper;
+ let store;
+
+ const [packageWithoutTags, packageWithTags] = packageList;
+
+ const findPackageTags = () => wrapper.find(PackageTags);
+ const findProjectLink = () => wrapper.find('[data-testid="packages-row-project"]');
+ const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]');
+ const findPackageType = () => wrapper.find('[data-testid="package-type"]');
+
+ const mountComponent = ({
+ isGroup = false,
+ packageEntity = packageWithoutTags,
+ shallow = true,
+ showPackageType = true,
+ disableDelete = false,
+ } = {}) => {
+ const mountFunc = shallow ? shallowMount : mount;
+
+ wrapper = mountFunc(PackagesListRow, {
+ store,
+ propsData: {
+ packageLink: 'foo',
+ packageEntity,
+ isGroup,
+ showPackageType,
+ disableDelete,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders', () => {
+ mountComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('tags', () => {
+ it('renders package tags when a package has tags', () => {
+ mountComponent({ isGroup: false, packageEntity: packageWithTags });
+
+ expect(findPackageTags().exists()).toBe(true);
+ });
+
+ it('does not render when there are no tags', () => {
+ mountComponent();
+
+ expect(findPackageTags().exists()).toBe(false);
+ });
+ });
+
+ describe('when is is group', () => {
+ beforeEach(() => {
+ mountComponent({ isGroup: true });
+ });
+
+ it('has project field', () => {
+ expect(findProjectLink().exists()).toBe(true);
+ });
+ });
+
+ describe('showPackageType', () => {
+ it('shows the type when set', () => {
+ mountComponent();
+
+ expect(findPackageType().exists()).toBe(true);
+ });
+
+ it('does not show the type when not set', () => {
+ mountComponent({ showPackageType: false });
+
+ expect(findPackageType().exists()).toBe(false);
+ });
+ });
+
+ describe('deleteAvailable', () => {
+ it('does not show when not set', () => {
+ mountComponent({ disableDelete: true });
+
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+ });
+
+ describe('delete event', () => {
+ beforeEach(() => mountComponent({ packageEntity: packageWithoutTags, shallow: false }));
+
+ it('emits the packageToDelete event when the delete button is clicked', () => {
+ findDeleteButton().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.emitted('packageToDelete')).toBeTruthy();
+ expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages/shared/components/package_tags_spec.js b/spec/frontend/packages/shared/components/package_tags_spec.js
new file mode 100644
index 00000000000..cc49a9a9244
--- /dev/null
+++ b/spec/frontend/packages/shared/components/package_tags_spec.js
@@ -0,0 +1,115 @@
+import { mount } from '@vue/test-utils';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import { mockTags } from '../../mock_data';
+
+describe('PackageTags', () => {
+ let wrapper;
+
+ function createComponent(tags = [], props = {}) {
+ const propsData = {
+ tags,
+ ...props,
+ };
+
+ wrapper = mount(PackageTags, {
+ propsData,
+ });
+ }
+
+ const tagLabel = () => wrapper.find('[data-testid="tagLabel"]');
+ const tagBadges = () => wrapper.findAll('[data-testid="tagBadge"]');
+ const moreBadge = () => wrapper.find('[data-testid="moreBadge"]');
+
+ afterEach(() => {
+ if (wrapper) wrapper.destroy();
+ });
+
+ describe('tag label', () => {
+ it('shows the tag label by default', () => {
+ createComponent();
+
+ expect(tagLabel().exists()).toBe(true);
+ });
+
+ it('hides when hideLabel prop is set to true', () => {
+ createComponent(mockTags, { hideLabel: true });
+
+ expect(tagLabel().exists()).toBe(false);
+ });
+ });
+
+ it('renders the correct number of tags', () => {
+ createComponent(mockTags.slice(0, 2));
+
+ expect(tagBadges()).toHaveLength(2);
+ expect(moreBadge().exists()).toBe(false);
+ });
+
+ it('does not render more than the configured tagDisplayLimit', () => {
+ createComponent(mockTags);
+
+ expect(tagBadges()).toHaveLength(2);
+ });
+
+ it('renders the more tags badge if there are more than the configured limit', () => {
+ createComponent(mockTags);
+
+ expect(tagBadges()).toHaveLength(2);
+ expect(moreBadge().exists()).toBe(true);
+ expect(moreBadge().text()).toContain('2');
+ });
+
+ it('renders the configured tagDisplayLimit when set in props', () => {
+ createComponent(mockTags, { tagDisplayLimit: 1 });
+
+ expect(tagBadges()).toHaveLength(1);
+ expect(moreBadge().exists()).toBe(true);
+ expect(moreBadge().text()).toContain('3');
+ });
+
+ describe('tagBadgeStyle', () => {
+ const defaultStyle = ['badge', 'badge-info', 'gl-display-none'];
+
+ it('shows tag badge when there is only one', () => {
+ createComponent([mockTags[0]]);
+
+ const expectedStyle = [...defaultStyle, 'gl-display-flex', 'gl-ml-3'];
+
+ expect(
+ tagBadges()
+ .at(0)
+ .classes(),
+ ).toEqual(expect.arrayContaining(expectedStyle));
+ });
+
+ it('shows tag badge for medium or heigher resolutions', () => {
+ createComponent(mockTags);
+
+ const expectedStyle = [...defaultStyle, 'd-md-flex'];
+
+ expect(
+ tagBadges()
+ .at(1)
+ .classes(),
+ ).toEqual(expect.arrayContaining(expectedStyle));
+ });
+
+ it('correctly prepends left and appends right when there is more than one tag', () => {
+ createComponent(mockTags, {
+ tagDisplayLimit: 4,
+ });
+
+ const expectedStyleWithoutAppend = [...defaultStyle, 'd-md-flex'];
+ const expectedStyleWithAppend = [...expectedStyleWithoutAppend, 'gl-mr-2'];
+
+ const allBadges = tagBadges();
+
+ expect(allBadges.at(0).classes()).toEqual(
+ expect.arrayContaining([...expectedStyleWithAppend, 'gl-ml-3']),
+ );
+ expect(allBadges.at(1).classes()).toEqual(expect.arrayContaining(expectedStyleWithAppend));
+ expect(allBadges.at(2).classes()).toEqual(expect.arrayContaining(expectedStyleWithAppend));
+ expect(allBadges.at(3).classes()).toEqual(expect.arrayContaining(expectedStyleWithoutAppend));
+ });
+ });
+});
diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages/shared/components/packages_list_loader_spec.js
new file mode 100644
index 00000000000..c8c2e2a4ba4
--- /dev/null
+++ b/spec/frontend/packages/shared/components/packages_list_loader_spec.js
@@ -0,0 +1,42 @@
+import { mount } from '@vue/test-utils';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+
+describe('PackagesListLoader', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(PackagesListLoader, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ const getShapes = () => wrapper.vm.desktopShapes;
+ const findSquareButton = () => wrapper.find({ ref: 'button-loader' });
+
+ beforeEach(createComponent);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when used for projects', () => {
+ it('should return 5 rects with last one being a square', () => {
+ expect(getShapes()).toHaveLength(5);
+ expect(findSquareButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when used for groups', () => {
+ beforeEach(() => {
+ createComponent({ isGroup: true });
+ });
+
+ it('should return 5 rects with no square', () => {
+ expect(getShapes()).toHaveLength(5);
+ expect(findSquareButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages/shared/components/publish_method_spec.js
new file mode 100644
index 00000000000..bb9287c1204
--- /dev/null
+++ b/spec/frontend/packages/shared/components/publish_method_spec.js
@@ -0,0 +1,50 @@
+import { shallowMount } from '@vue/test-utils';
+import PublishMethod from '~/packages/shared/components/publish_method.vue';
+import { packageList } from '../../mock_data';
+
+describe('publish_method', () => {
+ let wrapper;
+
+ const [packageWithoutPipeline, packageWithPipeline] = packageList;
+
+ const findPipelineRef = () => wrapper.find({ ref: 'pipeline-ref' });
+ const findPipelineSha = () => wrapper.find({ ref: 'pipeline-sha' });
+ const findManualPublish = () => wrapper.find({ ref: 'manual-ref' });
+
+ const mountComponent = (packageEntity = {}, isGroup = false) => {
+ wrapper = shallowMount(PublishMethod, {
+ propsData: {
+ packageEntity,
+ isGroup,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders', () => {
+ mountComponent(packageWithPipeline);
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('pipeline information', () => {
+ it('displays branch and commit when pipeline info exists', () => {
+ mountComponent(packageWithPipeline);
+
+ expect(findPipelineRef().exists()).toBe(true);
+ expect(findPipelineSha().exists()).toBe(true);
+ });
+
+ it('does not show any pipeline details when no information exists', () => {
+ mountComponent(packageWithoutPipeline);
+
+ expect(findPipelineRef().exists()).toBe(false);
+ expect(findPipelineSha().exists()).toBe(false);
+ expect(findManualPublish().exists()).toBe(true);
+ expect(findManualPublish().text()).toBe('Manually Published');
+ });
+ });
+});
diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js
new file mode 100644
index 00000000000..1fe90a4827f
--- /dev/null
+++ b/spec/frontend/packages/shared/utils_spec.js
@@ -0,0 +1,66 @@
+import {
+ packageTypeToTrackCategory,
+ beautifyPath,
+ getPackageTypeLabel,
+ getCommitLink,
+} from '~/packages/shared/utils';
+import { PackageType, TrackingCategories } from '~/packages/shared/constants';
+import { packageList } from '../mock_data';
+
+describe('Packages shared utils', () => {
+ describe('packageTypeToTrackCategory', () => {
+ it('prepend UI to package category', () => {
+ expect(packageTypeToTrackCategory()).toMatchInlineSnapshot(`"UI::undefined"`);
+ });
+
+ it.each(Object.keys(PackageType))('returns a correct category string for %s', packageKey => {
+ const packageName = PackageType[packageKey];
+ expect(packageTypeToTrackCategory(packageName)).toBe(
+ `UI::${TrackingCategories[packageName]}`,
+ );
+ });
+ });
+
+ describe('beautifyPath', () => {
+ it('returns a string with spaces around /', () => {
+ expect(beautifyPath('foo/bar')).toBe('foo / bar');
+ });
+ it('does not fail for empty string', () => {
+ expect(beautifyPath()).toBe('');
+ });
+ });
+
+ describe('getPackageTypeLabel', () => {
+ describe.each`
+ packageType | expectedResult
+ ${'conan'} | ${'Conan'}
+ ${'maven'} | ${'Maven'}
+ ${'npm'} | ${'NPM'}
+ ${'nuget'} | ${'NuGet'}
+ ${'pypi'} | ${'PyPi'}
+ ${'composer'} | ${'Composer'}
+ ${'foo'} | ${null}
+ `(`package type`, ({ packageType, expectedResult }) => {
+ it(`${packageType} should show as ${expectedResult}`, () => {
+ expect(getPackageTypeLabel(packageType)).toBe(expectedResult);
+ });
+ });
+ });
+
+ describe('getCommitLink', () => {
+ it('returns a relative link when isGroup is false', () => {
+ const link = getCommitLink(packageList[0], false);
+
+ expect(link).toContain('../commit');
+ });
+
+ describe('when isGroup is true', () => {
+ it('returns an absolute link matching project path', () => {
+ const mavenPackage = packageList[0];
+ const link = getCommitLink(mavenPackage, true);
+
+ expect(link).toContain(`/${mavenPackage.project_path}/commit`);
+ });
+ });
+ });
+});