Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#425): E2E test actions #620

Merged
merged 47 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c29a4f8
feat(#425): Create e2e test setup
sugat009 Jan 30, 2024
208fd85
Setup mocha hooks and add to npm run command
sugat009 Jun 20, 2024
7f98495
orchestrate with mocha hooks
m5r Jun 27, 2024
86a0e97
test run in the ci
m5r Jul 1, 2024
4eda2bf
test run in the ci
m5r Jul 1, 2024
8bca220
move session-token test to test/integration folder
m5r Jul 1, 2024
1402640
add trace logs
m5r Jul 1, 2024
0900742
add trace logs
m5r Jul 1, 2024
8143ac8
remove traces
m5r Jul 1, 2024
25140ba
split logic if we have an existing project configuration file
m5r Jul 1, 2024
a896345
refactor hooks and extract utils functions to deal with cht-docker-he…
m5r Jul 3, 2024
d456a26
refactor `spinUpCht` to fix eslint warning about promise executor can…
m5r Jul 3, 2024
639b888
finally get the first e2e test out, working as expected
m5r Jul 3, 2024
6c3d8f1
trace errors in CI
m5r Jul 4, 2024
cf64ad3
check for existence of project configuration before running teardown
m5r Jul 4, 2024
fb12834
add more traces
m5r Jul 4, 2024
64887d4
do we have the latest version of the script?
m5r Jul 4, 2024
8009130
what's wrong with `docker exec`?
m5r Jul 4, 2024
1b26d68
create make parent directories as needed same as `mkdir -p`
m5r Jul 4, 2024
d85caa1
organize todos, remove debugging logs
m5r Jul 4, 2024
2707755
replace 4 spaces => 2 spaces to follow coding style in the repo
m5r Jul 4, 2024
d24846b
oops forgot these
m5r Jul 4, 2024
e2757eb
extract utils functions to reuse in other tests
m5r Jul 4, 2024
1867912
logs
m5r Jul 4, 2024
2d71043
pass project name to `runChtConf` as expected
m5r Jul 4, 2024
4fcac95
await getProjectUrl()
m5r Jul 4, 2024
626bcc5
sonar :)
m5r Jul 4, 2024
c065b78
clean up
m5r Jul 9, 2024
c22e98e
more clean up
m5r Jul 9, 2024
e36c2e2
clearer test title
m5r Jul 9, 2024
5c78268
- switch back to cht-docker-compose.sh from cht-core master
m5r Jul 10, 2024
9b1cfcc
add trace
m5r Jul 10, 2024
9ec0f86
clean up trace
m5r Jul 10, 2024
25155e7
replace hardcoded package.json name
m5r Jul 10, 2024
b0d8f46
add comments to explain the rationale behind the `stdio` option when …
m5r Jul 10, 2024
9688f80
remove linting before running e2e tests
m5r Jul 11, 2024
6899214
increase timeout to prevent frequent failures due to CHT instance tak…
m5r Jul 11, 2024
52d59e0
remove `.cht-docker-helper` in teardown
m5r Jul 11, 2024
3f57497
fix import of `DEFAULT_PROJECT_NAME`
m5r Jul 11, 2024
dcafa3e
dedup code in `initProject`
m5r Jul 11, 2024
8cab016
remove unnecessary `structuredClone`
m5r Jul 11, 2024
0b2379a
add assertions about `baseSettings.language`
m5r Jul 11, 2024
e29816f
throw error early when config file doesn't exist
m5r Jul 11, 2024
116d5b2
extract `readCompiledAppSettings` & `writeBaseAppSettings` cht conf u…
m5r Jul 11, 2024
ebbd7e9
touch a word about e2e tests in readme
m5r Jul 11, 2024
fcdd4cf
it's better with the right npm script...
m5r Jul 11, 2024
97fc47a
format
m5r Jul 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,22 @@ jobs:
coverage
.nyc_output
if: ${{ failure() }}

