diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-20 18:42:06 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-20 18:42:06 +0000 |
commit | 6e4e1050d9dba2b7b2523fdd1768823ab85feef4 (patch) | |
tree | 78be5963ec075d80116a932011d695dd33910b4e /spec/frontend/packages | |
parent | 1ce776de4ae122aba3f349c02c17cebeaa8ecf07 (diff) | |
download | gitlab-ce-6e4e1050d9dba2b7b2523fdd1768823ab85feef4.tar.gz |
Add latest changes from gitlab-org/gitlab@13-3-stable-ee
Diffstat (limited to 'spec/frontend/packages')
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`); + }); + }); + }); +}); |