diff options
Diffstat (limited to 'spec/frontend/packages/details')
28 files changed, 2439 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@/'); + }); + }); +}); |