diff --git a/.gitignore b/.gitignore index 2cdb405..3321350 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,8 @@ data/* openimis-dist_dkr.code-workspace node_modules/ cypress/screenshots/ +.DS_Store +./.DS_Store +yarn.lock cypress/downloads/ cypress/fixtures/tmp_individuals.csv diff --git a/cypress.config.js b/cypress.config.js index 4b032d9..bc6c773 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -50,9 +50,10 @@ module.exports = defineConfig({ viewportHeight: 670, e2e: { projectId: "q6gc25", // Cypress Cloud, needed for recording - baseUrl: 'http://localhost', + baseUrl: 'http://localhost:3000', defaultCommandTimeout: 15000, taskTimeout: timeoutMinutes * 60 * 1000 + 10, + slowMo: 1000, downloadsFolder: 'cypress/downloads', setupNodeEvents(on, config) { on('task', { diff --git a/cypress/e2e/invoice.cy.js b/cypress/e2e/invoice.cy.js new file mode 100644 index 0000000..1c040e0 --- /dev/null +++ b/cypress/e2e/invoice.cy.js @@ -0,0 +1,43 @@ +import { invoicePage } from '../support/pages/InvoicePage'; + +describe('Invoice payment workflow', () => { + let data; + + before(() => { + cy.fixture('invoicePayment').then((fixture) => { + data = fixture; + }); + }); + + beforeEach(() => { + cy.login(); + }); + + afterEach(function () { + // Clean data only if the test didn't failed + if (this.currentTest.state === 'failed') return; + invoicePage.deletePayment(data.family.head.chfId, data.payment, { + failIfMissing: false, + }); + }); + + it('Create invoice payment', () => { + + //create family and give them policy + invoicePage.createFamily(data.family); + invoicePage.createPolicy(data.family.head.chfId, data.policy); + + // go to create invoice payment + invoicePage.goToForm(data.family.head.chfId); + invoicePage.fillForm(data.payment); + + // save payment + cy.contains('[role="dialog"]', 'Create').within(() => { + cy.contains('button', 'Create').click({ force: true }); + }); + cy.waitForGraphQL('createPayment'); + + // check invoice payment + invoicePage.verifyExists(data.family.head.chfId, data.payment); + }); +}); \ No newline at end of file diff --git a/cypress/fixtures/invoicePayment.json b/cypress/fixtures/invoicePayment.json new file mode 100644 index 0000000..4223bc1 --- /dev/null +++ b/cypress/fixtures/invoicePayment.json @@ -0,0 +1,43 @@ +{ + "payment": { + "reconciliationStatus": "Reconciliated", + "status": "Accepted", + "reference": "ref001", + "payerName": "Joseph", + "code": "INV0010", + "label": "INV", + "codeThirdparty": "SP01", + "receiptNumber": "677763166", + "fees": 12345, + "amountReceived": 5000, + "paymentOrigin": "epargne", + "paymentDate": { + "day": 19, + "month": 3, + "year": 2026 + } + }, + "family": { + "location": "R1D1M1V1 Rachla", + "head": { + "givenNames": "Antoine", + "lastName": "Robert", + "gender": "Male", + "chfId": "777746566", + "dob": { + "day": 19, + "month": 12, + "year": 2025 + } + } + }, + "policy": { + "product": { + "code": "FCUL0001", + "name": "Fixed Cycle Cover Ultha" + }, + "officer": { + "code": "E00007 Johnson James" + } + } +} \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 0dca391..e95d987 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -79,15 +79,15 @@ Cypress.Commands.add('deleteModuleConfig', (moduleName) => { Cypress.Commands.add('shouldHaveMenuItemsInOrder', (expectedMenuNames) => { cy.get('div[role="button"]') - .filter(':visible') - .should(($buttons) => { - expect($buttons).to.have.length(expectedMenuNames.length); + .filter(':visible') + .should(($buttons) => { + expect($buttons).to.have.length(expectedMenuNames.length); - // Check each sub menu item text and order - expectedMenuNames.forEach((itemText, index) => { - expect($buttons.eq(index)).to.contain(itemText); + // Check each sub menu item text and order + expectedMenuNames.forEach((itemText, index) => { + expect($buttons.eq(index)).to.contain(itemText); + }); }); - }); }) Cypress.Commands.add('deleteActivities', (activityNames) => { @@ -207,7 +207,7 @@ Cypress.Commands.add('deleteProgram', (programName) => { cy.wrap(row).within(() => { // Find and click the Delete button in this row cy.get('button[title="Delete"]') - .click({force: true}); + .click({ force: true }); }); // Confirm deletion in dialog @@ -226,7 +226,7 @@ Cypress.Commands.add('deleteProgram', (programName) => { cy.get('ul.MuiList-root li') .first() .should('contain', 'Delete program'); - // .should('contain', `Delete program ${programName}`); //TODO: switch to this after fix + // .should('contain', `Delete program ${programName}`); //TODO: switch to this after fix // Close journal drawer cy.get('.MuiDrawer-paperAnchorRight button') @@ -314,33 +314,33 @@ Cypress.Commands.add( programName, maxBeneficiaries, programType, - institution='', - description='', + institution = '', + description = '', ) => { - cy.assertMuiInput('Code', programCode) - cy.assertMuiInput('Name', programName) - const today = getTodayFormatted() - cy.assertMuiInput('Date from', today) - cy.assertMuiInput('Date to', today) - cy.assertMuiInput('Max Beneficiaries', maxBeneficiaries) - cy.assertMuiInput('Institution', institution) - cy.assertMuiInput('Description', description, 'textarea') -}) + cy.assertMuiInput('Code', programCode) + cy.assertMuiInput('Name', programName) + const today = getTodayFormatted() + cy.assertMuiInput('Date from', today) + cy.assertMuiInput('Date to', today) + cy.assertMuiInput('Max Beneficiaries', maxBeneficiaries) + cy.assertMuiInput('Institution', institution) + cy.assertMuiInput('Description', description, 'textarea') + }) Cypress.Commands.add( 'checkProgramFieldValuesInListView', (programCode, programName, maxBeneficiaries, programType) => { - cy.contains('tfoot', 'Rows Per Page') - cy.contains('td', programName).should('exist') - cy.contains('td', programName) - .parent('tr').within(() => { - cy.contains('td', programCode) - cy.contains('td', programType) - cy.contains('td', maxBeneficiaries) - cy.contains('td', new Date().toISOString().substring(0, 10)) - }) -}) + cy.contains('tfoot', 'Rows Per Page') + cy.contains('td', programName).should('exist') + cy.contains('td', programName) + .parent('tr').within(() => { + cy.contains('td', programCode) + cy.contains('td', programType) + cy.contains('td', maxBeneficiaries) + cy.contains('td', new Date().toISOString().substring(0, 10)) + }) + }) Cypress.Commands.add('uploadIndividualsCSV', (numIndividuals) => { cy.task('updateCSV', { numIndividuals }).then(() => { @@ -371,7 +371,7 @@ Cypress.Commands.add('ensureSufficientIndividuals', (expectedNumIndividuals) => cy.visit('/front/individuals') cy.uploadIndividualsCSV(numToAdd) - cy.wait(100*numToAdd) // group creation takes time + cy.wait(100 * numToAdd) // group creation takes time cy.visit('/front/individuals') cy.getItemCount("Individual").then(newCount => { @@ -396,7 +396,7 @@ Cypress.Commands.add('ensureSufficientHouseholds', (expectedNumGroups) => { cy.visit('/front/individuals') cy.uploadIndividualsCSV(numIndividualsToAdd) - cy.wait(100*numIndividualsToAdd) // group creation takes time + cy.wait(100 * numIndividualsToAdd) // group creation takes time cy.visit('/front/groups') cy.getItemCount("Group").then(newCount => { @@ -614,26 +614,206 @@ Cypress.Commands.add('enrollGroupBeneficiariesIntoProgram', ( }) -Cypress.Commands.add('enterMuiInput', (label, value, inputTag='input') => { - cy.contains('label', label) +Cypress.Commands.add('enterMuiInput', (label, value, inputTag = 'input') => { + cy.contains('label', label, { matchCase: false }) .siblings('.MuiInputBase-root') .find(inputTag) .first() - .clear({force: true}) - .type(value, {force: true}); + .clear({ force: true }) + .type(value, { force: true }); }) +Cypress.Commands.add('getMuiInput', (label) => { + return cy.contains('label', label) + .invoke('attr', 'for') + .then((id) => cy.get(`#${id}`)); +}); + Cypress.Commands.add('chooseMuiSelect', (label, value) => { - cy.contains('label', label) + cy.contains('label', label, { matchCase: false }) .siblings('.MuiInputBase-root') - .find('[role="button"]') + .click(); + + cy.get('body') + .contains('li[role="option"]', value, { timeout: 10000 }) + .should('be.visible') + .click({ force: true }); +}) + +Cypress.Commands.add('chooseMuiAutocomplete', (label, value) => { + cy.contains('label', label, { matchCase: false }) + .siblings('.MuiInputBase-root') + .find('input') .click() + .clear() + .type(value, { delay: 50 }); - cy.contains('[role="listbox"] li', value).as('option') - cy.get('@option').click() + cy.get('body') + .contains('li[role="option"], li[role="presentation"], [role="menu"] li', value, { timeout: 10000 }) + .should('be.visible') + .click(); +}); + +const yearView = "year view is open, switch to calendar view" +const calendarView = "calendar view is open, switch to year view" + +Cypress.Commands.add('chooseMuiDatePicker', (label, dateOrDay, month, year) => { + // Support both chooseMuiDatePicker(label, day, month, year) + // and chooseMuiDatePicker(label, { day, month, year }) + let day; + if (dateOrDay && typeof dateOrDay === 'object') { + ({ day, month, year } = dateOrDay); + } else { + day = dateOrDay; + } + + // Normalize inputs so that selectors always receive primitive values. + let normalizedDay = day; + let normalizedMonth = month; + let normalizedYear = year; + + if (day && typeof day === 'object') { + if (day instanceof Date) { + normalizedDay = day.getDate(); + normalizedMonth = day.getMonth() + 1; + normalizedYear = day.getFullYear(); + } else { + // Support plain objects like { day, month, year } if used. + if (Object.prototype.hasOwnProperty.call(day, 'day')) { + normalizedDay = day.day; + } + if (Object.prototype.hasOwnProperty.call(day, 'month')) { + normalizedMonth = day.month; + } + if (Object.prototype.hasOwnProperty.call(day, 'year')) { + normalizedYear = day.year; + } + } + } + + cy.contains('label', label, { matchCase: false }) + .siblings('.MuiPickersInputBase-root') + .find('button') + .first() + .click(); + + if (normalizedYear) { + const normalizedYearText = String(normalizedYear); + cy.get('body') + .contains('li[role="option"]', normalizedYearText, { timeout: 10000 }) + .should('be.visible') + .click({ force: true }); + cy.get('[aria-label="' + calendarView + '"]') + .should('be.visible') + .click(); + cy.get('.MuiYearCalendar-button') + .contains(normalizedYearText) + .click(); + cy.get('[aria-label="' + yearView + '"]') + .should('be.visible') + .click(); + } + if (normalizedMonth) { + const normalizedMonthText = String(normalizedMonth); + cy.get('[aria-label="' + yearView + '"]') + .should('be.visible') + .click(); + cy.get('.MuiYearCalendar-button') + .contains(normalizedMonthText) + .click(); + } + const normalizedDayText = String(normalizedDay); + cy.get('[role="gridcell"]') + .contains(normalizedDayText) + .should('be.visible') + .click(); }) -Cypress.Commands.add('assertMuiInput', (label, value, inputTag='input') => { +Cypress.Commands.add('chooseCraMuiDatePicker', (label, dateOrDay, month, year) => { + let day; + if (dateOrDay && typeof dateOrDay === 'object') { + if (dateOrDay instanceof Date) { + day = dateOrDay.getDate(); + month = dateOrDay.getMonth() + 1; + year = dateOrDay.getFullYear(); + } else { + ({ day, month, year } = dateOrDay); + } + } else { + day = dateOrDay; + } + + cy.contains('label', label) + .closest('.MuiFormControl-root') + .find('input') + .click({ force: true }) + + cy.get('.MuiPickersModal-dialogRoot').should('be.visible'); + + if (month || year) { + cy.get('.MuiPickersCalendarHeader-transitionContainer p').then(($header) => { + const headerText = $header.text(); + + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + const currentMonth = months.findIndex((m) => headerText.includes(m)) + 1; + const currentYear = parseInt(headerText.match(/\d{4}/)?.[0]); + + const targetMonth = month ?? currentMonth; + const targetYear = year ?? currentYear; + + const diff = + (targetYear - currentYear) * 12 + (targetMonth - currentMonth); + + const navSelector = diff > 0 + ? '.MuiPickersCalendarHeader-iconButton:last-child' + : '.MuiPickersCalendarHeader-iconButton:first-child'; + + Cypress._.times(Math.abs(diff), () => { + cy.get(navSelector).click(); + cy.get('.MuiPickersCalendarHeader-transitionContainer').should('not.have.class', 'MuiPickersSlideTransition-slideEnter'); + }); + }); + } + + cy.get('.MuiPickersCalendar-transitionContainer') + .find('.MuiPickersDay-day:not(.MuiPickersDay-hidden)') + .each(($el) => { + if ($el.find('p').text().trim() === String(day)) { + cy.wrap($el).click(); + return false; // stoppe le .each() dès le premier match + } + }); + + cy.get('.MuiPickersModal-withAdditionalAction') + .contains('button', 'OK') + .click(); +}); + +Cypress.Commands.add('save', () => { + cy.get('[aria-label="Save changes"]') + .find('button') + .should('be.visible') + .and('not.be.disabled') + .click(); +}); + +Cypress.Commands.add('openRow', (value) => { + cy.contains(value, { matchCase: false }).parents('tr').first().dblclick({ force: true }); +}); + +Cypress.Commands.add('clickButtonByText', (text, options = {}) => { + const { force = true, ...containsOptions } = options; + + cy.contains('button', text, { matchCase: false, ...containsOptions }) + .should('be.visible') + .click({ force }); +}); + +Cypress.Commands.add('assertMuiInput', (label, value, inputTag = 'input') => { cy.contains('label', label) .siblings('.MuiInputBase-root') .find(inputTag) @@ -641,7 +821,17 @@ Cypress.Commands.add('assertMuiInput', (label, value, inputTag='input') => { .and('have.value', value); }) -Cypress.Commands.add('assertMuiInputDisabled', (label, value=null, inputTag='input') => { +Cypress.Commands.add('goToSubMenu', (menu, submenu) => { + cy.contains(menu).click(); + + if (typeof submenu === 'string' && submenu.startsWith('/')) { + cy.get(`a[href="${submenu}"]`).click(); + } else { + cy.contains('a', submenu).click(); + } +}); + +Cypress.Commands.add('assertMuiInputDisabled', (label, value = null, inputTag = 'input') => { const input = cy.contains('label', label) .siblings('.MuiInputBase-root') .find(inputTag) @@ -678,27 +868,27 @@ Cypress.Commands.add('chooseMuiAutocomplete', (label, value = null) => { }) Cypress.Commands.add('setModuleConfig', (moduleName, configFixtureFile) => { - cy.deleteModuleConfig(moduleName) - - cy.contains('a', 'Module configurations').click() - - // Create module config using fixture config file - cy.contains('a', 'Add module configuration').click() - cy.get('input[name="module"]').type(moduleName) - cy.get('select[name="layer"]').select('backend') - cy.get('input[name="version"]').type(1) - - cy.fixture(configFixtureFile).then((config) => { - const configString = JSON.stringify(config, null, 2); - cy.get('textarea[name="config"]') - .type(configString, { - parseSpecialCharSequences: false, - delay: 0 // Type faster - }); + cy.deleteModuleConfig(moduleName) + + cy.contains('a', 'Module configurations').click() + + // Create module config using fixture config file + cy.contains('a', 'Add module configuration').click() + cy.get('input[name="module"]').type(moduleName) + cy.get('select[name="layer"]').select('backend') + cy.get('input[name="version"]').type(1) + + cy.fixture(configFixtureFile).then((config) => { + const configString = JSON.stringify(config, null, 2); + cy.get('textarea[name="config"]') + .type(configString, { + parseSpecialCharSequences: false, + delay: 0 // Type faster + }); - cy.get('input[value="Save"]').click() - cy.contains("was added successfully") - }) + cy.get('input[value="Save"]').click() + cy.contains("was added successfully") + }) }) Cypress.Commands.add('getItemCount', (itemName) => { @@ -1006,3 +1196,8 @@ Cypress.Commands.add('addGrievanceComment', (commentText, commentData = {}) => { // cy.reload(); cy.contains(commentText).should('exist'); }); + +Cypress.Commands.add('waitForGraphQL', (alias = 'graphqlRequest') => { + cy.intercept('POST', '**/api/graphql').as(alias); + return cy.wait(`@${alias}`); +}); diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index 29fa847..781156b 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -11,5 +11,4 @@ before(() => { after(() => { cy.task('removeSetupFile') -}) - +}) \ No newline at end of file diff --git a/cypress/support/pages/InvoicePage.js b/cypress/support/pages/InvoicePage.js new file mode 100644 index 0000000..2676bb1 --- /dev/null +++ b/cypress/support/pages/InvoicePage.js @@ -0,0 +1,213 @@ +const SELECTORS = { + listbox: '[role="listbox"]', + option: '[role="option"]', + dialog: '[role="dialog"]', + addIcon: 'button.MuiFab-primary', + deleteBtn: 'button[title="Delete"]', + editBtn: '[aria-label="Edit"]', + saveButton: '[title="Save changes"] button', + table: 'table', +}; + +const formatDate = ({ day, month, year }) => { + const mm = String(month).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + return `${year}-${mm}-${dd}`; +}; + +export class InvoicePage { + + goToList() { + cy.contains('Legal and Finance').click(); + cy.contains('a', 'Invoices').click(); + } + + searchByCode(code) { + cy.enterMuiInput('Code', code, 'input'); + cy.contains('button', 'Search').click({ force: true }); + cy.get(SELECTORS.table).should('be.visible'); + } + + openPaymentsTab() { + cy.scrollTo('bottom'); + cy.contains('button', 'Payments').click(); + } + + goToForm(headChfId) { + this.goToInvoicePayments(headChfId); + cy.get(SELECTORS.addIcon).click({ force: true }); + } + + goToInvoicePayments(headChfId){ + this.goToList(); + this.searchByCode(headChfId); + this.openRow(headChfId); + cy.get('input[value="Family"]').should('be.visible'); + this.openPaymentsTab(); + } + + openRow(code) { + cy.contains('td', code) + .closest('tr') + .dblclick(); + } + + selectDropdown(index, value) { + cy.get(SELECTORS.dialog).within(() => { + cy.get('[aria-haspopup="listbox"]').eq(index).click(); + }); + cy.get(SELECTORS.option).contains(value).click(); + cy.get(SELECTORS.listbox).should('not.exist'); + } + + fillForm(payment) { + this.selectDropdown(0, payment.reconciliationStatus); + this.selectDropdown(1, payment.status); + + cy.get(SELECTORS.dialog).within(() => { + const fields = [ + ['Payer Reference', payment.reference], + ['Payer Name', payment.payerName], + ['Code', payment.code], + ['Label', payment.label], + ['Code Thirdparty', payment.codeThirdparty], + ['Receipt Number', payment.receiptNumber], + ['Fees', payment.fees], + ['Amount Received', payment.amountReceived], + ['Payment Origin', payment.paymentOrigin], + ]; + fields.forEach(([label, value]) => cy.enterMuiInput(label, value)); + cy.chooseCraMuiDatePicker('Payment Date', payment.paymentDate); + }); + } + + searchPayment(headChfId, paymentCode) { + this.goToInvoicePayments(headChfId) + + cy.contains('Search Criteria') + .closest('.MuiPaper-root') + .within(() => cy.enterMuiInput('Code', paymentCode, 'input')); + + cy.contains('button', 'Search').click(); + cy.get(SELECTORS.table).should('be.visible'); + } + + verifyExists(invoiceCode, payment) { + this.searchPayment(invoiceCode, payment.code); + cy.contains('td', payment.code).should('be.visible'); + this.verifyRowValues(payment); + } + + verifyRowValues(payment) { + cy.contains('Payments Found') + .closest('.MuiPaper-root') + .within(() => { + cy.contains('tr', payment.code) + .within(() => { + cy.get('input[disabled]') + .should('have.value', payment.reconciliationStatus); + cy.get('td').eq(1).should('have.text', payment.code); + cy.get('td').eq(2).should('have.text', payment.label); + cy.get('td').eq(3).should('have.text', payment.codeThirdparty); + cy.get('td').eq(4).should('have.text', payment.receiptNumber); + cy.get('td').eq(5).should('have.text', `${payment.fees}.00`); + cy.get('td').eq(6).should('have.text', `${payment.amountReceived}.00`); + cy.get('td').eq(7).should('have.text', formatDate(payment.paymentDate)); + cy.get('td').eq(8).should('have.text', payment.paymentOrigin); + cy.get('td').eq(9).should('have.text', payment.reference); + }); + }); + } + + deletePayment(invoiceCode, payment, { failIfMissing = true } = {}) { + this.searchPayment(invoiceCode, payment.code); + cy.scrollTo('right'); + + cy.get('body').then(($body) => { + const exists = $body + .find('tr') + .toArray() + .some((row) => row.innerText.includes(payment.code)); + + if (!exists) { + if (!failIfMissing) { + cy.log(`Payment "${payment.code}" not found — delete aborted`); + return; + } + throw new Error(`Payment "${payment.code}" not found for deletion`); + } + + cy.contains('Payments Found') + .closest('.MuiPaper-root') + .within(() => { + cy.contains('tr', payment.code) + .find(SELECTORS.deleteBtn) + .click(); + }); + + cy.get(SELECTORS.dialog).within(() => { + cy.contains('button', 'OK', { matchCase: false }).click({ force: true }); + }); + cy.waitForGraphQL('deletePayment'); + + this.searchPayment(invoiceCode, payment.code); + cy.contains('td', payment.code).should('not.exist'); + }); + } + + goToFamilyList = () => cy.goToSubMenu('Insurees and Policies', 'Families/Group'); + + searchFamily(code) { + this.goToFamilyList(); + cy.enterMuiInput('Head Ins. No.', code, 'input'); + cy.contains('button', 'Search').click({ force: true }); + cy.waitForGraphQL('searchFamily'); + cy.contains('Families/Groups Found').should('be.visible'); + } + + createFamily(family) { + this.searchFamily(family.head.chfId); + cy.get('body').then(($body) => { + const exists = $body + .find('tr') + .toArray() + .some((row) => row.innerText.includes(family.head.chfId)); + + if (exists) { + return; + } else { + cy.get(SELECTORS.addIcon).click({ force: true }); + this.fillFamilyForm(family); + cy.get(SELECTORS.saveButton).click({ force: true }); + cy.waitForGraphQL('saveFamily'); + } + }); + } + + fillFamilyForm(family) { + cy.chooseMuiSelect('Village', family.location); + cy.enterMuiInput('Insurance No.', family.head.chfId, 'input'); + cy.enterMuiInput('Last Name', family.head.lastName); + cy.enterMuiInput('Given Names', family.head.givenNames); + cy.chooseCraMuiDatePicker('Birth Date', family.head.dob); + cy.chooseMuiSelect('Gender', family.head.gender); + } + + createPolicy(headChfId, policy) { + this.searchFamily(headChfId); + this.openRow(headChfId); + cy.contains('button', 'Add policy').click({ force: true }); + cy.waitForGraphQL('openPolicy'); + this.fillPolicyForm(policy); + cy.get(SELECTORS.saveButton).click({ force: true }); + cy.contains('button', 'Close').click(); + cy.waitForGraphQL('createPolicy'); + } + + fillPolicyForm(policy) { + cy.chooseMuiAutocomplete('Product', policy.product.name); + cy.chooseMuiSelect('Officer', policy.officer.code); + } +} + +export const invoicePage = new InvoicePage(); \ No newline at end of file diff --git a/package.json b/package.json index b21a519..7af7c65 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "cypress-file-upload": "^5.0.8" }, "scripts": { - "cy:open": "cypress open" + "cy:open": "cypress open", + "cy:run": "cypress run" } }