diff options
author | Phil Hughes <me@iamphill.com> | 2018-08-03 13:24:48 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-08-03 13:24:48 +0000 |
commit | 8a9421429d15a805ef07b8200bcba551fe7314ff (patch) | |
tree | 175bdc21d5f7d0a4f029df3997a4830f8931c338 | |
parent | beda5ca507ba2c5ce1617be4c21d3a3076f25d3e (diff) | |
parent | 94981308a028a1e6c8996701f324d0e0c0339e73 (diff) | |
download | gitlab-ce-8a9421429d15a805ef07b8200bcba551fe7314ff.tar.gz |
Merge branch '45318-junit-FE' into 'master'
Frontend code for "JUnit XML Test Summary In MR widget"
See merge request gitlab-org/gitlab-ce!20936
34 files changed, 1100 insertions, 17 deletions
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue new file mode 100644 index 00000000000..0fc84b4552a --- /dev/null +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -0,0 +1,116 @@ +<script> + import { mapActions, mapGetters, mapState } from 'vuex'; + import { s__ } from '~/locale'; + import { componentNames } from '~/vue_shared/components/reports/issue_body'; + import ReportSection from '~/vue_shared/components/reports/report_section.vue'; + import SummaryRow from '~/vue_shared/components/reports/summary_row.vue'; + import IssuesList from '~/vue_shared/components/reports/issues_list.vue'; + import Modal from './modal.vue'; + import createStore from '../store'; + import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils'; + + export default { + name: 'GroupedTestReportsApp', + store: createStore(), + components: { + ReportSection, + SummaryRow, + IssuesList, + Modal, + }, + props: { + endpoint: { + type: String, + required: true, + }, + }, + componentNames, + computed: { + ...mapState([ + 'reports', + 'isLoading', + 'hasError', + 'summary', + ]), + ...mapState({ + modalTitle: state => state.modal.title || '', + modalData: state => state.modal.data || {}, + }), + ...mapGetters([ + 'summaryStatus', + ]), + groupedSummaryText() { + if (this.isLoading) { + return s__('Reports|Test summary results are being parsed'); + } + + if (this.hasError) { + return s__('Reports|Test summary failed loading results'); + } + + return summaryTextBuilder(s__('Reports|Test summary'), this.summary); + }, + }, + created() { + this.setEndpoint(this.endpoint); + + this.fetchReports(); + }, + methods: { + ...mapActions(['setEndpoint', 'fetchReports']), + reportText(report) { + const summary = report.summary || {}; + return reportTextBuilder(report.name, summary); + }, + getReportIcon(report) { + return statusIcon(report.status); + }, + shouldRenderIssuesList(report) { + return ( + report.existing_failures.length > 0 || + report.new_failures.length > 0 || + report.resolved_failures > 0 + ); + }, + }, + }; +</script> +<template> + <report-section + :status="summaryStatus" + :success-text="groupedSummaryText" + :loading-text="groupedSummaryText" + :error-text="groupedSummaryText" + :has-issues="reports.length > 0" + class="mr-widget-border-top grouped-security-reports" + > + <div + slot="body" + class="mr-widget-grouped-section report-block" + > + <template + v-for="(report, i) in reports" + > + <summary-row + :summary="reportText(report)" + :status-icon="getReportIcon(report)" + :key="`summary-row-${i}`" + /> + <issues-list + v-if="shouldRenderIssuesList(report)" + :unresolved-issues="report.existing_failures" + :new-issues="report.new_failures" + :resolved-issues="report.resolved_failures" + :key="`issues-list-${i}`" + :component="$options.componentNames.TestIssueBody" + class="report-block-group-list" + /> + </template> + + <modal + :title="modalTitle" + :modal-data="modalData" + /> + </div> + </report-section> +</template> diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue new file mode 100644 index 00000000000..b2133714858 --- /dev/null +++ b/app/assets/javascripts/reports/components/modal.vue @@ -0,0 +1,73 @@ +<script> + import Modal from '~/vue_shared/components/gl_modal.vue'; + import LoadingButton from '~/vue_shared/components/loading_button.vue'; + import CodeBlock from '~/vue_shared/components/code_block.vue'; + import { fieldTypes } from '../constants'; + + export default { + components: { + Modal, + LoadingButton, + CodeBlock, + }, + props: { + title: { + type: String, + required: true, + }, + modalData: { + type: Object, + required: true, + }, + }, + fieldTypes, + }; +</script> +<template> + <modal + id="modal-mrwidget-reports" + :header-title-text="title" + class="modal-security-report-dast modal-hide-footer" + > + <slot> + <div + v-for="(field, key, index) in modalData" + v-if="field.value" + :key="index" + class="row prepend-top-10 append-bottom-10" + > + <strong class="col-sm-2 text-right"> + {{ field.text }}: + </strong> + + <div class="col-sm-10 text-secondary"> + <code-block + v-if="field.type === $options.fieldTypes.codeBock" + :code="field.value" + /> + + <template v-else-if="field.type === $options.fieldTypes.link"> + <a + :href="field.value" + target="_blank" + rel="noopener noreferrer" + class="js-modal-link" + > + {{ field.value }} + </a> + </template> + + <template v-else-if="field.type === $options.fieldTypes.miliseconds"> + {{ field.value }} ms + </template> + + <template v-else-if="field.type === $options.fieldTypes.text"> + {{ field.value }} + </template> + </div> + </div> + </slot> + <div slot="footer"> + </div> + </modal> +</template> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue new file mode 100644 index 00000000000..cd443a49b52 --- /dev/null +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -0,0 +1,44 @@ +<script> + import { mapActions } from 'vuex'; + + export default { + name: 'TestIssueBody', + props: { + issue: { + type: Object, + required: true, + }, + // failed || success + status: { + type: String, + required: true, + }, + isNew: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + ...mapActions(['openModal']), + }, + }; +</script> +<template> + <div class="report-block-list-issue-description prepend-top-5 append-bottom-5"> + <div class="report-block-list-issue-description-text"> + <button + type="button" + class="btn-link btn-blank text-left break-link vulnerability-name-button" + @click="openModal({ issue })" + > + <div + v-if="isNew" + class="badge badge-danger append-right-5" + > + {{ s__('New') }} + </div>{{ issue.name }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js new file mode 100644 index 00000000000..807ecb1039e --- /dev/null +++ b/app/assets/javascripts/reports/constants.js @@ -0,0 +1,16 @@ +export const fieldTypes = { + codeBock: 'codeBlock', + link: 'link', + miliseconds: 'miliseconds', + text: 'text', +}; + +export const LOADING = 'LOADING'; +export const ERROR = 'ERROR'; +export const SUCCESS = 'SUCCESS'; + +export const STATUS_FAILED = 'failed'; +export const STATUS_SUCCESS = 'success'; +export const ICON_WARNING = 'warning'; +export const ICON_SUCCESS = 'success'; +export const ICON_NOTFOUND = 'notfound'; diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js index 15c077b0fd8..acabcc1d193 100644 --- a/app/assets/javascripts/reports/store/actions.js +++ b/app/assets/javascripts/reports/store/actions.js @@ -1,7 +1,9 @@ import Visibility from 'visibilityjs'; +import $ from 'jquery'; import axios from '../../lib/utils/axios_utils'; import Poll from '../../lib/utils/poll'; import * as types from './mutation_types'; +import httpStatusCodes from '../../lib/utils/http_status'; export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); @@ -41,12 +43,19 @@ export const fetchReports = ({ state, dispatch }) => { }, data: state.endpoint, method: 'getReports', - successCallback: ({ data }) => dispatch('receiveReportsSuccess', data), + successCallback: ({ data, status }) => dispatch('receiveReportsSuccess', { + data, status, + }), errorCallback: () => dispatch('receiveReportsError'), }); if (!Visibility.hidden()) { eTagPoll.makeRequest(); + } else { + axios + .get(state.endpoint) + .then(({ data, status }) => dispatch('receiveReportsSuccess', { data, status })) + .catch(() => dispatch('receiveReportsError')); } Visibility.change(() => { @@ -58,10 +67,22 @@ export const fetchReports = ({ state, dispatch }) => { }); }; -export const receiveReportsSuccess = ({ commit }, response) => - commit(types.RECEIVE_REPORTS_SUCCESS, response); +export const receiveReportsSuccess = ({ commit }, response) => { + // With 204 we keep polling and don't update the state + if (response.status === httpStatusCodes.OK) { + commit(types.RECEIVE_REPORTS_SUCCESS, response.data); + } +}; export const receiveReportsError = ({ commit }) => commit(types.RECEIVE_REPORTS_ERROR); +export const openModal = ({ dispatch }, payload) => { + dispatch('setModalData', payload); + + $('#modal-mrwidget-reports').modal('show'); +}; + +export const setModalData = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/store/getters.js new file mode 100644 index 00000000000..95266194acb --- /dev/null +++ b/app/assets/javascripts/reports/store/getters.js @@ -0,0 +1,16 @@ +import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../constants'; + +export const summaryStatus = state => { + if (state.isLoading) { + return LOADING; + } + + if (state.hasError || state.status === STATUS_FAILED) { + return ERROR; + } + + return SUCCESS; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/reports/store/index.js b/app/assets/javascripts/reports/store/index.js index af4f9688fb4..9d8f7dc3b74 100644 --- a/app/assets/javascripts/reports/store/index.js +++ b/app/assets/javascripts/reports/store/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import state from './state'; @@ -9,5 +10,6 @@ Vue.use(Vuex); export default () => new Vuex.Store({ actions, mutations, + getters, state: state(), }); diff --git a/app/assets/javascripts/reports/store/mutation_types.js b/app/assets/javascripts/reports/store/mutation_types.js index 77722974c45..82bda31df5d 100644 --- a/app/assets/javascripts/reports/store/mutation_types.js +++ b/app/assets/javascripts/reports/store/mutation_types.js @@ -3,3 +3,5 @@ export const SET_ENDPOINT = 'SET_ENDPOINT'; export const REQUEST_REPORTS = 'REQUEST_REPORTS'; export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR'; +export const SET_ISSUE_MODAL_DATA = 'SET_ISSUE_MODAL_DATA'; + diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index d9d301826cf..e806d120b51 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -16,11 +16,32 @@ export default { state.summary.resolved = response.summary.resolved; state.summary.failed = response.summary.failed; + state.status = response.status; state.reports = response.suites; }, [types.RECEIVE_REPORTS_ERROR](state) { state.isLoading = false; state.hasError = true; + + state.reports = []; + state.summary = { + total: 0, + resolved: 0, + failed: 0, + }; + state.status = null; + }, + [types.SET_ISSUE_MODAL_DATA](state, payload) { + state.modal.title = payload.issue.name; + + Object.keys(payload.issue).forEach((key) => { + if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) { + state.modal.data[key] = { + ...state.modal.data[key], + value: payload.issue[key], + }; + } + }); }, }; diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js index 97f9d0a6859..4cab2e27a16 100644 --- a/app/assets/javascripts/reports/store/state.js +++ b/app/assets/javascripts/reports/store/state.js @@ -1,9 +1,14 @@ +import { s__ } from '~/locale'; +import { fieldTypes } from '../constants'; + export default () => ({ endpoint: null, isLoading: false, hasError: false, + status: null, + summary: { total: 0, resolved: 0, @@ -25,4 +30,32 @@ export default () => ({ * } */ reports: [], + + modal: { + title: null, + + data: { + class: { + value: null, + text: s__('Reports|Class'), + type: fieldTypes.link, + }, + execution_time: { + value: null, + text: s__('Reports|Execution time'), + type: fieldTypes.miliseconds, + }, + failure: { + value: null, + text: s__('Reports|Failure'), + type: fieldTypes.codeBock, + }, + system_output: { + value: null, + text: s__('Reports|System output'), + type: fieldTypes.codeBock, + }, + }, + }, + }); diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js new file mode 100644 index 00000000000..35632218269 --- /dev/null +++ b/app/assets/javascripts/reports/store/utils.js @@ -0,0 +1,59 @@ +import { sprintf, n__, s__ } from '~/locale'; +import { + STATUS_FAILED, + STATUS_SUCCESS, + ICON_WARNING, + ICON_SUCCESS, + ICON_NOTFOUND, +} from '../constants'; + +const textBuilder = results => { + const { failed, resolved, total } = results; + + const failedString = failed + ? n__('%d failed test result', '%d failed test results', failed) + : null; + const resolvedString = resolved + ? n__('%d fixed test result', '%d fixed test results', resolved) + : null; + const totalString = total ? n__('out of %d total test', 'out of %d total tests', total) : null; + + let resultsString = s__('Reports|no changed test results'); + + if (failed) { + if (resolved) { + resultsString = sprintf(s__('Reports|%{failedString} and %{resolvedString}'), { + failedString, + resolvedString, + }); + } else { + resultsString = failedString; + } + } else if (resolved) { + resultsString = resolvedString; + } + + return `${resultsString} ${totalString}`; +}; + +export const summaryTextBuilder = (name = '', results = {}) => { + const resultsString = textBuilder(results); + return `${name} contained ${resultsString}`; +}; + +export const reportTextBuilder = (name = '', results = {}) => { + const resultsString = textBuilder(results); + return `${name} found ${resultsString}`; +}; + +export const statusIcon = status => { + if (status === STATUS_FAILED) { + return ICON_WARNING; + } + + if (status === STATUS_SUCCESS) { + return ICON_SUCCESS; + } + + return ICON_NOTFOUND; +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index b5de3dd6d73..80593d1f34a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -36,6 +36,7 @@ import { notify, SourceBranchRemovalStatus, } from './dependencies'; +import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import { setFaviconOverlay } from '../lib/utils/common_utils'; export default { @@ -68,6 +69,7 @@ export default { 'mr-widget-auto-merge-failed': AutoMergeFailed, 'mr-widget-rebase': RebaseState, SourceBranchRemovalStatus, + GroupedTestReportsApp, }, props: { mrData: { @@ -260,6 +262,10 @@ export default { :deployment="deployment" /> <div class="mr-section-container"> + <grouped-test-reports-app + v-if="mr.testResultsPath" + :endpoint="mr.testResultsPath" + /> <div class="mr-widget-section"> <component :is="componentName" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index e84c436905d..672e5280b5e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -108,6 +108,8 @@ export default class MergeRequestStore { this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false; this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null; + this.testResultsPath = data.test_reports_path; + this.setState(data); } diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue new file mode 100644 index 00000000000..3cca7a86bef --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/code_block.vue @@ -0,0 +1,16 @@ +<script> +export default { + name: 'CodeBlock', + props: { + code: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <pre class="code-block rounded"> + <code class="d-block">{{ code }}</code> + </pre> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/issue_body.js b/app/assets/javascripts/vue_shared/components/reports/issue_body.js index f2141e519da..54dfb7b16bf 100644 --- a/app/assets/javascripts/vue_shared/components/reports/issue_body.js +++ b/app/assets/javascripts/vue_shared/components/reports/issue_body.js @@ -1,3 +1,9 @@ -export const components = {}; +import TestIssueBody from '~/reports/components/test_issue_body.vue'; -export const componentNames = {}; +export const components = { + TestIssueBody, +}; + +export const componentNames = { + TestIssueBody: TestIssueBody.name, +}; diff --git a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue index c01f77c2509..2545e84f932 100644 --- a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue +++ b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue @@ -18,6 +18,11 @@ export default { failed: STATUS_FAILED, neutral: STATUS_NEUTRAL, props: { + newIssues: { + type: Array, + required: false, + default: () => [], + }, unresolvedIssues: { type: Array, required: false, @@ -45,6 +50,15 @@ export default { <div class="report-block-container"> <issues-block + v-if="newIssues.length" + :component="component" + :issues="newIssues" + class="js-mr-code-new-issues" + status="failed" + is-new + /> + + <issues-block v-if="unresolvedIssues.length" :component="component" :issues="unresolvedIssues" diff --git a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue index 2d1f3d82234..1f13e555b31 100644 --- a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue +++ b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue @@ -24,6 +24,11 @@ export default { type: String, required: true, }, + isNew: { + type: Boolean, + required: false, + default: false, + }, }, }; </script> @@ -46,6 +51,7 @@ export default { :is="component" :issue="issue" :status="issue.status || status" + :is-new="isNew" /> </li> </ul> diff --git a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue index 997bad960e2..28156d7c983 100644 --- a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue +++ b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue @@ -29,7 +29,8 @@ export default { }, popoverOptions: { type: Object, - required: true, + required: false, + default: null, }, }, computed: { @@ -60,7 +61,11 @@ export default { {{ summary }} </div> - <popover :options="popoverOptions" /> + <popover + v-if="popoverOptions" + :options="popoverOptions" + /> + </div> </div> </template> diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 340fddd398b..7145a76db6d 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -353,3 +353,18 @@ .flex-right { margin-left: auto; } + +.code-block { + background: $black; + color: $gray-darkest; + white-space: pre; + overflow-x: auto; + font-size: 12px; + border: 0; + padding: $grid-size; + + code { + background-color: inherit; + padding: inherit; + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 2d6dba52801..c9865610b78 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -469,3 +469,4 @@ img.emoji { .inline { display: inline-block; } .center { text-align: center; } .vertical-align-middle { vertical-align: middle; } +.flex-align-self-center { align-self: center; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 7bd0f0bf1e0..a355ceea7a0 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -15,6 +15,39 @@ } } +.mr-widget-border-top { + border-top: 1px solid $border-color; +} + +.media-section { + @include media-breakpoint-down(md) { + align-items: flex-start; + + .media-body { + flex-direction: column; + align-items: flex-start; + } + } + + .code-text { + @include media-breakpoint-up(lg) { + align-self: center; + flex: 1; + } + } +} + +.mr-widget-section { + .media { + align-items: center; + } + + .code-text { + flex: 1; + } +} + + .mr-widget-heading { position: relative; border: 1px solid $border-color; @@ -54,6 +87,14 @@ padding: 0; } + .grouped-security-reports { + padding: 0; + + > .media { + padding: $gl-padding; + } + } + form { margin-bottom: 0; diff --git a/changelogs/unreleased/45318-junit-FE.yml b/changelogs/unreleased/45318-junit-FE.yml new file mode 100644 index 00000000000..bbc08f54484 --- /dev/null +++ b/changelogs/unreleased/45318-junit-FE.yml @@ -0,0 +1,5 @@ +--- +title: Adds frontend support to render test reports on the MR widget +merge_request: 20936 +author: +type: added diff --git a/locale/gitlab.pot b/locale/gitlab.pot index dd3c8692954..c1b80cd114c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -36,6 +36,16 @@ msgid_plural "%d exporters" msgstr[0] "" msgstr[1] "" +msgid "%d failed test result" +msgid_plural "%d failed test results" +msgstr[0] "" +msgstr[1] "" + +msgid "%d fixed test result" +msgid_plural "%d fixed test results" +msgstr[0] "" +msgstr[1] "" + msgid "%d issue" msgid_plural "%d issues" msgstr[0] "" @@ -4390,6 +4400,33 @@ msgstr "" msgid "Reply to this email directly or %{view_it_on_gitlab}." msgstr "" +msgid "Reports|%{failedString} and %{resolvedString}" +msgstr "" + +msgid "Reports|Class" +msgstr "" + +msgid "Reports|Execution time" +msgstr "" + +msgid "Reports|Failure" +msgstr "" + +msgid "Reports|System output" +msgstr "" + +msgid "Reports|Test summary" +msgstr "" + +msgid "Reports|Test summary failed loading results" +msgstr "" + +msgid "Reports|Test summary results are being parsed" +msgstr "" + +msgid "Reports|no changed test results" +msgstr "" + msgid "Repository" msgstr "" @@ -6273,6 +6310,11 @@ msgstr "" msgid "or" msgstr "" +msgid "out of %d total test" +msgid_plural "out of %d total tests" +msgstr[0] "" +msgstr[1] "" + msgid "parent" msgid_plural "parents" msgstr[0] "" diff --git a/spec/javascripts/reports/components/grouped_test_reports_app_spec.js b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js new file mode 100644 index 00000000000..d86e565036c --- /dev/null +++ b/spec/javascripts/reports/components/grouped_test_reports_app_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import state from '~/reports/store/state'; +import component from '~/reports/components/grouped_test_reports_app.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import newFailedTestReports from '../mock_data/new_failures_report.json'; +import successTestReports from '../mock_data/no_failures_report.json'; +import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json'; + +describe('Grouped Test Reports App', () => { + let vm; + let mock; + const Component = Vue.extend(component); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + vm.$store.replaceState(state()); + vm.$destroy(); + mock.restore(); + }); + + describe('with success result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, successTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders success summary text', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.fa-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained no changed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found no changed test results out of 8 total tests', + ); + expect(vm.$el.textContent).toContain( + 'java ant found no changed test results out of 3 total tests', + ); + done(); + }, 0); + }); + }); + + describe('with 204 result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(204, {}, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders success summary text', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary results are being parsed', + ); + + done(); + }, 0); + }); + }); + + describe('with new failed result', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, newFailedTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders failed summary text + new badge', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.fa-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 2 failed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found 2 failed test results out of 8 total tests', + ); + expect(vm.$el.textContent).toContain('New'); + expect(vm.$el.textContent).toContain( + 'java ant found no changed test results out of 3 total tests', + ); + done(); + }, 0); + }); + }); + + describe('with mixed results', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, mixedResultsTestReports, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders summary text', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.fa-spinner')).toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary contained 2 failed test results and 2 fixed test results out of 11 total tests', + ); + + expect(vm.$el.textContent).toContain( + 'rspec:pg found 1 failed test result and 2 fixed test results out of 8 total tests', + ); + expect(vm.$el.textContent).toContain('New'); + expect(vm.$el.textContent).toContain( + ' java ant found 1 failed test result out of 3 total tests', + ); + done(); + }, 0); + }); + }); + + describe('with error', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(500, {}, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders loading summary text with loading icon', done => { + setTimeout(() => { + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary failed loading results', + ); + done(); + }, 0); + }); + }); + + describe('while loading', () => { + beforeEach(() => { + mock.onGet('test_results.json').reply(200, {}, {}); + vm = mountComponent(Component, { + endpoint: 'test_results.json', + }); + }); + + it('renders loading summary text with loading icon', done => { + expect(vm.$el.querySelector('.fa-spinner')).not.toBeNull(); + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Test summary results are being parsed', + ); + + setTimeout(() => { + done(); + }, 0); + }); + }); +}); diff --git a/spec/javascripts/reports/components/modal_spec.js b/spec/javascripts/reports/components/modal_spec.js new file mode 100644 index 00000000000..3a567c40eca --- /dev/null +++ b/spec/javascripts/reports/components/modal_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import component from '~/reports/components/modal.vue'; +import state from '~/reports/store/state'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/vue_component_helper'; + +describe('Grouped Test Reports Modal', () => { + const Component = Vue.extend(component); + const modalDataStructure = state().modal.data; + + // populate data + modalDataStructure.execution_time.value = 0.009411; + modalDataStructure.system_output.value = 'Failure/Error: is_expected.to eq(3)\n\n'; + modalDataStructure.class.value = 'link'; + + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + title: 'Test#sum when a is 1 and b is 2 returns summary', + modalData: modalDataStructure, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders code block', () => { + expect(vm.$el.querySelector('code').textContent).toEqual(modalDataStructure.system_output.value); + }); + + it('renders link', () => { + expect(vm.$el.querySelector('.js-modal-link').getAttribute('href')).toEqual(modalDataStructure.class.value); + expect(trimText(vm.$el.querySelector('.js-modal-link').textContent)).toEqual(modalDataStructure.class.value); + }); + + it('renders miliseconds', () => { + expect(vm.$el.textContent).toContain(`${modalDataStructure.execution_time.value} ms`); + }); + + it('render title', () => { + expect(trimText(vm.$el.querySelector('.modal-title').textContent)).toEqual('Test#sum when a is 1 and b is 2 returns summary'); + }); +}); diff --git a/spec/javascripts/reports/components/test_issue_body_spec.js b/spec/javascripts/reports/components/test_issue_body_spec.js new file mode 100644 index 00000000000..0ea81f714e7 --- /dev/null +++ b/spec/javascripts/reports/components/test_issue_body_spec.js @@ -0,0 +1,71 @@ +import Vue from 'vue'; +import component from '~/reports/components/test_issue_body.vue'; +import createStore from '~/reports/store'; +import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { trimText } from '../../helpers/vue_component_helper'; +import { issue } from '../mock_data/mock_data'; + +describe('Test Issue body', () => { + let vm; + const Component = Vue.extend(component); + const store = createStore(); + + const commonProps = { + issue, + status: 'failed', + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('on click', () => { + it('calls openModal action', () => { + vm = mountComponentWithStore(Component, { + store, + props: commonProps, + }); + + spyOn(vm, 'openModal'); + + vm.$el.querySelector('button').click(); + expect(vm.openModal).toHaveBeenCalledWith({ + issue: commonProps.issue, + }); + }); + }); + + describe('is new', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + store, + props: Object.assign({}, commonProps, { isNew: true }), + }); + }); + + it('renders issue name', () => { + expect(vm.$el.textContent).toContain(commonProps.issue.name); + }); + + it('renders new badge', () => { + expect(trimText(vm.$el.querySelector('.badge').textContent)).toEqual('New'); + }); + }); + + describe('not new', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + store, + props: commonProps, + }); + }); + + it('renders issue name', () => { + expect(vm.$el.textContent).toContain(commonProps.issue.name); + }); + + it('does not renders new badge', () => { + expect(vm.$el.querySelector('.badge')).toEqual(null); + }); + }); +}); diff --git a/spec/javascripts/reports/mock_data/mock_data.js b/spec/javascripts/reports/mock_data/mock_data.js new file mode 100644 index 00000000000..0d90253bad2 --- /dev/null +++ b/spec/javascripts/reports/mock_data/mock_data.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/prefer-default-export +export const issue = { + result: 'failure', + name: 'Test#sum when a is 1 and b is 2 returns summary', + execution_time: 0.009411, + system_output: + "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'", +}; diff --git a/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json b/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json new file mode 100644 index 00000000000..ceaf894375a --- /dev/null +++ b/spec/javascripts/reports/mock_data/new_and_fixed_failures_report.json @@ -0,0 +1 @@ +{"status":"failed","summary":{"total":11,"resolved":2,"failed":2},"suites":[{"name":"rspec:pg","status":"failed","summary":{"total":8,"resolved":2,"failed":1},"new_failures":[{"status":"failed","name":"Test#subtract when a is 2 and b is 1 returns correct result","execution_time":0.00908,"system_output":"Failure/Error: is_expected.to eq(1)\n\n expected: 1\n got: 3\n\n (compared using ==)\n./spec/test_spec.rb:43:in `block (4 levels) in <top (required)>'"}],"resolved_failures":[{"status":"success","name":"Test#sum when a is 1 and b is 2 returns summary","execution_time":0.000318,"system_output":null},{"status":"success","name":"Test#sum when a is 100 and b is 200 returns summary","execution_time":0.000074,"system_output":null}],"existing_failures":[]},{"name":"java ant","status":"failed","summary":{"total":3,"resolved":0,"failed":1},"new_failures":[],"resolved_failures":[],"existing_failures":[{"status":"failed","name":"sumTest","execution_time":0.004,"system_output":"junit.framework.AssertionFailedError: expected:<3> but was:<-1>\n\tat CalculatorTest.sumTest(Unknown Source)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n"}]}]}
\ No newline at end of file diff --git a/spec/javascripts/reports/mock_data/new_failures_report.json b/spec/javascripts/reports/mock_data/new_failures_report.json new file mode 100644 index 00000000000..930efe16f65 --- /dev/null +++ b/spec/javascripts/reports/mock_data/new_failures_report.json @@ -0,0 +1 @@ +{"summary":{"total":11,"resolved":0,"failed":2},"suites":[{"name":"rspec:pg","summary":{"total":8,"resolved":0,"failed":2},"new_failures":[{"result":"failure","name":"Test#sum when a is 1 and b is 2 returns summary","execution_time":0.009411,"system_output":"Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'"},{"result":"failure","name":"Test#sum when a is 100 and b is 200 returns summary","execution_time":0.000162,"system_output":"Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'"}],"resolved_failures":[],"existing_failures":[]},{"name":"java ant","summary":{"total":3,"resolved":0,"failed":0},"new_failures":[],"resolved_failures":[],"existing_failures":[]}]}
\ No newline at end of file diff --git a/spec/javascripts/reports/mock_data/no_failures_report.json b/spec/javascripts/reports/mock_data/no_failures_report.json new file mode 100644 index 00000000000..6c0675ff7dc --- /dev/null +++ b/spec/javascripts/reports/mock_data/no_failures_report.json @@ -0,0 +1 @@ +{"status":"success","summary":{"total":11,"resolved":0,"failed":0},"suites":[{"name":"rspec:pg","status":"success","summary":{"total":8,"resolved":0,"failed":0},"new_failures":[],"resolved_failures":[],"existing_failures":[]},{"name":"java ant","status":"success","summary":{"total":3,"resolved":0,"failed":0},"new_failures":[],"resolved_failures":[],"existing_failures":[]}]}
\ No newline at end of file diff --git a/spec/javascripts/reports/store/actions_spec.js b/spec/javascripts/reports/store/actions_spec.js index c714c5af156..41137b50847 100644 --- a/spec/javascripts/reports/store/actions_spec.js +++ b/spec/javascripts/reports/store/actions_spec.js @@ -8,6 +8,8 @@ import { clearEtagPoll, receiveReportsSuccess, receiveReportsError, + openModal, + setModalData, } from '~/reports/store/actions'; import state from '~/reports/store/state'; import * as types from '~/reports/store/mutation_types'; @@ -56,7 +58,9 @@ describe('Reports Store Actions', () => { describe('success', () => { it('dispatches requestReports and receiveReportsSuccess ', done => { - mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] }); + mock + .onGet(`${TEST_HOST}/endpoint.json`) + .replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] }); testAction( fetchReports, @@ -68,7 +72,7 @@ describe('Reports Store Actions', () => { type: 'requestReports', }, { - payload: { summary: {}, suites: [{ name: 'rspec' }] }, + payload: { data: { summary: {}, suites: [{ name: 'rspec' }] }, status: 200 }, type: 'receiveReportsSuccess', }, ], @@ -103,16 +107,27 @@ describe('Reports Store Actions', () => { }); describe('receiveReportsSuccess', () => { - it('should commit RECEIVE_REPORTS_SUCCESS mutation', done => { + it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', done => { testAction( receiveReportsSuccess, - { summary: {} }, + { data: { summary: {} }, status: 200 }, mockedState, [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }], [], done, ); }); + + it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => { + testAction( + receiveReportsSuccess, + { data: { summary: {} }, status: 204 }, + mockedState, + [], + [], + done, + ); + }); }); describe('receiveReportsError', () => { @@ -127,4 +142,30 @@ describe('Reports Store Actions', () => { ); }); }); + + describe('openModal', () => { + it('should dispatch setModalData', done => { + testAction( + openModal, + { name: 'foo' }, + mockedState, + [], + [{ type: 'setModalData', payload: { name: 'foo' } }], + done, + ); + }); + }); + + describe('setModalData', () => { + it('should commit SET_ISSUE_MODAL_DATA', done => { + testAction( + setModalData, + { name: 'foo' }, + mockedState, + [{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }], + [], + done, + ); + }); + }); }); diff --git a/spec/javascripts/reports/store/mutations_spec.js b/spec/javascripts/reports/store/mutations_spec.js index 3e0b15438c3..8f99d2675a5 100644 --- a/spec/javascripts/reports/store/mutations_spec.js +++ b/spec/javascripts/reports/store/mutations_spec.js @@ -1,6 +1,7 @@ import state from '~/reports/store/state'; import mutations from '~/reports/store/mutations'; import * as types from '~/reports/store/mutation_types'; +import { issue } from '../mock_data/mock_data'; describe('Reports Store Mutations', () => { let stateCopy; @@ -42,24 +43,21 @@ describe('Reports Store Mutations', () => { { name: 'StringHelper#concatenate when a is git and b is lab returns summary', execution_time: 0.0092435, - system_output: - 'Failure/Error: is_expected.to eq(\'gitlab\')', + system_output: "Failure/Error: is_expected.to eq('gitlab')", }, ], resolved_failures: [ { name: 'StringHelper#concatenate when a is git and b is lab returns summary', execution_time: 0.009235, - system_output: - 'Failure/Error: is_expected.to eq(\'gitlab\')', + system_output: "Failure/Error: is_expected.to eq('gitlab')", }, ], existing_failures: [ { name: 'StringHelper#concatenate when a is git and b is lab returns summary', execution_time: 1232.08, - system_output: - 'Failure/Error: is_expected.to eq(\'gitlab\')', + system_output: "Failure/Error: is_expected.to eq('gitlab')", }, ], }, @@ -89,6 +87,7 @@ describe('Reports Store Mutations', () => { beforeEach(() => { mutations[types.RECEIVE_REPORTS_ERROR](stateCopy); }); + it('should reset isLoading', () => { expect(stateCopy.isLoading).toEqual(false); }); @@ -97,5 +96,25 @@ describe('Reports Store Mutations', () => { expect(stateCopy.hasError).toEqual(true); }); + it('should reset reports', () => { + expect(stateCopy.reports).toEqual([]); + }); + }); + + describe('SET_ISSUE_MODAL_DATA', () => { + beforeEach(() => { + mutations[types.SET_ISSUE_MODAL_DATA](stateCopy, { + issue, + }); + }); + + it('should set modal title', () => { + expect(stateCopy.modal.title).toEqual(issue.name); + }); + + it('should set modal data', () => { + expect(stateCopy.modal.data.execution_time.value).toEqual(issue.execution_time); + expect(stateCopy.modal.data.system_output.value).toEqual(issue.system_output); + }); }); }); diff --git a/spec/javascripts/reports/store/utils_spec.js b/spec/javascripts/reports/store/utils_spec.js new file mode 100644 index 00000000000..1679d120db2 --- /dev/null +++ b/spec/javascripts/reports/store/utils_spec.js @@ -0,0 +1,138 @@ +import * as utils from '~/reports/store/utils'; +import { + STATUS_FAILED, + STATUS_SUCCESS, + ICON_WARNING, + ICON_SUCCESS, + ICON_NOTFOUND, +} from '~/reports/constants'; + +describe('Reports store utils', () => { + describe('summaryTextbuilder', () => { + it('should render text for no changed results in multiple tests', () => { + const name = 'Test summary'; + const data = { total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe('Test summary contained no changed test results out of 10 total tests'); + }); + + it('should render text for no changed results in one test', () => { + const name = 'Test summary'; + const data = { total: 1 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe('Test summary contained no changed test results out of 1 total test'); + }); + + it('should render text for multiple failed results', () => { + const name = 'Test summary'; + const data = { failed: 3, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe('Test summary contained 3 failed test results out of 10 total tests'); + }); + + it('should render text for multiple fixed results', () => { + const name = 'Test summary'; + const data = { resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe('Test summary contained 4 fixed test results out of 10 total tests'); + }); + + it('should render text for multiple fixed, and multiple failed results', () => { + const name = 'Test summary'; + const data = { failed: 3, resolved: 4, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary contained 3 failed test results and 4 fixed test results out of 10 total tests', + ); + }); + + it('should render text for a singular fixed, and a singular failed result', () => { + const name = 'Test summary'; + const data = { failed: 1, resolved: 1, total: 10 }; + const result = utils.summaryTextBuilder(name, data); + + expect(result).toBe( + 'Test summary contained 1 failed test result and 1 fixed test result out of 10 total tests', + ); + }); + }); + + describe('reportTextBuilder', () => { + it('should render text for no changed results in multiple tests', () => { + const name = 'Rspec'; + const data = { total: 10 }; + const result = utils.reportTextBuilder(name, data); + + expect(result).toBe('Rspec found no changed test results out of 10 total tests'); + }); + + it('should render text for no changed results in one test', () => { + const name = 'Rspec'; + const data = { total: 1 }; + const result = utils.reportTextBuilder(name, data); + + expect(result).toBe('Rspec found no changed test results out of 1 total test'); + }); + + it('should render text for multiple failed results', () => { + const name = 'Rspec'; + const data = { failed: 3, total: 10 }; + const result = utils.reportTextBuilder(name, data); + + expect(result).toBe('Rspec found 3 failed test results out of 10 total tests'); + }); + + it('should render text for multiple fixed results', () => { + const name = 'Rspec'; + const data = { resolved: 4, total: 10 }; + const result = utils.reportTextBuilder(name, data); + + expect(result).toBe('Rspec found 4 fixed test results out of 10 total tests'); + }); + + it('should render text for multiple fixed, and multiple failed results', () => { + const name = 'Rspec'; + const data = { failed: 3, resolved: 4, total: 10 }; + const result = utils.reportTextBuilder(name, data); + + expect(result).toBe( + 'Rspec found 3 failed test results and 4 fixed test results out of 10 total tests', + ); + }); + + it('should render text for a singular fixed, and a singular failed result', () => { + const name = 'Rspec'; + const data = { failed: 1, resolved: 1, total: 10 }; + const result = utils.reportTextBuilder(name, data); + + expect(result).toBe( + 'Rspec found 1 failed test result and 1 fixed test result out of 10 total tests', + ); + }); + }); + + describe('statusIcon', () => { + describe('with failed status', () => { + it('returns ICON_WARNING', () => { + expect(utils.statusIcon(STATUS_FAILED)).toEqual(ICON_WARNING); + }); + }); + + describe('with success status', () => { + it('returns ICON_SUCCESS', () => { + expect(utils.statusIcon(STATUS_SUCCESS)).toEqual(ICON_SUCCESS); + }); + }); + + describe('without a status', () => { + it('returns ICON_NOTFOUND', () => { + expect(utils.statusIcon()).toEqual(ICON_NOTFOUND); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/code_block_spec.js b/spec/javascripts/vue_shared/components/code_block_spec.js new file mode 100644 index 00000000000..6b91a20ff76 --- /dev/null +++ b/spec/javascripts/vue_shared/components/code_block_spec.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import component from '~/vue_shared/components/code_block.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Code Block', () => { + const Component = Vue.extend(component); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a code block with the provided code', () => { + const code = + "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'"; + + vm = mountComponent(Component, { + code, + }); + + expect(vm.$el.querySelector('code').textContent).toEqual(code); + }); + + it('escapes XSS injections', () => { + const code = 'CCC<img src=x onerror=alert(document.domain)>'; + + vm = mountComponent(Component, { + code, + }); + + expect(vm.$el.querySelector('code').textContent).toEqual(code); + }); +}); |