diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
commit | 7e9c479f7de77702622631cff2628a9c8dcbc627 (patch) | |
tree | c8f718a08e110ad7e1894510980d2155a6549197 /spec/frontend/jobs | |
parent | e852b0ae16db4052c1c567d9efa4facc81146e88 (diff) |
Add latest changes from gitlab-org/gitlab@13-6-stable-eev13.6.0-rc42
Diffstat (limited to 'spec/frontend/jobs')
5 files changed, 511 insertions, 117 deletions
diff --git a/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js b/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js new file mode 100644 index 00000000000..08973223c08 --- /dev/null +++ b/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js @@ -0,0 +1,76 @@ +import { GlLink, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue'; +import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants'; +import createStore from '~/jobs/store'; +import job from '../mock_data'; + +describe('Job Retry Forward Deployment Modal', () => { + let store; + let wrapper; + + const retryOutdatedJobDocsUrl = 'url-to-docs'; + const findLink = () => wrapper.find(GlLink); + const findModal = () => wrapper.find(GlModal); + + const createWrapper = ({ props = {}, provide = {}, stubs = {} } = {}) => { + store = createStore(); + wrapper = shallowMount(JobRetryForwardDeploymentModal, { + propsData: { + modalId: 'modal-id', + href: job.retry_path, + ...props, + }, + provide, + store, + stubs, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(createWrapper); + + describe('Modal configuration', () => { + it('should display the correct messages', () => { + const modal = findModal(); + expect(modal.attributes('title')).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.title); + expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.info); + expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.areYouSure); + }); + }); + + describe('Modal docs help link', () => { + it('should not display an info link when none is provided', () => { + createWrapper(); + + expect(findLink().exists()).toBe(false); + }); + + it('should display an info link when one is provided', () => { + createWrapper({ provide: { retryOutdatedJobDocsUrl } }); + + expect(findLink().attributes('href')).toBe(retryOutdatedJobDocsUrl); + expect(findLink().text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.moreInfo); + }); + }); + + describe('Modal actions', () => { + beforeEach(createWrapper); + + it('should correctly configure the primary action', () => { + expect(findModal().props('actionPrimary').attributes).toMatchObject([ + { + 'data-method': 'post', + href: job.retry_path, + variant: 'danger', + }, + ]); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js new file mode 100644 index 00000000000..be684769b46 --- /dev/null +++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import SidebarJobDetailsContainer from '~/jobs/components/sidebar_job_details_container.vue'; +import DetailRow from '~/jobs/components/sidebar_detail_row.vue'; +import createStore from '~/jobs/store'; +import { extendedWrapper } from '../../helpers/vue_test_utils_helper'; +import job from '../mock_data'; + +describe('Job Sidebar Details Container', () => { + let store; + let wrapper; + + const findJobTimeout = () => wrapper.findByTestId('job-timeout'); + const findJobTags = () => wrapper.findByTestId('job-tags'); + const findAllDetailsRow = () => wrapper.findAll(DetailRow); + + const createWrapper = ({ props = {} } = {}) => { + store = createStore(); + wrapper = extendedWrapper( + shallowMount(SidebarJobDetailsContainer, { + propsData: props, + store, + stubs: { + DetailRow, + }, + }), + ); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('when no details are available', () => { + it('should render an empty container', () => { + createWrapper(); + + expect(wrapper.isEmpty()).toBe(true); + }); + }); + + describe('when some of the details are available', () => { + beforeEach(createWrapper); + + it.each([ + ['duration', 'Duration: 6 seconds'], + ['erased_at', 'Erased: 3 weeks ago'], + ['finished_at', 'Finished: 3 weeks ago'], + ['queued', 'Queued: 9 seconds'], + ['runner', 'Runner: local ci runner (#1)'], + ['coverage', 'Coverage: 20%'], + ])('uses %s to render job-%s', async (detail, value) => { + await store.dispatch('receiveJobSuccess', { [detail]: job[detail] }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.isEmpty()).toBe(false); + expect(detailsRow).toHaveLength(1); + expect(detailsRow.at(0).text()).toBe(value); + }); + + it('only renders tags', async () => { + const { tags } = job; + await store.dispatch('receiveJobSuccess', { tags }); + const tagsComponent = findJobTags(); + + expect(wrapper.isEmpty()).toBe(false); + expect(tagsComponent.text()).toBe('Tags: tag'); + }); + }); + + describe('when all the info are available', () => { + it('renders all the details components', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); + + expect(findAllDetailsRow()).toHaveLength(7); + }); + }); + + describe('timeout', () => { + const { + metadata: { timeout_human_readable, timeout_source }, + } = job; + + beforeEach(createWrapper); + + it('does not render if metadata is empty', async () => { + const metadata = {}; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.isEmpty()).toBe(true); + expect(detailsRow.exists()).toBe(false); + }); + + it('uses metadata to render timeout', async () => { + const metadata = { timeout_human_readable }; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.isEmpty()).toBe(false); + expect(detailsRow).toHaveLength(1); + expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s'); + }); + + it('uses metadata to render timeout and the source', async () => { + const metadata = { timeout_human_readable, timeout_source }; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)'); + }); + + it('should not render when no time is provided', async () => { + const metadata = { timeout_source }; + await store.dispatch('receiveJobSuccess', { metadata }); + + expect(findJobTimeout().exists()).toBe(false); + }); + + it('should pass the help URL', async () => { + const helpUrl = 'fakeUrl'; + const props = { runnerHelpUrl: helpUrl }; + createWrapper({ props }); + await store.dispatch('receiveJobSuccess', { metadata: { timeout_human_readable } }); + + expect(findJobTimeout().props('helpUrl')).toBe(helpUrl); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js new file mode 100644 index 00000000000..4bf697ab7cc --- /dev/null +++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js @@ -0,0 +1,70 @@ +import { GlButton, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import job from '../mock_data'; +import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; +import createStore from '~/jobs/store'; + +describe('Job Sidebar Retry Button', () => { + let store; + let wrapper; + + const forwardDeploymentFailure = 'forward_deployment_failure'; + const findRetryButton = () => wrapper.find(GlButton); + const findRetryLink = () => wrapper.find(GlLink); + + const createWrapper = ({ props = {} } = {}) => { + store = createStore(); + wrapper = shallowMount(JobsSidebarRetryButton, { + propsData: { + href: job.retry_path, + modalId: 'modal-id', + ...props, + }, + store, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(createWrapper); + + it.each([ + [null, false, true], + ['unmet_prerequisites', false, true], + [forwardDeploymentFailure, true, false], + ])( + 'when error is: %s, should render button: %s | should render link: %s', + async (failureReason, buttonExists, linkExists) => { + await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason }); + + expect(findRetryButton().exists()).toBe(buttonExists); + expect(findRetryLink().exists()).toBe(linkExists); + expect(wrapper.text()).toMatch('Retry'); + }, + ); + + describe('Button', () => { + it('should have the correct configuration', async () => { + await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure }); + + expect(findRetryButton().attributes()).toMatchObject({ + category: 'primary', + variant: 'info', + }); + }); + }); + + describe('Link', () => { + it('should have the correct configuration', () => { + expect(findRetryLink().attributes()).toMatchObject({ + 'data-method': 'post', + href: job.retry_path, + }); + }); + }); +}); diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index c2412a807c3..314b23ec29b 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -2,21 +2,26 @@ import { shallowMount } from '@vue/test-utils'; import Line from '~/jobs/components/log/line.vue'; import LineNumber from '~/jobs/components/log/line_number.vue'; +const httpUrl = 'http://example.com'; +const httpsUrl = 'https://example.com'; + +const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({ + line: { + content: [ + { + text, + style: 'term-fg-l-green', + }, + ], + lineNumber: 0, + }, + path: '/jashkenas/underscore/-/jobs/335', +}); + describe('Job Log Line', () => { let wrapper; - - const data = { - line: { - content: [ - { - text: 'Running with gitlab-runner 12.1.0 (de7731dd)', - style: 'term-fg-l-green', - }, - ], - lineNumber: 0, - }, - path: '/jashkenas/underscore/-/jobs/335', - }; + let data; + let originalGon; const createComponent = (props = {}) => { wrapper = shallowMount(Line, { @@ -26,12 +31,25 @@ describe('Job Log Line', () => { }); }; + const findLine = () => wrapper.find('span'); + const findLink = () => findLine().find('a'); + const findLinksAt = i => + findLine() + .findAll('a') + .at(i); + beforeEach(() => { + originalGon = window.gon; + window.gon.features = { + ciJobLineLinks: false, + }; + + data = mockProps(); createComponent(data); }); afterEach(() => { - wrapper.destroy(); + window.gon = originalGon; }); it('renders the line number component', () => { @@ -39,10 +57,109 @@ describe('Job Log Line', () => { }); it('renders a span the provided text', () => { - expect(wrapper.find('span').text()).toBe(data.line.content[0].text); + expect(findLine().text()).toBe(data.line.content[0].text); }); it('renders the provided style as a class attribute', () => { - expect(wrapper.find('span').classes()).toContain(data.line.content[0].style); + expect(findLine().classes()).toContain(data.line.content[0].style); + }); + + describe.each([true, false])('when feature ci_job_line_links enabled = %p', ciJobLineLinks => { + beforeEach(() => { + window.gon.features = { + ciJobLineLinks, + }; + }); + + it('renders text with symbols', () => { + const text = 'apt-get update < /dev/null > /dev/null'; + createComponent(mockProps({ text })); + + expect(findLine().text()).toBe(text); + }); + + it.each` + tag | text + ${'a'} | ${'<a href="#">linked</a>'} + ${'script'} | ${'<script>doEvil();</script>'} + ${'strong'} | ${'<strong>highlighted</strong>'} + `('escapes `<$tag>` tags in text', ({ tag, text }) => { + createComponent(mockProps({ text })); + + expect( + findLine() + .find(tag) + .exists(), + ).toBe(false); + expect(findLine().text()).toBe(text); + }); + }); + + describe('when ci_job_line_links is enabled', () => { + beforeEach(() => { + window.gon.features = { + ciJobLineLinks: true, + }; + }); + + it('renders an http link', () => { + createComponent(mockProps({ text: httpUrl })); + + expect(findLink().text()).toBe(httpUrl); + expect(findLink().attributes().href).toBe(httpUrl); + }); + + it('renders an https link', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().text()).toBe(httpsUrl); + expect(findLink().attributes().href).toBe(httpsUrl); + }); + + it('renders a multiple links surrounded by text', () => { + createComponent(mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` })); + expect(findLine().text()).toBe( + 'My HTTP url: http://example.com and my HTTPS url: https://example.com', + ); + expect(findLinksAt(0).attributes().href).toBe(httpUrl); + expect(findLinksAt(1).attributes().href).toBe(httpsUrl); + }); + + it('renders a link with rel nofollow and noopener', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().attributes().rel).toBe('nofollow noopener noreferrer'); + }); + + it('renders a link with corresponding styles', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().classes()).toEqual(['gl-reset-color!', 'gl-text-decoration-underline']); + }); + + it('render links surrounded by text', () => { + createComponent( + mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl} are here.` }), + ); + expect(findLine().text()).toBe( + 'My HTTP url: http://example.com and my HTTPS url: https://example.com are here.', + ); + expect(findLinksAt(0).attributes().href).toBe(httpUrl); + expect(findLinksAt(1).attributes().href).toBe(httpsUrl); + }); + + const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url + + test.each` + type | text + ${'js'} | ${jshref} + ${'file'} | ${'file:///a-file'} + ${'ftp'} | ${'ftp://example.com/file'} + ${'email'} | ${'email@example.com'} + ${'no scheme'} | ${'example.com/page'} + `('does not render a $type link', ({ text }) => { + createComponent(mockProps({ text })); + expect(findLink().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js index 48788df0c93..1d4be2fb81e 100644 --- a/spec/frontend/jobs/components/sidebar_spec.js +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -1,167 +1,166 @@ -import Vue from 'vue'; -import sidebarDetailsBlock from '~/jobs/components/sidebar.vue'; +import { shallowMount } from '@vue/test-utils'; +import Sidebar, { forwardDeploymentFailureModalId } from '~/jobs/components/sidebar.vue'; +import StagesDropdown from '~/jobs/components/stages_dropdown.vue'; +import JobsContainer from '~/jobs/components/jobs_container.vue'; +import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue'; +import JobRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; import createStore from '~/jobs/store'; import job, { jobsInStage } from '../mock_data'; -import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { trimText } from '../../helpers/text_helper'; +import { extendedWrapper } from '../../helpers/vue_test_utils_helper'; describe('Sidebar details block', () => { - const SidebarComponent = Vue.extend(sidebarDetailsBlock); - let vm; let store; + let wrapper; - beforeEach(() => { + const forwardDeploymentFailure = 'forward_deployment_failure'; + const findModal = () => wrapper.find(JobRetryForwardDeploymentModal); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); + const findRetryButton = () => wrapper.find(JobRetryButton); + const findTerminalLink = () => wrapper.findByTestId('terminal-link'); + + const createWrapper = ({ props = {} } = {}) => { store = createStore(); - }); + wrapper = extendedWrapper( + shallowMount(Sidebar, { + ...props, + store, + }), + ); + }; afterEach(() => { - vm.$destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('when there is no retry path retry', () => { - it('should not render a retry button', () => { - const copy = { ...job }; - delete copy.retry_path; - - store.dispatch('receiveJobSuccess', copy); - vm = mountComponentWithStore(SidebarComponent, { - store, - }); + it('should not render a retry button', async () => { + createWrapper(); + const copy = { ...job, retry_path: null }; + await store.dispatch('receiveJobSuccess', copy); - expect(vm.$el.querySelector('.js-retry-button')).toBeNull(); + expect(findRetryButton().exists()).toBe(false); }); }); describe('without terminal path', () => { - it('does not render terminal link', () => { - store.dispatch('receiveJobSuccess', job); - vm = mountComponentWithStore(SidebarComponent, { store }); + it('does not render terminal link', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); - expect(vm.$el.querySelector('.js-terminal-link')).toBeNull(); + expect(findTerminalLink().exists()).toBe(false); }); }); describe('with terminal path', () => { - it('renders terminal link', () => { - store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); - vm = mountComponentWithStore(SidebarComponent, { - store, - }); + it('renders terminal link', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); - expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull(); + expect(findTerminalLink().exists()).toBe(true); }); }); - beforeEach(() => { - store.dispatch('receiveJobSuccess', job); - vm = mountComponentWithStore(SidebarComponent, { store }); - }); - describe('actions', () => { - it('should render link to new issue', () => { - expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual( - job.new_issue_path, - ); + beforeEach(() => { + createWrapper(); + return store.dispatch('receiveJobSuccess', job); + }); - expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual( - 'New issue', - ); + it('should render link to new issue', () => { + expect(findNewIssueButton().attributes('href')).toBe(job.new_issue_path); + expect(findNewIssueButton().text()).toBe('New issue'); }); - it('should render link to retry job', () => { - expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path); + it('should render the retry button', () => { + expect(findRetryButton().props('href')).toBe(job.retry_path); }); it('should render link to cancel job', () => { - expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path); + expect(findCancelButton().text()).toMatch('Cancel'); + expect(findCancelButton().attributes('href')).toBe(job.cancel_path); }); }); - describe('information', () => { - it('should render job duration', () => { - expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual( - 'Duration: 6 seconds', - ); - }); - - it('should render erased date', () => { - expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual( - 'Erased: 3 weeks ago', - ); - }); - - it('should render finished date', () => { - expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual( - 'Finished: 3 weeks ago', - ); - }); - - it('should render queued date', () => { - expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual( - 'Queued: 9 seconds', + describe('forward deployment failure', () => { + describe('when the relevant data is missing', () => { + it.each` + retryPath | failureReason + ${null} | ${null} + ${''} | ${''} + ${job.retry_path} | ${''} + ${''} | ${forwardDeploymentFailure} + ${job.retry_path} | ${'unmet_prerequisites'} + `( + 'should not render the modal when path and failure are $retryPath, $failureReason', + async ({ retryPath, failureReason }) => { + createWrapper(); + await store.dispatch('receiveJobSuccess', { + ...job, + failure_reason: failureReason, + retry_path: retryPath, + }); + expect(findModal().exists()).toBe(false); + }, ); }); - it('should render runner ID', () => { - expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual( - 'Runner: local ci runner (#1)', - ); - }); + describe('when there is the relevant error', () => { + beforeEach(() => { + createWrapper(); + return store.dispatch('receiveJobSuccess', { + ...job, + failure_reason: forwardDeploymentFailure, + }); + }); - it('should render timeout information', () => { - expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual( - 'Timeout: 1m 40s (from runner)', - ); - }); + it('should render the modal', () => { + expect(findModal().exists()).toBe(true); + }); - it('should render coverage', () => { - expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual( - 'Coverage: 20%', - ); - }); + it('should provide the modal id to the button and modal', () => { + expect(findRetryButton().props('modalId')).toBe(forwardDeploymentFailureModalId); + expect(findModal().props('modalId')).toBe(forwardDeploymentFailureModalId); + }); - it('should render tags', () => { - expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag'); + it('should provide the retry path to the button and modal', () => { + expect(findRetryButton().props('href')).toBe(job.retry_path); + expect(findModal().props('href')).toBe(job.retry_path); + }); }); }); describe('stages dropdown', () => { beforeEach(() => { - store.dispatch('receiveJobSuccess', job); + createWrapper(); + return store.dispatch('receiveJobSuccess', { ...job, stage: 'aStage' }); }); describe('with stages', () => { - beforeEach(() => { - vm = mountComponentWithStore(SidebarComponent, { store }); - }); - it('renders value provided as selectedStage as selected', () => { - expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual( - vm.selectedStage, - ); + expect(wrapper.find(StagesDropdown).props('selectedStage')).toBe('aStage'); }); }); describe('without jobs for stages', () => { - beforeEach(() => { - store.dispatch('receiveJobSuccess', job); - vm = mountComponentWithStore(SidebarComponent, { store }); - }); + beforeEach(() => store.dispatch('receiveJobSuccess', job)); - it('does not render job container', () => { - expect(vm.$el.querySelector('.js-jobs-container')).toBeNull(); + it('does not render jobs container', () => { + expect(wrapper.find(JobsContainer).exists()).toBe(false); }); }); describe('with jobs for stages', () => { - beforeEach(() => { - store.dispatch('receiveJobSuccess', job); - store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); - vm = mountComponentWithStore(SidebarComponent, { store }); + beforeEach(async () => { + await store.dispatch('receiveJobSuccess', job); + await store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); }); it('renders list of jobs', () => { - expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull(); + expect(wrapper.find(JobsContainer).exists()).toBe(true); }); }); }); |