e2e:
jkuester marked this conversation as resolved.
Show resolved Hide resolved
name: E2E tests
runs-on: ubuntu-22.04

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18.x
- name: Install dependencies
run: |
pip install git+https://github.com/medic/[email protected]#egg=pyxform-medic
npm ci
- name: Hard code local-ip IP in /etc/hosts per https://github.com/medic/medic-infrastructure/issues/571#issuecomment-2209120441
run: |
echo "15.188.129.97 local-ip.medicmobile.org" | sudo tee -a /etc/hosts
- name: Run E2E tests
run: npm run test-e2e
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ coverage
.nyc_output
.DS_Store
test/.DS_Store
test/e2e/.cht-docker-helper
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,16 @@ To develop a new action or improve an existing one, check the ["Actions" doc](sr

## Testing

### Unit tests

Execute `npm test` to run static analysis checks and the test suite. Requires Docker to run integration tests against a CouchDB instance.

### End-to-end tests

Run `npm run test-e2e` to run the end-to-end test suite against an actual CHT instance locally. These tests rely on [CHT Docker Helper](https://docs.communityhealthtoolkit.org/hosting/4.x/app-developer/#cht-docker-helper-for-4x) to spin up and tear down an instance locally.

The code interfacing with CHT Docker Helper lives in [`test/e2e/cht-docker-utils.js`](./test/e2e/cht-docker-utils.js). You should rely on the API exposed by this file to orchestrate CHT instances for testing purposes. It is preferable to keep the number of CHT instances orchestrated in E2E tests low as it takes a non-negligible amount of time to spin up an instance and can quickly lead to timeouts.

## Executing your local branch

1. Clone the project locally
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"eslint": "eslint 'src/**/*.js' test/*.js 'test/**/*.js'",
"docker-start-couchdb": "npm run docker-stop-couchdb && docker run -d -p 6984:5984 --rm --name cht-conf-couchdb couchdb:2.3.1 && sh test/scripts/wait_for_response_code.sh 6984 200 CouchDB",
"docker-stop-couchdb": "docker stop cht-conf-couchdb || true",
"test": "npm run eslint && npm run docker-start-couchdb && npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha --forbid-only \"../../test/**/*.spec.js\" && cd ../.. && npm run docker-stop-couchdb",
"test": "npm run eslint && npm run docker-start-couchdb && npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha --forbid-only \"../../test/**/*.spec.js\" --exclude \"../../test/e2e/**/*.spec.js\" && cd ../.. && npm run docker-stop-couchdb",
"test-e2e": "mocha --config test/e2e/.mocharc.js",
"semantic-release": "semantic-release"
},
"bin": {
Expand Down
14 changes: 14 additions & 0 deletions test/e2e/.mocharc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
allowUncaught: false,
color: true,
checkLeaks: true,
fullTrace: true,
asyncOnly: false,
spec: ['test/e2e/**/*.spec.js'],
timeout: 120_000, // spinning up a CHT instance takes a little long
reporter: 'spec',
file: ['test/e2e/hooks.js'],
captureFile: 'test/e2e/results.txt',
exit: true,
recursive: true,
};
76 changes: 76 additions & 0 deletions test/e2e/cht-conf-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const path = require('path');
const { exec } = require('child_process');
const fs = require('fs');
const fse = require('fs-extra');

const log = require('../../src/lib/log');
const { getProjectUrl } = require('./cht-docker-utils');

const getProjectDirectory = (projectName) => path.resolve(__dirname, `../../build/${projectName}`);

const runChtConf = (projectName, command) => new Promise((resolve, reject) => {
getProjectUrl(projectName).then(url => {
const projectDirectory = getProjectDirectory(projectName);
const cliPath = path.join(__dirname, '../../src/bin/index.js');
exec(`node ${cliPath} --url=${url} ${command}`, { cwd: projectDirectory }, (error, stdout, stderr) => {
if (!error) {
return resolve(stdout);
}

log.error(stderr);
reject(new Error(stdout.toString('utf8')));
});
});
});

const cleanupProject = (projectName) => {
const projectDirectory = getProjectDirectory(projectName);
if (fs.existsSync(projectDirectory)) {
fse.removeSync(projectDirectory);
}
};

const initProject = async (projectName) => {
const projectDirectory = getProjectDirectory(projectName);
cleanupProject(projectName);

fse.mkdirpSync(projectDirectory);
fs.writeFileSync(
jkuester marked this conversation as resolved.
Show resolved Hide resolved
path.join(projectDirectory, 'package.json'),
JSON.stringify({
name: projectName,
version: '1.0.0',
dependencies: {
'cht-conf': 'file:../..',
},
}, null, 4),
);

await runChtConf(projectName, 'initialise-project-layout');
};

const writeBaseAppSettings = async (projectName, baseSettings) => {
const projectDirectory = getProjectDirectory(projectName);

return await fs.promises.writeFile(
path.join(projectDirectory, 'app_settings/base_settings.json'),
JSON.stringify(baseSettings, null, 2),
);
};

const readCompiledAppSettings = async (projectName) => {
const projectDirectory = getProjectDirectory(projectName);

return JSON.parse(
await fs.promises.readFile(path.join(projectDirectory, 'app_settings.json'), 'utf8')
);
};

module.exports = {
cleanupProject,
getProjectDirectory,
initProject,
runChtConf,
readCompiledAppSettings,
writeBaseAppSettings,
};
129 changes: 129 additions & 0 deletions test/e2e/cht-docker-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const path = require('path');
const fs = require('fs');
const https = require('https');
const { spawn } = require('child_process');
const fse = require('fs-extra');
const request = require('request-promise-native');

const log = require('../../src/lib/log');

const DEFAULT_PROJECT_NAME = 'cht_conf_e2e_tests';
const dockerHelperDirectory = path.resolve(__dirname, '.cht-docker-helper');
const dockerHelperScript = path.resolve(dockerHelperDirectory, './cht-docker-compose.sh');

const downloadDockerHelperScript = () => new Promise((resolve, reject) => {
const file = fs.createWriteStream(dockerHelperScript, { mode: 0o755 });
https
.get('https://raw.githubusercontent.com/medic/cht-core/master/scripts/docker-helper-4.x/cht-docker-compose.sh', (response) => {
response.pipe(file);
file.on('finish', () => file.close(resolve));
file.on('error', () => file.close(reject));
})
.on('error', () => {
fs.unlinkSync(file.path);
file.close(() => reject('Failed to download CHT Docker Helper script "cht-docker-compose.sh"'));
});
});

const ensureScriptExists = async () => {
if (!fs.existsSync(dockerHelperDirectory)) {
await fs.promises.mkdir(dockerHelperDirectory);
}

if (!fs.existsSync(dockerHelperScript)) {
jkuester marked this conversation as resolved.
Show resolved Hide resolved
await downloadDockerHelperScript();
}
};

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

const getProjectConfig = async (projectName) => {
const configFilePath = path.resolve(dockerHelperDirectory, `${projectName}.env`);
if (!fs.existsSync(configFilePath)) {
throw new Error(`Unexpected error: config file not found at ${configFilePath}`);
}

const configFile = await fs.promises.readFile(configFilePath, 'utf8');
return Object.fromEntries(
configFile.toString()
.split('\n')
.map(line => line.split('='))
.filter(entry => entry.length === 2),
);
};

const getProjectUrl = async (projectName = DEFAULT_PROJECT_NAME) => {
const config = await getProjectConfig(projectName);
const { COUCHDB_USER, COUCHDB_PASSWORD, NGINX_HTTPS_PORT } = config;
return `https://${COUCHDB_USER}:${COUCHDB_PASSWORD}@127-0-0-1.local-ip.medicmobile.org:${NGINX_HTTPS_PORT}`;
};

const isProjectReady = async (projectName, attempt = 1) => {
log.info(`Checking if CHT is ready, attempt ${attempt}.`);
const url = await getProjectUrl(projectName);
await request({ uri: `${url}/api/v2/monitoring`, json: true })
.catch(async (error) => {
if (
error.error.code !== 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
![502, 503].includes(error.statusCode)
) {
// unexpected error, log it to keep a trace,
// but we'll keep retrying until the instance is up, or we hit the timeout limit
log.trace(error);
}

await sleep(1000);
return isProjectReady(projectName, attempt + 1);
});
};

const startProject = (projectName) => new Promise((resolve, reject) => {
log.info(`Starting CHT instance "${projectName}"`);

// stdio: 'pipe' to answer the prompts to initialize a project by writing to stdin
const childProcess = spawn(dockerHelperScript, { stdio: 'pipe', cwd: dockerHelperDirectory });
childProcess.on('error', reject);
childProcess.on('close', async () => {
await isProjectReady(projectName);
resolve();
});

childProcess.stdin.write('y\n');
childProcess.stdin.write('y\n');
childProcess.stdin.write(`${projectName}\n`);
});

const destroyProject = (projectName) => new Promise((resolve, reject) => {
// stdio: 'inherit' to see the script's logs and understand why it requests elevated permissions when cleaning up project files
const childProcess = spawn(dockerHelperScript, [`${projectName}.env`, 'destroy'], {
stdio: 'inherit',
cwd: dockerHelperDirectory,
});
childProcess.on('error', reject);
childProcess.on('close', resolve);
});

const spinUpCht = async (projectName = DEFAULT_PROJECT_NAME) => {
await ensureScriptExists();
await startProject(projectName);
};

const tearDownCht = async (projectName = DEFAULT_PROJECT_NAME) => {
if (!fs.existsSync(dockerHelperDirectory)) {
return;
}

if (fs.existsSync(path.resolve(dockerHelperDirectory, `${projectName}.env`))) {
await ensureScriptExists();
await destroyProject(projectName);
}

fse.removeSync(dockerHelperDirectory);
};

module.exports = {
DEFAULT_PROJECT_NAME,
getProjectUrl,
spinUpCht,
tearDownCht,
};
60 changes: 60 additions & 0 deletions test/e2e/edit-app-settings.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { expect } = require('chai');
const request = require('request-promise-native');

const { DEFAULT_PROJECT_NAME, getProjectUrl } = require('./cht-docker-utils');
const {
cleanupProject,
initProject,
runChtConf,
readCompiledAppSettings,
writeBaseAppSettings,
} = require('./cht-conf-utils');

describe('edit-app-settings', () => {
const projectName = DEFAULT_PROJECT_NAME;

before(async () => {
await initProject(projectName);
});

after(async () => {
await cleanupProject(projectName);
});

it('disables a language, recompile, and push app settings', async () => {
const url = await getProjectUrl(projectName);
const baseSettings = await request.get({ url: `${url}/api/v1/settings`, json: true });
baseSettings.languages.forEach(language => expect(language.enabled).to.be.true);
expect(baseSettings.locale).to.equal('en');
expect(baseSettings.locale_outgoing).to.equal('en');

baseSettings.languages = baseSettings.languages.map(language => {
if (language.locale === 'en') {
language.enabled = false;
}

return language;
});
baseSettings.locale = 'fr';
baseSettings.locale_outgoing = 'fr';
await writeBaseAppSettings(projectName, baseSettings);

await runChtConf(projectName, 'compile-app-settings');
const compiledSettings = await readCompiledAppSettings(projectName);
expect(compiledSettings.languages.find(language => language.locale === 'en')).to.deep.equal({
locale: 'en',
enabled: false,
});
expect(compiledSettings.locale).to.equal('fr');
expect(compiledSettings.locale_outgoing).to.equal('fr');

await runChtConf(projectName, 'upload-app-settings');
const newSettings = await request.get({ url: `${url}/api/v1/settings`, json: true });
expect(newSettings.languages.find(language => language.locale === 'en')).to.deep.equal({
locale: 'en',
enabled: false,
});
expect(newSettings.locale).to.equal('fr');
expect(newSettings.locale_outgoing).to.equal('fr');
});
});
11 changes: 11 additions & 0 deletions test/e2e/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { spinUpCht, tearDownCht } = require('./cht-docker-utils');

before(async () => {
// cleanup eventual leftovers before starting
await tearDownCht();
await spinUpCht();
});

after(async () => {
await tearDownCht();
});
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const runCliCommand = (command) => {
});
};

describe('e2e/session-token', function() {
describe('integration/session-token', function() {
this.timeout(15000);

let sessionToken;
Expand Down Expand Up @@ -149,4 +149,4 @@ describe('e2e/session-token', function() {
// Bad Request: Malformed AuthSession cookie
.that.contains('INFO Error: Received error code 400');
});
});
});