Skip to content

Commit 91bfe30

Browse files
authored
Merge pull request #79 from openimis/feature/coremis-e2e
Feature: add base and coremis end-to-end tests
2 parents 38dddd4 + 48e1a1c commit 91bfe30

17 files changed

Lines changed: 2036 additions & 71 deletions

.github/workflows/ci.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
with:
2323
fetch-depth: 0
2424
- name: SonarCloud Scan
25-
uses: SonarSource/sonarcloud-github-action@master
25+
uses: SonarSource/sonarqube-scan-action@1a6d90ebcb0e6a6b1d87e37ba693fe453195ae25
2626
env:
2727
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2828
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -42,8 +42,11 @@ jobs:
4242
rm -f .env.bak
4343
echo 'SECRET_KEY=cypressci' >> .env
4444
echo 'DEMO_DATASET=true' >> .env
45+
echo 'DB_TAG=25.04' >> .env
4546
echo 'BE_TAG=develop' >> .env
4647
echo 'FE_TAG=develop' >> .env
48+
echo 'DOMAIN=localhost' >> .env
49+
echo 'HOSTS=localhost' >> .env
4750
cp .env.openSearch.example .env.openSearch
4851
4952
docker compose up -d
@@ -66,7 +69,7 @@ jobs:
6669
exit 1
6770
6871
- name: Cypress run
69-
uses: cypress-io/github-action@v6
72+
uses: cypress-io/github-action@b8ba51a856ba5f4c15cf39007636d4ab04f23e3c
7073
with:
7174
record: false
7275
parallel: false

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ data/*
77
openimis-dist_dkr.code-workspace
88
node_modules/
99
cypress/screenshots/
10+
cypress/downloads/
11+
cypress/fixtures/tmp_individuals.csv

CONTRIBUTOR.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
# CONTRIBUTING
22

3-
This repository provides a Docker package for openIMIS that includes all components to quickly setup, test and demo the solution.
4-
3+
This repository provides a Docker package for openIMIS that includes all components to quickly setup, test and demo the solution.
54

6-
If you are only looking to setup and test the solution please look for further instructions on the [openIMIS Wiki](https://openimis.atlassian.net/wiki/spaces/OP/pages/963182705/MO1.1+Install+the+modular+openIMIS+using+Docker)
5+
If you are only looking to setup and test the solution please look for further instructions on the [openIMIS Wiki](https://openimis.atlassian.net/wiki/spaces/OP/pages/963182705/MO1.1+Install+the+modular+openIMIS+using+Docker)
76

8-
9-
The openIMIS docker compose currently includes the database, backend and worker, frontend, and third parties components (Lightning, OpenSearch, RabbitMQ, etc.).
10-
7+
The openIMIS docker compose currently includes the database, backend and worker, frontend, and third parties components (Lightning, OpenSearch, RabbitMQ, etc.).
118

129
In case of troubles, please contact our service desk via our [ticketing platform](https://openimis.atlassian.net/servicedesk/customer).
1310

@@ -104,5 +101,9 @@ This can be useful for local development or verifying a staging deployment,
104101
for example, if the target host is localhost:3000,
105102
pass it into the corresponding test command with `-- --config "baseUrl=http://localhost:3000"`:
106103

107-
- Headless: `npx cypress run --config "baseUrl=http://localhost:3000"`
108-
- Headed: `npx cypress open --config "baseUrl=http://localhost:3000"`
104+
Additionally, if you are using social protection specific language pack,
105+
e.g. benefit plan would be called programme, you can pass in
106+
`--env useSocialProtectionLanguagePack=true`
107+
108+
- Headless: `npx cypress run --config "baseUrl=http://localhost:3000" --env useSocialProtectionLanguagePack=true`
109+
- Headed: `npx cypress open --config "baseUrl=http://localhost:3000" --env useSocialProtectionLanguagePack=true`

cypress.config.js

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ const serverFlagPath = path.resolve(__dirname, 'serverStarted')
77
const payload = {
88
"query": "{ moduleConfigurations { module, config, controls { field, usage } } }"
99
}
10-
const timeout = 4 * 60 * 1000 // 4 minutes
11-
const interval = 10000 // Check every 10 seconds
10+
const timeoutMinutes = 10
11+
const retryIntervalSeconds = 15
1212

1313
function waitForServerToStart(url) {
1414
console.log('Waiting for API server to start...')
@@ -30,11 +30,13 @@ function waitForServerToStart(url) {
3030
}
3131
})
3232
.catch(error => {
33-
if (Date.now() - startTime >= timeout) {
34-
return reject(new Error('Timed out waiting for the server to start'))
33+
if (Date.now() - startTime >= timeoutMinutes * 60 * 1000) {
34+
return reject(
35+
new Error(`Timed out waiting for the server to start: ${error.message}`)
36+
)
3537
} else {
36-
console.log(`Retrying in ${interval / 1000} seconds...`)
37-
return setTimeout(checkServer, interval)
38+
console.log(`${error.message}. Retrying in ${retryIntervalSeconds} seconds...`)
39+
return setTimeout(checkServer, retryIntervalSeconds * 1000)
3840
}
3941
})
4042
}
@@ -44,36 +46,83 @@ function waitForServerToStart(url) {
4446
}
4547

4648
module.exports = defineConfig({
49+
viewportWidth: 1280,
50+
viewportHeight: 670,
4751
e2e: {
4852
projectId: "q6gc25", // Cypress Cloud, needed for recording
49-
baseUrl: 'http://localhost/front',
50-
defaultCommandTimeout: 10000,
51-
taskTimeout: 300000,
53+
baseUrl: 'http://localhost',
54+
defaultCommandTimeout: 15000,
55+
taskTimeout: timeoutMinutes * 60 * 1000 + 10,
56+
downloadsFolder: 'cypress/downloads',
5257
setupNodeEvents(on, config) {
5358
on('task', {
54-
checkSetup() {
55-
return fs.existsSync(serverFlagPath)
56-
},
57-
completeSetup() {
58-
const rootUrl = config.baseUrl.replace("/front", "")
59-
const url = `${rootUrl}/api/graphql`
60-
return waitForServerToStart(url)
61-
.then(() => {
62-
console.log('Server is ready!')
63-
fs.writeFileSync(serverFlagPath, new Date().toString())
64-
return null
65-
})
66-
.catch(error => {
67-
console.error('Failed to start server:', error)
68-
return null
69-
})
70-
},
71-
removeSetupFile() {
72-
if (fs.existsSync(serverFlagPath)) {
73-
fs.unlinkSync(serverFlagPath)
59+
checkSetup() {
60+
return fs.existsSync(serverFlagPath)
61+
},
62+
completeSetup() {
63+
const url = `${config.baseUrl}/api/graphql`
64+
return waitForServerToStart(url)
65+
.then(() => {
66+
console.log('Server is ready!')
67+
fs.writeFileSync(serverFlagPath, new Date().toString())
68+
return null
69+
})
70+
.catch(error => {
71+
console.error('Failed to start server:')
72+
console.error(error.stack);
73+
return reject(error);
74+
})
75+
},
76+
removeSetupFile() {
77+
if (fs.existsSync(serverFlagPath)) {
78+
fs.unlinkSync(serverFlagPath)
79+
}
80+
return null
81+
},
82+
updateCSV({ numIndividuals } = {}) {
83+
const fixturePath = path.join(config.fixturesFolder, 'individuals.csv');
84+
const tmpPath = path.join(config.fixturesFolder, 'tmp_individuals.csv');
85+
const csv = fs.readFileSync(fixturePath, 'utf8');
86+
87+
const lines = csv.trim().split('\n');
88+
const header = lines[0];
89+
const rows = lines.slice(1);
90+
const headerCols = header.split(',');
91+
const groupCodeIdx = headerCols.indexOf('group_code');
92+
93+
// map old group_code → new group_code
94+
const groupMap = {};
95+
96+
// Subset or duplicate rows
97+
let adjustedRows = [];
98+
if (numIndividuals && numIndividuals > 0) {
99+
if (numIndividuals <= rows.length) {
100+
adjustedRows = rows.slice(0, numIndividuals);
101+
} else {
102+
const times = Math.floor(numIndividuals / rows.length);
103+
const remainder = numIndividuals % rows.length;
104+
adjustedRows = Array(times).fill(rows).flat().concat(rows.slice(0, remainder));
74105
}
75-
return null
106+
} else {
107+
adjustedRows = rows;
76108
}
109+
110+
const newLines = [header];
111+
for (const line of adjustedRows) {
112+
const row = line.split(',');
113+
const oldCode = row[groupCodeIdx];
114+
115+
if (!groupMap[oldCode]) {
116+
groupMap[oldCode] = Math.random().toString(36).substring(2, 8).toUpperCase();
117+
}
118+
119+
row[groupCodeIdx] = groupMap[oldCode];
120+
newLines.push(row.join(','));
121+
}
122+
123+
fs.writeFileSync(tmpPath, newLines.join('\n'));
124+
return null;
125+
}
77126
})
78127
},
79128
},

cypress/e2e/admin.cy.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { capitalizeWords } from '../support/utils';
2+
3+
const path = require('path');
4+
5+
describe('Django admin workflows', () => {
6+
before(function () {
7+
// This ensures that Admin user's core user exists
8+
// because is only auto provisioned on first login to the frontend UI
9+
cy.login()
10+
cy.logout()
11+
});
12+
13+
beforeEach(function () {
14+
cy.loginAdminInterface()
15+
});
16+
17+
it('Configures menu', function () {
18+
cy.deleteModuleConfig("fe-core")
19+
cy.visit('/front')
20+
cy.get('div.MuiToolbar-root').should('exist') // default top toolbar menu
21+
22+
cy.visit('/api/admin');
23+
cy.contains('a', 'Module configurations').click()
24+
25+
// Create menu config using fixture config file
26+
cy.contains('a', 'Add module configuration').click()
27+
cy.get('input[name="module"]').type('fe-core')
28+
cy.get('select[name="layer"]').select('frontend')
29+
cy.get('input[name="version"]').type(1)
30+
31+
cy.fixture('menu-config-sp.json').then((config) => {
32+
const configString = JSON.stringify(config, null, 2);
33+
cy.get('textarea[name="config"]')
34+
.type(configString, {
35+
parseSpecialCharSequences: false,
36+
delay: 0 // Type faster
37+
});
38+
cy.get('input[name="is_exposed"]').check()
39+
40+
cy.get('input[value="Save"]').click()
41+
42+
cy.visit('/front')
43+
cy.get('div.MuiDrawer-root').should('exist') // left drawer menu
44+
45+
const expectedMenuItems = [
46+
'Social Protection',
47+
'Dashboards',
48+
'Payments',
49+
'Grievance',
50+
'Tasks Management',
51+
'Administration',
52+
]
53+
const programMenuText = 'programmes'
54+
const expectedSubMenuItems = [
55+
'Individuals',
56+
'Groups',
57+
'Import Data - API',
58+
programMenuText,
59+
]
60+
cy.get('div.MuiDrawer-root').first().within(() => {
61+
cy.shouldHaveMenuItemsInOrder(expectedMenuItems)
62+
63+
cy.contains('div[role="button"]', 'Social Protection').click();
64+
65+
cy.contains('div[role="button"]', 'Social Protection')
66+
.siblings('.MuiCollapse-root').within(() => {
67+
cy.shouldHaveMenuItemsInOrder(expectedSubMenuItems)
68+
69+
// Verify submenu persistence selected state
70+
cy.contains('div[role="button"]', programMenuText).click();
71+
cy.contains('div.Mui-selected[role="button"]', programMenuText);
72+
73+
cy.contains('div[role="button"]', 'Individuals').click();
74+
cy.contains('div.Mui-selected[role="button"]', 'Individuals');
75+
76+
cy.visit('/front/benefitPlans')
77+
cy.contains('div.Mui-selected[role="button"]', programMenuText);
78+
})
79+
});
80+
})
81+
})
82+
83+
it('Configuring individual json schema reflects in advanced filters and upload template', function () {
84+
cy.setModuleConfig('individual', 'individual-config-minimal.json')
85+
86+
cy.visit('/front/individuals')
87+
cy.contains('li', 'UPLOAD').click()
88+
cy.contains('button', 'Template').click()
89+
90+
const downloadedFilename = path.join(
91+
Cypress.config('downloadsFolder'),
92+
'individual_upload_template.csv'
93+
);
94+
cy.readFile(downloadedFilename, { timeout: 15000 }).should('exist');
95+
96+
cy.readFile(downloadedFilename)
97+
.then(async (text) => {
98+
expect(text.length).to.be.greaterThan(0);
99+
expect(text).to.contain('able_bodied');
100+
expect(text).to.contain('educated_level');
101+
expect(text).to.contain('number_of_children');
102+
});
103+
104+
cy.contains('button', 'Cancel').click()
105+
cy.contains('button', 'Advanced Filters').click()
106+
cy.get('div[role="dialog"] div.MuiSelect-select').click()
107+
cy.contains('li[role="option"]', 'Able bodied')
108+
cy.contains('li[role="option"]', 'Educated level')
109+
cy.contains('li[role="option"]', 'Number of children')
110+
})
111+
112+
it('Configures project activities', function () {
113+
const activities = [
114+
'E2E Tree Planting',
115+
'E2E River Cleaning',
116+
'E2E Soil Conservation',
117+
'E2E Extra',
118+
];
119+
const newName = 'E2E Water Conservation'
120+
121+
cy.deleteActivities(activities.concat([newName]))
122+
123+
// Create
124+
activities.forEach(activityName => {
125+
cy.contains('a', 'Activities').click()
126+
cy.contains('a', 'Add Activity').click()
127+
cy.get('input[name="name"]').type(activityName)
128+
cy.get('input[value="Save"]').click()
129+
cy.contains('td.field-name', activityName)
130+
})
131+
132+
// Update
133+
cy.contains('td.field-name', 'E2E Soil Conservation')
134+
.parent('tr')
135+
.find('th.field-id a')
136+
.click();
137+
cy.get('input[name="name"]').clear().type(newName)
138+
cy.get('input[value="Save"]').click()
139+
cy.contains('td.field-name', newName)
140+
.parent('tr')
141+
.within(() => {
142+
cy.get('td.field-name').should('contain', newName);
143+
cy.get('td.field-version').should('have.text', '2');
144+
});
145+
146+
// Soft Delete
147+
cy.contains('td.field-name', 'E2E Extra')
148+
.parent('tr')
149+
.find('th.field-id a')
150+
.click();
151+
cy.get('input[name="is_deleted"]').check()
152+
cy.get('input[value="Save"]').click()
153+
cy.contains('td.field-name', 'E2E Extra')
154+
.siblings('td.field-is_deleted')
155+
.find('img[alt="True"]')
156+
.should('exist');
157+
})
158+
})
159+

0 commit comments

Comments
 (0)