diff --git a/.eslintrc b/.eslintrc index e40502acd6..cb45ce5f69 100644 --- a/.eslintrc +++ b/.eslintrc @@ -147,11 +147,14 @@ "Chart": true, "Cypress": true, "cy": true, + "describe": true, + "expect": true, "it": true, "context": true, "before": true, "beforeEach": true, "onScan": true, - "extend_cscript": true + "extend_cscript": true, + "localforage": true, } } diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000000..4bc55da1d8 --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,104 @@ +name: UI + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-18.04 + + strategy: + fail-fast: false + + name: UI Tests (Cypress) + + services: + mysql: + image: mariadb:10.3 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: YES + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + + - uses: actions/setup-node@v2 + with: + node-version: 14 + check-latest: true + + - name: Add to Hosts + run: | + echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts + + - name: Cache pip + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Cache node modules + uses: actions/cache@v2 + env: + cache-name: cache-node-modules + with: + path: ~/.npm + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Cache cypress binary + uses: actions/cache@v2 + with: + path: ~/.cache + key: ${{ runner.os }}-cypress- + restore-keys: | + ${{ runner.os }}-cypress- + ${{ runner.os }}- + + - name: Install + run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh + env: + DB: mariadb + TYPE: ui + + - name: Site Setup + run: cd ~/frappe-bench/ && bench --site test_site execute erpnext.setup.utils.before_tests + + - name: cypress pre-requisites + run: cd ~/frappe-bench/apps/frappe && yarn add cypress-file-upload@^5 --no-lockfile + + + - name: Build Assets + run: cd ~/frappe-bench/ && bench build + + - name: UI Tests + run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests erpnext --headless + env: + CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..02b10d893f --- /dev/null +++ b/cypress.json @@ -0,0 +1,11 @@ +{ + "baseUrl": "http://test_site:8000/", + "projectId": "da59y9", + "adminPassword": "admin", + "defaultCommandTimeout": 20000, + "pageLoadTimeout": 15000, + "retries": { + "runMode": 2, + "openMode": 2 + } +} diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 0000000000..da18d9352a --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} \ No newline at end of file diff --git a/cypress/integration/test_customer.js b/cypress/integration/test_customer.js new file mode 100644 index 0000000000..3d6ed5d0d8 --- /dev/null +++ b/cypress/integration/test_customer.js @@ -0,0 +1,13 @@ + +context('Customer', () => { + before(() => { + cy.login(); + }); + it('Check Customer Group', () => { + cy.visit(`app/customer/`); + cy.get('.primary-action').click(); + cy.wait(500); + cy.get('.custom-actions > .btn').click(); + cy.get_field('customer_group', 'Link').should('have.value', 'All Customer Groups'); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 0000000000..07d9804a73 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,17 @@ +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +module.exports = () => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +}; diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 0000000000..7929a2e0ef --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }); +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }); +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }); +// +// +// -- This is will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }); diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 0000000000..72070cc81c --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,26 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; +import '../../../frappe/cypress/support/commands' // eslint-disable-line + + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +Cypress.Cookies.defaults({ + preserve: 'sid' +}); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000000..d90ebf6856 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowJs": true, + "baseUrl": "../node_modules", + "types": [ + "cypress" + ] + }, + "include": [ + "**/*.*" + ] +} \ No newline at end of file diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index e092b07222..dd6d647abc 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -26,9 +26,6 @@ class Issue(Document): self.set_lead_contact(self.raised_by) - if not self.service_level_agreement: - self.reset_sla_fields() - def on_update(self): # Add a communication in the issue timeline if self.flags.create_communication and self.via_customer_portal: @@ -54,106 +51,6 @@ class Issue(Document): self.company = frappe.db.get_value("Lead", self.lead, "company") or \ frappe.db.get_default("Company") - def reset_sla_fields(self): - self.agreement_status = "" - self.response_by = "" - self.resolution_by = "" - self.response_by_variance = 0 - self.resolution_by_variance = 0 - - def update_status(self): - status = frappe.db.get_value("Issue", self.name, "status") - if self.status != "Open" and status == "Open" and not self.first_responded_on: - self.first_responded_on = frappe.flags.current_time or now_datetime() - - if self.status in ["Closed", "Resolved"] and status not in ["Resolved", "Closed"]: - self.resolution_date = frappe.flags.current_time or now_datetime() - if frappe.db.get_value("Issue", self.name, "agreement_status") == "Ongoing": - set_service_level_agreement_variance(issue=self.name) - self.update_agreement_status() - set_resolution_time(issue=self) - set_user_resolution_time(issue=self) - - if self.status == "Open" and status != "Open": - # if no date, it should be set as None and not a blank string "", as per mysql strict config - self.resolution_date = None - self.reset_issue_metrics() - # enable SLA and variance on Reopen - self.agreement_status = "Ongoing" - set_service_level_agreement_variance(issue=self.name) - - self.handle_hold_time(status) - - def handle_hold_time(self, status): - if self.service_level_agreement: - # set response and resolution variance as None as the issue is on Hold - pause_sla_on = frappe.db.get_all("Pause SLA On Status", fields=["status"], - filters={"parent": self.service_level_agreement}) - hold_statuses = [entry.status for entry in pause_sla_on] - update_values = {} - - if hold_statuses: - if self.status in hold_statuses and status not in hold_statuses: - update_values['on_hold_since'] = frappe.flags.current_time or now_datetime() - if not self.first_responded_on: - update_values['response_by'] = None - update_values['response_by_variance'] = 0 - update_values['resolution_by'] = None - update_values['resolution_by_variance'] = 0 - - # calculate hold time when status is changed from any hold status to any non-hold status - if self.status not in hold_statuses and status in hold_statuses: - hold_time = self.total_hold_time if self.total_hold_time else 0 - now_time = frappe.flags.current_time or now_datetime() - last_hold_time = 0 - if self.on_hold_since: - # last_hold_time will be added to the sla variables - last_hold_time = time_diff_in_seconds(now_time, self.on_hold_since) - update_values['total_hold_time'] = hold_time + last_hold_time - - # re-calculate SLA variables after issue changes from any hold status to any non-hold status - # add hold time to SLA variables - start_date_time = get_datetime(self.service_level_agreement_creation) - priority = get_priority(self) - now_time = frappe.flags.current_time or now_datetime() - - if not self.first_responded_on: - response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) - response_by = add_to_date(response_by, seconds=round(last_hold_time)) - response_by_variance = round(time_diff_in_seconds(response_by, now_time)) - update_values['response_by'] = response_by - update_values['response_by_variance'] = response_by_variance + last_hold_time - - resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) - resolution_by = add_to_date(resolution_by, seconds=round(last_hold_time)) - resolution_by_variance = round(time_diff_in_seconds(resolution_by, now_time)) - update_values['resolution_by'] = resolution_by - update_values['resolution_by_variance'] = resolution_by_variance + last_hold_time - update_values['on_hold_since'] = None - - self.db_set(update_values) - - def update_agreement_status(self): - if self.service_level_agreement and self.agreement_status == "Ongoing": - if cint(frappe.db.get_value("Issue", self.name, "response_by_variance")) < 0 or \ - cint(frappe.db.get_value("Issue", self.name, "resolution_by_variance")) < 0: - - self.agreement_status = "Failed" - else: - self.agreement_status = "Fulfilled" - - def update_agreement_status_on_custom_status(self): - """ - Update Agreement Fulfilled status using Custom Scripts for Custom Issue Status - """ - if not self.first_responded_on: # first_responded_on set when first reply is sent to customer - self.response_by_variance = round(time_diff_in_seconds(self.response_by, now_datetime()), 2) - - if not self.resolution_date: # resolution_date set when issue has been closed - self.resolution_by_variance = round(time_diff_in_seconds(self.resolution_by, now_datetime()), 2) - - self.agreement_status = "Fulfilled" if self.response_by_variance > 0 and self.resolution_by_variance > 0 else "Failed" - def create_communication(self): communication = frappe.new_doc("Communication") communication.update({ @@ -318,4 +215,4 @@ def make_issue_from_communication(communication, ignore_communication_links=Fals def get_holidays(holiday_list_name): holiday_list = frappe.get_cached_doc("Holiday List", holiday_list_name) holidays = [holiday.holiday_date for holiday in holiday_list.holidays] - return holidays + return holidays \ No newline at end of file