Merge pull request #3338 from NoelDeMartin/MOBILE-4110

MOBILE-4110: Behat improvements
main
Dani Palou 2022-07-07 09:09:57 +02:00 committed by GitHub
commit 1f81ea3513
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 758 additions and 421 deletions

View File

@ -63,3 +63,24 @@ function notify_on_error_exit {
exit 1 exit 1
fi fi
} }
function get_behat_plugin_changes_diff {
i=0
previoushash=""
currenthash=`git rev-parse HEAD`
initialhash=`git rev-list HEAD | tail -n 1`
totalcommits=`git log --oneline | wc -l`
repositoryname=`echo $GITHUB_REPOSITORY | sed "s/\\//\\\\\\\\\\//"`
((totalcommits--))
while [ $i -lt $totalcommits ] && [[ -z $previoushash ]]; do
previoushash=`git rev-list --format=%B --max-count=1 HEAD~$i | grep -o "https:\/\/github\.com\/$repositoryname\/compare\/[^.]\+\.\.\.[^.]\+" | sed "s/https:\/\/github\.com\/$repositoryname\/compare\/[^.]\+\.\.\.//"`
((i++))
done
if [[ -z $previoushash ]]; then
previoushash=$initialhash
fi
echo "$previoushash...$currenthash"
}

View File

@ -0,0 +1,68 @@
#!/bin/bash
source "./.github/scripts/functions.sh"
if [ -z $GIT_TOKEN ] || [ -z $BEHAT_PLUGIN_GITHUB_REPOSITORY ] || [ -z $BEHAT_PLUGIN_BRANCH ]; then
print_error "Env vars not correctly defined"
exit 1
fi
if [[ $BEHAT_PLUGIN_BRANCH != $GITHUB_REF_NAME ]]; then
echo "Script disabled for this branch"
exit 0
fi
# Clone plugin repository.
print_title "Cloning Behat plugin repository..."
git clone https://$GIT_TOKEN@github.com/$BEHAT_PLUGIN_GITHUB_REPOSITORY.git tmp/local_moodleappbehat -b integration
pluginversion=$(cat tmp/local_moodleappbehat/version.php | grep "\$plugin->version" | grep -o -E "[0-9]+")
# Auto-generate plugin.
print_title "Building Behat plugin..."
if [ -z $BEHAT_PLUGIN_EXCLUDE_FEATURES ]; then
scripts/build-behat-plugin.js tmp/local_moodleappbehat
else
scripts/build-behat-plugin.js tmp/local_moodleappbehat --exclude-features
fi
notify_on_error_exit "Unsuccessful build, stopping..."
# Check if there are any changes (ignoring plugin version).
print_title "Checking changes..."
newpluginversion=$(cat tmp/local_moodleappbehat/version.php | grep "\$plugin->version" | grep -o -E "[0-9]+")
sed -i s/\$plugin-\>version\ =\ [0-9]\\+\;/\$plugin-\>version\ =\ $pluginversion\;/ tmp/local_moodleappbehat/version.php
if [[ -z `git -C tmp/local_moodleappbehat/ status --short` ]]; then
echo "There weren't any changes to apply!"
exit
fi
if [[ $pluginversion -eq $newpluginversion ]]; then
((newpluginversion++))
fi
sed -i s/\$plugin-\>version\ =\ [0-9]\\+\;/\$plugin-\>version\ =\ $newpluginversion\;/ tmp/local_moodleappbehat/version.php
# Apply new changes
print_title "Applying changes to repository..."
cd tmp/local_moodleappbehat
diff=`get_behat_plugin_changes_diff`
# Set up Github Actions bot user
# See https://github.community/t/github-actions-bot-email-address/17204/6
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .
git commit -m "[auto-generated] Update plugin files
Check out the commits that caused these changes: https://github.com/$GITHUB_REPOSITORY/compare/$diff
"
notify_on_error_exit "Unsuccessful commit, stopping..."
echo "Pushing changes..."
git push
notify_on_error_exit "Unsuccessful push, stopping..."
echo "Behat plugin updated!"

View File

@ -1,10 +1,10 @@
name: Behat tests name: Acceptance tests (Behat)
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tags: behat_tags:
description: 'Execute tags' description: 'Behat tags to execute'
required: true required: true
default: '~@performance' default: '~@performance'
moodle_branch: moodle_branch:
@ -15,6 +15,10 @@ on:
description: 'Moodle repository' description: 'Moodle repository'
required: true required: true
default: 'https://github.com/moodle/moodle' default: 'https://github.com/moodle/moodle'
pull_request:
branches:
- integration
- unscheduled
jobs: jobs:
behat: behat:
@ -23,6 +27,9 @@ jobs:
MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: 7.3 MOODLE_DOCKER_PHP_VERSION: 7.3
MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }}
MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }}
BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- id: nvmrc - id: nvmrc
@ -32,7 +39,7 @@ jobs:
node-version: '${{ steps.nvmrc.outputs.node_version }}' node-version: '${{ steps.nvmrc.outputs.node_version }}'
- name: Additional checkouts - name: Additional checkouts
run: | run: |
git clone --branch ${{ github.event.inputs.moodle_branch }} --depth 1 ${{ github.event.inputs.moodle_repository }} $GITHUB_WORKSPACE/moodle git clone --branch $MOODLE_BRANCH --depth 1 $MOODLE_REPOSITORY $GITHUB_WORKSPACE/moodle
git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages - name: Install npm packages
run: npm ci --no-audit run: npm ci --no-audit
@ -48,9 +55,9 @@ jobs:
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db
- name: Compile & launch production app with Docker - name: Compile & launch app with Docker
run: | run: |
docker build -t moodlehq/moodleapp:behat . docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat .
docker run -d --rm --name moodleapp moodlehq/moodleapp:behat docker run -d --rm --name moodleapp moodlehq/moodleapp:behat
docker network connect moodle-docker_default moodleapp --alias moodleapp docker network connect moodle-docker_default moodleapp --alias moodleapp
- name: Init Behat - name: Init Behat
@ -60,4 +67,4 @@ jobs:
- name: Run Behat tests - name: Run Behat tests
run: | run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&${{ github.event.inputs.tags }}' --auto-rerun" $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&$BEHAT_TAGS' --auto-rerun"

View File

@ -40,9 +40,9 @@ jobs:
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db
- name: Compile & launch production app with Docker - name: Compile & launch app with Docker
run: | run: |
docker build -t moodlehq/moodleapp:performance . docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:performance .
docker run -d --rm --name moodleapp moodlehq/moodleapp:performance docker run -d --rm --name moodleapp moodlehq/moodleapp:performance
docker network connect moodle-docker_default moodleapp --alias moodleapp docker network connect moodle-docker_default moodleapp --alias moodleapp
- name: Init Behat - name: Init Behat

View File

@ -0,0 +1,18 @@
name: Update Behat plugin
on: ['push']
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
- name: Install npm packages
run: npm ci --no-audit
- name: Update Behat plugin
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
BEHAT_PLUGIN_GITHUB_REPOSITORY: ${{ secrets.BEHAT_PLUGIN_GITHUB_REPOSITORY }}
BEHAT_PLUGIN_BRANCH: ${{ secrets.BEHAT_PLUGIN_BRANCH }}
run: ./.github/scripts/update_behat_plugin.sh

View File

@ -68,21 +68,3 @@ jobs:
homebrew: homebrew:
packages: packages:
- jq - jq
- stage: test
name: "End to end tests (mod_forum and mod_messages)"
services:
- docker
if: type = cron
script: scripts/test_e2e.sh "@app&&@mod_forum" "@app&&@mod_messages"
- stage: test
name: "End to end tests (mod_course, core_course and mod_courses)"
services:
- docker
if: type = cron
script: scripts/test_e2e.sh "@app&&@mod_course" "@app&&@core_course" "@app&&@mod_courses"
- stage: test
name: "End to end tests (others)"
services:
- docker
if: type = cron
script: scripts/test_e2e.sh "@app&&~@mod_forum&&~@mod_messages&&~@mod_course&&~@core_course&&~@mod_courses"

View File

@ -46,6 +46,12 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"fileReplacements": [
{
"replace": "src/testing/testing.module.ts",
"with": "src/testing/testing.module.prod.ts"
}
],
"optimization": { "optimization": {
"scripts": false, "scripts": false,
"styles": true "styles": true

View File

@ -71,5 +71,5 @@ gulp.task('watch', () => {
}); });
gulp.task('watch-behat', () => { gulp.task('watch-behat', () => {
gulp.watch(['./src/**/*.feature', './local-moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); gulp.watch(['./src/**/*.feature', './local_moodleappbehat'], { interval: 500 }, gulp.parallel('behat'));
}); });

View File

@ -96,9 +96,8 @@ class behat_app extends behat_app_helper {
public function i_wait_the_app_to_restart() { public function i_wait_the_app_to_restart() {
// Wait window to reload. // Wait window to reload.
$this->spin(function() { $this->spin(function() {
$result = $this->js("return !window.behat;"); if ($this->runtime_js('hasInitialized()')) {
// Behat runtime shouldn't be initialized after reload.
if (!$result) {
throw new DriverException('Window is not reloading properly.'); throw new DriverException('Window is not reloading properly.');
} }
@ -115,25 +114,25 @@ class behat_app extends behat_app_helper {
* @Then /^I should( not)? find (".+")( inside the .+)? in the app$/ * @Then /^I should( not)? find (".+")( inside the .+)? in the app$/
* @param bool $not Whether assert that the element was not found * @param bool $not Whether assert that the element was not found
* @param string $locator Element locator * @param string $locator Element locator
* @param string $containerName Container name * @param string $container Container name
*/ */
public function i_find_in_the_app(bool $not, string $locator, string $containerName = '') { public function i_find_in_the_app(bool $not, string $locator, string $container = '') {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
if (!empty($containerName)) { if (!empty($container)) {
preg_match('/^ inside the (.+)$/', $containerName, $matches); preg_match('/^ inside the (.+)$/', $container, $matches);
$containerName = $matches[1]; $container = $matches[1];
} }
$containerName = json_encode($containerName); $options = json_encode(['containerName' => $container]);
$this->spin(function() use ($not, $locator, $containerName) { $this->spin(function() use ($not, $locator, $options) {
$result = $this->js("return window.behat.find($locator, $containerName);"); $result = $this->runtime_js("find($locator, $options)");
if ($not && $result === 'OK') { if ($not && $result === 'OK') {
throw new DriverException('Error, found an item that should not be found'); throw new DriverException('Error, found an element that should not be found');
} }
if (!$not && $result !== 'OK') { if (!$not && $result !== 'OK') {
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding element - ' . $result);
} }
return true; return true;
@ -152,10 +151,10 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator) { $this->spin(function() use ($locator) {
$result = $this->js("return window.behat.scrollTo($locator);"); $result = $this->runtime_js("scrollTo($locator)");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding element - ' . $result);
} }
return true; return true;
@ -175,7 +174,7 @@ class behat_app extends behat_app_helper {
*/ */
public function i_load_more_items_in_the_app(bool $not = false) { public function i_load_more_items_in_the_app(bool $not = false) {
$this->spin(function() use ($not) { $this->spin(function() use ($not) {
$result = $this->js('return await window.behat.loadMoreItems();'); $result = $this->runtime_js('loadMoreItems()');
if ($not && $result !== 'ERROR: All items are already loaded.') { if ($not && $result !== 'ERROR: All items are already loaded.') {
throw new DriverException('It should not have been possible to load more items'); throw new DriverException('It should not have been possible to load more items');
@ -200,7 +199,7 @@ class behat_app extends behat_app_helper {
public function i_swipe_in_the_app(string $direction) { public function i_swipe_in_the_app(string $direction) {
$method = 'swipe' . ucwords($direction); $method = 'swipe' . ucwords($direction);
$this->js("window.behat.getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); $this->runtime_js("getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()");
$this->wait_for_pending_js(); $this->wait_for_pending_js();
@ -219,21 +218,21 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator, $not) { $this->spin(function() use ($locator, $not) {
$result = $this->js("return window.behat.isSelected($locator);"); $result = $this->runtime_js("isSelected($locator)");
switch ($result) { switch ($result) {
case 'YES': case 'YES':
if ($not) { if ($not) {
throw new ExpectationException("Item was selected and shouldn't have", $this->getSession()->getDriver()); throw new ExpectationException("Element was selected and shouldn't have", $this->getSession()->getDriver());
} }
break; break;
case 'NO': case 'NO':
if (!$not) { if (!$not) {
throw new ExpectationException("Item wasn't selected and should have", $this->getSession()->getDriver()); throw new ExpectationException("Element wasn't selected and should have", $this->getSession()->getDriver());
} }
break; break;
default: default:
throw new DriverException('Error finding item - ' . $result); throw new DriverException('Error finding element - ' . $result);
} }
return true; return true;
@ -326,7 +325,7 @@ class behat_app extends behat_app_helper {
$this->login($username); $this->login($username);
} }
$mycoursesfound = $this->js("return window.behat.find({ text: 'My courses', selector: 'ion-tab-button'});"); $mycoursesfound = $this->runtime_js("find({ text: 'My courses', selector: 'ion-tab-button'})");
if ($mycoursesfound !== 'OK') { if ($mycoursesfound !== 'OK') {
// My courses not present enter from Dashboard. // My courses not present enter from Dashboard.
@ -382,7 +381,7 @@ class behat_app extends behat_app_helper {
*/ */
public function i_press_the_standard_button_in_the_app(string $button) { public function i_press_the_standard_button_in_the_app(string $button) {
$this->spin(function() use ($button) { $this->spin(function() use ($button) {
$result = $this->js("return await window.behat.pressStandard('$button');"); $result = $this->runtime_js("pressStandard('$button')");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing standard button - ' . $result); throw new DriverException('Error pressing standard button - ' . $result);
@ -420,7 +419,7 @@ class behat_app extends behat_app_helper {
], ],
]); ]);
$this->js("window.behat.notificationClicked($notification)"); $this->zone_js("pushNotifications.notificationClicked($notification)", true);
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -508,7 +507,7 @@ class behat_app extends behat_app_helper {
*/ */
public function i_close_the_popup_in_the_app() { public function i_close_the_popup_in_the_app() {
$this->spin(function() { $this->spin(function() {
$result = $this->js("return window.behat.closePopup();"); $result = $this->runtime_js('closePopup()');
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error closing popup - ' . $result); throw new DriverException('Error closing popup - ' . $result);
@ -536,7 +535,7 @@ class behat_app extends behat_app_helper {
* Clicks on / touches something that is visible in the app. * Clicks on / touches something that is visible in the app.
* *
* Note it is difficult to use the standard 'click on' or 'press' steps because those do not * Note it is difficult to use the standard 'click on' or 'press' steps because those do not
* distinguish visible items and the app always has many non-visible items in the DOM. * distinguish visible elements and the app always has many non-visible elements in the DOM.
* *
* @When /^I press (".+") in the app$/ * @When /^I press (".+") in the app$/
* @param string $locator Element locator * @param string $locator Element locator
@ -546,7 +545,7 @@ class behat_app extends behat_app_helper {
$locator = $this->parse_element_locator($locator); $locator = $this->parse_element_locator($locator);
$this->spin(function() use ($locator) { $this->spin(function() use ($locator) {
$result = $this->js("return await window.behat.press($locator);"); $result = $this->runtime_js("press($locator)");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result); throw new DriverException('Error pressing item - ' . $result);
@ -578,6 +577,33 @@ class behat_app extends behat_app_helper {
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
/**
* Checks if elements can be pressed in the app.
*
* @Then /^I should( not)? be able to press (".+") in the app$/
* @param bool $not Whether to assert that the element cannot be pressed
* @param string $locator Element locator
*/
public function i_should_be_able_to_press_in_the_app(bool $not, string $locator) {
$locator = $this->parse_element_locator($locator);
$this->spin(function() use ($not, $locator) {
$result = $this->runtime_js("find($locator, { onlyClickable: true })");
if ($not && $result === 'OK') {
throw new DriverException('Error, found a clickable element that should not be found');
}
if (!$not && $result !== 'OK') {
throw new DriverException('Error finding clickable element - ' . $result);
}
return true;
});
$this->wait_for_pending_js();
}
/** /**
* Select an item from a list of options, such as a radio button. * Select an item from a list of options, such as a radio button.
* *
@ -596,23 +622,23 @@ class behat_app extends behat_app_helper {
$this->spin(function() use ($selectedtext, $selected, $locator) { $this->spin(function() use ($selectedtext, $selected, $locator) {
// Don't do anything if the item is already in the expected state. // Don't do anything if the item is already in the expected state.
$result = $this->js("return window.behat.isSelected($locator);"); $result = $this->runtime_js("isSelected($locator)");
if ($result === $selected) { if ($result === $selected) {
return true; return true;
} }
// Press item. // Press element.
$result = $this->js("return await window.behat.press($locator);"); $result = $this->runtime_js("press($locator)");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error pressing item - ' . $result); throw new DriverException('Error pressing element - ' . $result);
} }
// Check that it worked as expected. // Check that it worked as expected.
$this->wait_for_pending_js(); $this->wait_for_pending_js();
$result = $this->js("return window.behat.isSelected($locator);"); $result = $this->runtime_js("isSelected($locator)");
switch ($result) { switch ($result) {
case 'YES': case 'YES':
@ -646,7 +672,7 @@ class behat_app extends behat_app_helper {
$value = addslashes_js($value); $value = addslashes_js($value);
$this->spin(function() use ($field, $value) { $this->spin(function() use ($field, $value) {
$result = $this->js("return await window.behat.setField(\"$field\", \"$value\");"); $result = $this->runtime_js("setField('$field', '$value')");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error setting field - ' . $result); throw new DriverException('Error setting field - ' . $result);
@ -685,7 +711,7 @@ class behat_app extends behat_app_helper {
*/ */
public function the_header_should_be_in_the_app(string $text) { public function the_header_should_be_in_the_app(string $text) {
$this->spin(function() use ($text) { $this->spin(function() use ($text) {
$result = $this->js('return window.behat.getHeader();'); $result = $this->runtime_js('getHeader()');
if (substr($result, 0, 3) !== 'OK:') { if (substr($result, 0, 3) !== 'OK:') {
throw new DriverException('Error getting header - ' . $result); throw new DriverException('Error getting header - ' . $result);
@ -766,7 +792,7 @@ class behat_app extends behat_app_helper {
* @When I run cron tasks in the app * @When I run cron tasks in the app
*/ */
public function i_run_cron_tasks_in_the_app() { public function i_run_cron_tasks_in_the_app() {
$this->js('await window.behat.forceSyncExecution()'); $this->zone_js('cronDelegate.forceSyncExecution()');
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -776,7 +802,7 @@ class behat_app extends behat_app_helper {
* @When I wait loading to finish in the app * @When I wait loading to finish in the app
*/ */
public function i_wait_loading_to_finish_in_the_app() { public function i_wait_loading_to_finish_in_the_app() {
$this->js('await window.behat.waitLoadingToFinish()'); $this->runtime_js('waitLoadingToFinish()');
$this->wait_for_pending_js(); $this->wait_for_pending_js();
} }
@ -798,7 +824,7 @@ class behat_app extends behat_app_helper {
$this->getSession()->switchToWindow($names[1]); $this->getSession()->switchToWindow($names[1]);
} }
$this->js('window.close();'); $this->js('window.close()');
$this->getSession()->switchToWindow($names[0]); $this->getSession()->switchToWindow($names[0]);
} }
@ -810,7 +836,7 @@ class behat_app extends behat_app_helper {
* @throws DriverException If the navigator.online mode is not available * @throws DriverException If the navigator.online mode is not available
*/ */
public function i_switch_offline_mode(string $offline) { public function i_switch_offline_mode(string $offline) {
$this->js("window.behat.network.setForceOffline($offline);"); $this->runtime_js("network.setForceOffline($offline)");
} }
} }

View File

@ -313,12 +313,12 @@ class behat_app_helper extends behat_base {
try { try {
// Init Behat JavaScript runtime. // Init Behat JavaScript runtime.
$initoptions = json_encode([
'skipOnBoarding' => $options['skiponboarding'] ?? true,
'configOverrides' => $this->appconfig,
]);
$initOptions = new StdClass(); $this->runtime_js("init($initoptions)");
$initOptions->skipOnBoarding = $options['skiponboarding'] ?? true;
$initOptions->configOverrides = $this->appconfig;
$this->js('window.behatInit(' . json_encode($initOptions) . ');');
} catch (Exception $error) { } catch (Exception $error) {
throw new DriverException('Moodle App not running or not running on Automated mode.'); throw new DriverException('Moodle App not running or not running on Automated mode.');
} }
@ -456,7 +456,7 @@ class behat_app_helper extends behat_base {
$res = $this->evaluate_script("Promise.resolve($script) $res = $this->evaluate_script("Promise.resolve($script)
.then(result => window.$promisevariable = result) .then(result => window.$promisevariable = result)
.catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message);"); .catch(error => window.$promisevariable = 'Async code rejected: ' + error?.message)");
do { do {
if (microtime(true) - $start > $timeout) { if (microtime(true) - $start > $timeout) {
@ -465,15 +465,42 @@ class behat_app_helper extends behat_base {
// 0.1 seconds. // 0.1 seconds.
usleep(100000); usleep(100000);
} while (!$this->evaluate_script("return '$promisevariable' in window;")); } while (!$this->evaluate_script("'$promisevariable' in window"));
$result = $this->evaluate_script("return window.$promisevariable;"); $result = $this->evaluate_script("window.$promisevariable");
$this->evaluate_script("delete window.$promisevariable;"); $this->evaluate_script("delete window.$promisevariable");
if (is_string($result) && strrpos($result, 'Async code rejected:') === 0) {
throw new DriverException($result);
}
return $result; return $result;
} }
/**
* Evaluate and execute methods from the Behat runtime.
*
* @param string $script
* @return mixed Result.
*/
protected function runtime_js(string $script) {
return $this->js("window.behat.$script");
}
/**
* Evaluate and execute methods from the Behat runtime inside the Angular zone.
*
* @param string $script
* @param bool $blocking
* @return mixed Result.
*/
protected function zone_js(string $script, bool $blocking = false) {
$blockingjson = json_encode($blocking);
return $this->runtime_js("runInZone(() => window.behat.$script, $blockingjson)");
}
/** /**
* Opens a custom URL for automatic login and redirect from the Moodle App (and waits to finish.) * Opens a custom URL for automatic login and redirect from the Moodle App (and waits to finish.)
* *
@ -513,9 +540,10 @@ class behat_app_helper extends behat_base {
// Generate custom URL. // Generate custom URL.
$parsed_url = parse_url($CFG->behat_wwwroot); $parsed_url = parse_url($CFG->behat_wwwroot);
$domain = $parsed_url['host'] ?? ''; $site = $parsed_url['host'] ?? '';
$rootpath = $parsed_url['path'] ?? ''; $site .= isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';
$url = $this->get_mobile_url_scheme() . "://$username@$domain$rootpath?token=$token&privatetoken=$privatetoken"; $site .= $parsed_url['path'] ?? '';
$url = $this->get_mobile_url_scheme() . "://$username@$site?token=$token&privatetoken=$privatetoken";
if (!empty($path)) { if (!empty($path)) {
$url .= '&redirect='.urlencode($CFG->behat_wwwroot.$path); $url .= '&redirect='.urlencode($CFG->behat_wwwroot.$path);
@ -548,8 +576,7 @@ class behat_app_helper extends behat_base {
* @param string $successXPath The XPath of the element to lookat after navigation. * @param string $successXPath The XPath of the element to lookat after navigation.
*/ */
protected function handle_url(string $customurl, string $successXPath = '') { protected function handle_url(string $customurl, string $successXPath = '') {
// Instead of using evaluate_async_script, we wait for the path to load. $result = $this->zone_js("customUrlSchemes.handleCustomURL('$customurl')");
$result = $this->js("return await window.behat.handleCustomURL('$customurl');");
if ($result !== 'OK') { if ($result !== 'OK') {
throw new DriverException('Error handling url - ' . $result); throw new DriverException('Error handling url - ' . $result);

View File

@ -178,7 +178,7 @@ class performance_measure implements behat_app_listener {
* @return int Current time in milliseconds. * @return int Current time in milliseconds.
*/ */
private function now(): int { private function now(): int {
return $this->driver->evaluateScript('Date.now();'); return $this->driver->evaluateScript('Date.now()');
} }
/** /**

255
package-lock.json generated
View File

@ -229,12 +229,31 @@
"uri-js": "^4.2.2" "uri-js": "^4.2.2"
} }
}, },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"core-js": { "core-js": {
"version": "3.6.4", "version": "3.6.4",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw==", "integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw==",
"dev": true "dev": true
}, },
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"open": { "open": {
"version": "7.0.4", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz", "resolved": "https://registry.npmjs.org/open/-/open-7.0.4.tgz",
@ -3889,6 +3908,16 @@
"strip-json-comments": "^3.1.1" "strip-json-comments": "^3.1.1"
}, },
"dependencies": { "dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"debug": { "debug": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
@ -3913,6 +3942,15 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true "dev": true
}, },
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -11772,12 +11810,12 @@
} }
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0"
"concat-map": "0.0.1"
} }
}, },
"braces": { "braces": {
@ -12327,6 +12365,16 @@
"integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",
"dev": true "dev": true
}, },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"chalk": { "chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@ -12447,6 +12495,17 @@
"table": "^5.2.3", "table": "^5.2.3",
"text-table": "^0.2.0", "text-table": "^0.2.0",
"v8-compile-cache": "^2.0.3" "v8-compile-cache": "^2.0.3"
},
"dependencies": {
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
}
} }
}, },
"eslint-scope": { "eslint-scope": {
@ -13433,7 +13492,7 @@
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
}, },
"concat-stream": { "concat-stream": {
"version": "1.6.2", "version": "1.6.2",
@ -16449,6 +16508,16 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true "dev": true
}, },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"chalk": { "chalk": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
@ -16493,6 +16562,15 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true "dev": true
}, },
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -16625,6 +16703,16 @@
"tsconfig-paths": "^3.9.0" "tsconfig-paths": "^3.9.0"
}, },
"dependencies": { "dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"doctrine": { "doctrine": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz",
@ -16634,6 +16722,15 @@
"esutils": "^2.0.2", "esutils": "^2.0.2",
"isarray": "^1.0.0" "isarray": "^1.0.0"
} }
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
} }
} }
}, },
@ -17635,6 +17732,16 @@
"color-convert": "^1.9.0" "color-convert": "^1.9.0"
} }
}, },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"braces": { "braces": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz",
@ -17766,6 +17873,15 @@
"to-regex": "^3.0.2" "to-regex": "^3.0.2"
} }
}, },
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"semver": { "semver": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@ -18326,6 +18442,25 @@
"minimatch": "^3.0.4", "minimatch": "^3.0.4",
"once": "^1.3.0", "once": "^1.3.0",
"path-is-absolute": "^1.0.0" "path-is-absolute": "^1.0.0"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}
}
} }
}, },
"glob-base": { "glob-base": {
@ -19962,6 +20097,25 @@
"integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==",
"requires": { "requires": {
"minimatch": "^3.0.4" "minimatch": "^3.0.4"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}
}
} }
}, },
"image-size": { "image-size": {
@ -20959,6 +21113,16 @@
"integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==",
"dev": true "dev": true
}, },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"chalk": { "chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -20968,6 +21132,15 @@
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
"supports-color": "^7.1.0" "supports-color": "^7.1.0"
} }
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
} }
} }
}, },
@ -23349,11 +23522,12 @@
"dev": true "dev": true
}, },
"minimatch": { "minimatch": {
"version": "3.0.4", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
"dev": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^2.0.1"
} }
}, },
"minimist": { "minimist": {
@ -25138,6 +25312,16 @@
"which": "^1.3.1" "which": "^1.3.1"
}, },
"dependencies": { "dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"cacache": { "cacache": {
"version": "12.0.4", "version": "12.0.4",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz", "resolved": "https://registry.npmjs.org/cacache/-/cacache-12.0.4.tgz",
@ -25200,6 +25384,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minipass": { "minipass": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
@ -28127,6 +28320,27 @@
"dev": true, "dev": true,
"requires": { "requires": {
"minimatch": "3.0.4" "minimatch": "3.0.4"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
}
} }
}, },
"reflect-metadata": { "reflect-metadata": {
@ -31856,6 +32070,27 @@
"@istanbuljs/schema": "^0.1.2", "@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4", "glob": "^7.1.4",
"minimatch": "^3.0.4" "minimatch": "^3.0.4"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
}
} }
}, },
"text-table": { "text-table": {

View File

@ -172,6 +172,7 @@
"jest": "^26.5.2", "jest": "^26.5.2",
"jest-preset-angular": "^8.3.1", "jest-preset-angular": "^8.3.1",
"jsonc-parser": "^2.3.1", "jsonc-parser": "^2.3.1",
"minimatch": "^5.1.0",
"native-run": "^1.4.0", "native-run": "^1.4.0",
"patch-package": "^6.4.7", "patch-package": "^6.4.7",
"terser-webpack-plugin": "^4.2.3", "terser-webpack-plugin": "^4.2.3",

View File

@ -14,6 +14,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
const minimatch = require('minimatch');
const { existsSync, readFileSync, writeFileSync, statSync, renameSync, rmSync } = require('fs'); const { existsSync, readFileSync, writeFileSync, statSync, renameSync, rmSync } = require('fs');
const { readdir } = require('fs').promises; const { readdir } = require('fs').promises;
const { mkdirSync, copySync } = require('fs-extra'); const { mkdirSync, copySync } = require('fs-extra');
@ -21,12 +22,22 @@ const { resolve, extname, dirname, basename, relative } = require('path');
async function main() { async function main() {
const pluginPath = process.argv[2] || guessPluginPath() || fail('Folder argument missing!'); const pluginPath = process.argv[2] || guessPluginPath() || fail('Folder argument missing!');
const excludeFeatures = process.argv.some(arg => arg === '--exclude-features');
const exclusions = excludeFeatures
? [
'*.feature',
'**/js/mobile/index.js',
'**/db/mobile.php',
'**/classes/output/mobile.php',
]
: [];
if (!existsSync(pluginPath)) { if (!existsSync(pluginPath)) {
mkdirSync(pluginPath); mkdirSync(pluginPath);
} else { } else {
// Empty directory, except the excluding list. // Empty directory, except the excluding list.
const excludeFromErase = [ const excludeFromErase = [
...exclusions,
'.git', '.git',
'.gitignore', '.gitignore',
'README.md', 'README.md',
@ -34,7 +45,7 @@ async function main() {
const files = await readdir(pluginPath, { withFileTypes: true }); const files = await readdir(pluginPath, { withFileTypes: true });
for (const file of files) { for (const file of files) {
if (excludeFromErase.indexOf(file.name) >= 0) { if (isExcluded(file.name, excludeFromErase)) {
continue; continue;
} }
@ -43,13 +54,17 @@ async function main() {
} }
} }
// Copy plugin template. // Copy plugin template.
const { version: appVersion } = require(projectPath('package.json')); const { version: appVersion } = require(projectPath('package.json'));
const templatePath = projectPath('local-moodleappbehat'); const templatePath = projectPath('local_moodleappbehat');
for await (const file of getDirectoryFiles(templatePath)) {
if (isExcluded(file, exclusions)) {
continue;
}
copySync(templatePath, pluginPath); copySync(file, file.replace(templatePath, pluginPath));
}
// Update version.php // Update version.php
const pluginFilePath = pluginPath + '/version.php'; const pluginFilePath = pluginPath + '/version.php';
@ -62,28 +77,30 @@ async function main() {
writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements));
// Copy feature files. // Copy feature files.
const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; if (!excludeFeatures) {
copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory }); const behatTempFeaturesPath = `${pluginPath}/behat-tmp`;
copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory });
const behatFeaturesPath = `${pluginPath}/tests/behat`; const behatFeaturesPath = `${pluginPath}/tests/behat`;
if (!existsSync(behatFeaturesPath)) { if (!existsSync(behatFeaturesPath)) {
mkdirSync(behatFeaturesPath, {recursive: true}); mkdirSync(behatFeaturesPath, {recursive: true});
}
for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) {
const featurePath = dirname(featureFile);
if (!featurePath.endsWith('/tests/behat')) {
continue;
} }
const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length)); for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) {
const searchRegExp = new RegExp('/', 'g'); const featurePath = dirname(featureFile);
const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; if (!featurePath.endsWith('/tests/behat')) {
const featureFilename = prefix + '-' + basename(featureFile); continue;
renameSync(featureFile, behatFeaturesPath + '/' + featureFilename); }
}
rmSync(behatTempFeaturesPath, {recursive: true}); const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length));
const searchRegExp = new RegExp('/', 'g');
const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core';
const featureFilename = prefix + '-' + basename(featureFile);
renameSync(featureFile, behatFeaturesPath + '/' + featureFilename);
}
rmSync(behatTempFeaturesPath, {recursive: true});
}
} }
function isFeatureFileOrDirectory(src) { function isFeatureFileOrDirectory(src) {
@ -92,6 +109,10 @@ function isFeatureFileOrDirectory(src) {
return stats.isDirectory() || extname(src) === '.feature'; return stats.isDirectory() || extname(src) === '.feature';
} }
function isExcluded(file, exclusions) {
return exclusions.some(exclusion => minimatch(file, exclusion));
}
function fail(message) { function fail(message) {
console.error(message); console.error(message);
process.exit(1); process.exit(1);

View File

@ -1,50 +0,0 @@
#!/bin/bash
source "scripts/functions.sh"
# Prepare variables
basedir="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../" && pwd )"
dockerscripts="$HOME/moodle-docker/bin/"
dockercompose="$dockerscripts/moodle-docker-compose"
export MOODLE_DOCKER_DB=pgsql
export MOODLE_DOCKER_BROWSER=chrome
export MOODLE_DOCKER_WWWROOT="$HOME/moodle"
export MOODLE_DOCKER_PHP_VERSION=7.4
export MOODLE_DOCKER_APP_PATH=$basedir
# Prepare dependencies
print_title "Preparing dependencies"
git clone --branch master --depth 1 git://github.com/moodle/moodle $HOME/moodle
git clone --branch ionic5 --depth 1 git://github.com/moodlehq/moodle-local_moodlemobileapp $HOME/moodle/local/moodlemobileapp
# TODO replace for moodlehq/moodle-docker after merging https://github.com/moodlehq/moodle-docker/pull/156
git clone --branch MOBILE-3738 --depth 1 git://github.com/NoelDeMartin/moodle-docker $HOME/moodle-docker
cp $HOME/moodle-docker/config.docker-template.php $HOME/moodle/config.php
# Build app
print_title "Building app"
npm ci
# Start containers
print_title "Starting containers"
$dockercompose pull
$dockercompose up -d
$dockerscripts/moodle-docker-wait-for-db
$dockerscripts/moodle-docker-wait-for-app
$dockercompose exec -T webserver sh -c "php admin/tool/behat/cli/init.php"
notify_on_error_exit "e2e failed initializing behat"
print_title "Running e2e tests"
# Run tests
for tags in "$@"
do
$dockercompose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --tags=\"$tags\" --auto-rerun"
notify_on_error_exit "Some e2e tests are failing, please review"
done
# Clean up
$dockercompose down

View File

@ -45,8 +45,9 @@ Feature: Users can manage entries in database activities
Scenario: Browse entry Scenario: Browse entry
Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app Given I entered the data activity "Web links" on course "Course 1" as "student1" in the app
# TODO Create and use a generator for database entries. # TODO Create and use a generator for database entries.
And I press "Add entries" in the app When I press "Add entries" in the app
And I set the following fields to these values in the app: And I set the following fields to these values in the app:
| URL | https://moodle.org/ | | URL | https://moodle.org/ |
| Description | Moodle community site | | Description | Moodle community site |
@ -59,16 +60,19 @@ Feature: Users can manage entries in database activities
And I press "Save" near "Web links" in the app And I press "Save" near "Web links" in the app
And I press "More" near "Moodle community site" in the app And I press "More" near "Moodle community site" in the app
Then I should find "Moodle community site" in the app Then I should find "Moodle community site" in the app
And I should not find "Next" in the app And I should be able to press "Previous" in the app
And I should find "Previous" in the app But I should not be able to press "Next" in the app
And I press "Previous" in the app
And I should find "Moodle Cloud" in the app When I press "Previous" in the app
And I should find "Next" in the app Then I should find "Moodle Cloud" in the app
And I should not find "Previous" in the app And I should be able to press "Next" in the app
And I press "Next" in the app But I should not be able to press "Previous" in the app
And I should find "Moodle community site" in the app
And I should not find "Moodle Cloud" in the app When I press "Next" in the app
And I press the back button in the app Then I should find "Moodle community site" in the app
But I should not find "Moodle Cloud" in the app
When I press the back button in the app
And I should find "Moodle community site" in the app And I should find "Moodle community site" in the app
And I should find "Moodle Cloud" in the app And I should find "Moodle Cloud" in the app

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { AfterViewInit, ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core'; import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
import { IonRouterOutlet } from '@ionic/angular'; import { IonRouterOutlet } from '@ionic/angular';
import { BackButtonEvent, ScrollDetail } from '@ionic/core'; import { BackButtonEvent, ScrollDetail } from '@ionic/core';
@ -21,7 +21,7 @@ import { CoreLoginHelper } from '@features/login/services/login-helper';
import { CoreEvents } from '@singletons/events'; import { CoreEvents } from '@singletons/events';
import { NgZone, SplashScreen, Translate } from '@singletons'; import { NgZone, SplashScreen, Translate } from '@singletons';
import { CoreNetwork } from '@services/network'; import { CoreNetwork } from '@services/network';
import { CoreApp, CoreAppProvider } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreSubscriptions } from '@singletons/subscriptions'; import { CoreSubscriptions } from '@singletons/subscriptions';
@ -38,10 +38,6 @@ import { CorePlatform } from '@services/platform';
const MOODLE_VERSION_PREFIX = 'version-'; const MOODLE_VERSION_PREFIX = 'version-';
const MOODLEAPP_VERSION_PREFIX = 'moodleapp-'; const MOODLEAPP_VERSION_PREFIX = 'moodleapp-';
type AutomatedTestsWindow = Window & {
changeDetector?: ChangeDetectorRef;
};
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: 'app.component.html', templateUrl: 'app.component.html',
@ -54,12 +50,6 @@ export class AppComponent implements OnInit, AfterViewInit {
protected lastUrls: Record<string, number> = {}; protected lastUrls: Record<string, number> = {};
protected lastInAppUrl?: string; protected lastInAppUrl?: string;
constructor(changeDetector: ChangeDetectorRef) {
if (CoreAppProvider.isAutomated()) {
(window as AutomatedTestsWindow).changeDetector = changeDetector;
}
}
/** /**
* Component being initialized. * Component being initialized.
* *

View File

@ -32,7 +32,7 @@ import { JitCompilerFactory } from '@angular/platform-browser-dynamic';
import { CoreCronDelegate } from '@services/cron'; import { CoreCronDelegate } from '@services/cron';
import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron'; import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron';
import { moodleTransitionAnimation } from '@classes/page-transition'; import { moodleTransitionAnimation } from '@classes/page-transition';
import { BehatTestingModule } from '@/testing/behat-testing.module'; import { TestingModule } from '@/testing/testing.module';
// For translate loader. AoT requires an exported function for factories. // For translate loader. AoT requires an exported function for factories.
export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
@ -60,7 +60,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
AppRoutingModule, AppRoutingModule,
CoreModule, CoreModule,
AddonsModule, AddonsModule,
BehatTestingModule, TestingModule,
], ],
providers: [ providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },

View File

@ -220,22 +220,9 @@ Feature: Test basic usage of comments in app
Scenario: Add comments & Delete comments (blogs) Scenario: Add comments & Delete comments (blogs)
# Create blog as a teacher # Create blog as a teacher
Given the following "blocks" exist: Given the following "core_blog > entries" exist:
| blockname | contextlevel | reference | pagetypepattern | defaultregion | configdata | | subject | body | user |
| blog_menu | Course | C1 | course-view-* | site-pre | | | Blog test | Blog body | teacher1 |
And I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" in the app
# TODO Create and use a generator blog entries.
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I click on "Open block drawer" "button"
And I click on "Add an entry about this course" "link" in the "Blog menu" "block"
And I set the following fields to these values:
| Entry title | Blog test |
| Blog entry body | Blog body |
And I press "Save changes"
And I close the browser tab opened by the app
# Create and delete comments as a student # Create and delete comments as a student
When I entered the app as "student1" When I entered the app as "student1"
@ -263,21 +250,9 @@ Feature: Test basic usage of comments in app
Scenario: Add comments offline & Delete comments offline & Sync comments (blogs) Scenario: Add comments offline & Delete comments offline & Sync comments (blogs)
# Create blog as a teacher # Create blog as a teacher
Given the following "blocks" exist: Given the following "core_blog > entries" exist:
| blockname | contextlevel | reference | pagetypepattern | defaultregion | configdata | | subject | body | user |
| blog_menu | Course | C1 | course-view-* | site-pre | | | Blog test | Blog body | teacher1 |
And I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" in the app
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I click on "Open block drawer" "button"
And I click on "Add an entry about this course" "link" in the "Blog menu" "block"
And I set the following fields to these values:
| Entry title | Blog test |
| Blog entry body | Blog body |
And I press "Save changes"
And I close the browser tab opened by the app
# Create and delete comments as a student # Create and delete comments as a student
When I entered the app as "student1" When I entered the app as "student1"

View File

@ -1,34 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreAppProvider } from '@services/app';
import { TestsBehatBlockingService } from './services/behat-blocking';
import { BehatTestsWindow, TestsBehatRuntime } from './services/behat-runtime';
function initializeBehatTestsWindow(window: BehatTestsWindow) {
// Make functions publicly available for Behat to call.
window.behatInit = TestsBehatRuntime.init;
}
@NgModule({
providers:
CoreAppProvider.isAutomated()
? [
{ provide: APP_INITIALIZER, multi: true, useValue: () => initializeBehatTestsWindow(window) },
TestsBehatBlockingService,
]
: [],
})
export class BehatTestingModule {}

View File

@ -15,13 +15,13 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, NgZone } from '@singletons'; import { makeSingleton, NgZone } from '@singletons';
import { BehatTestsWindow, TestsBehatRuntime } from './behat-runtime'; import { BehatTestsWindow, TestingBehatRuntime } from './behat-runtime';
/** /**
* Behat block JS manager. * Behat block JS manager.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TestsBehatBlockingService { export class TestingBehatBlockingService {
protected waitingBlocked = false; protected waitingBlocked = false;
protected recentMutation = false; protected recentMutation = false;
@ -48,7 +48,7 @@ export class TestsBehatBlockingService {
win.M.util = win.M.util ?? {}; win.M.util = win.M.util ?? {};
win.M.util.pending_js = win.M.util.pending_js ?? []; win.M.util.pending_js = win.M.util.pending_js ?? [];
TestsBehatRuntime.log('Initialized!'); TestingBehatRuntime.log('Initialized!');
} }
/** /**
@ -90,7 +90,7 @@ export class TestsBehatBlockingService {
} }
this.pendingList.push(key); this.pendingList.push(key);
TestsBehatRuntime.log('PENDING+: ' + this.pendingList); TestingBehatRuntime.log('PENDING+: ' + this.pendingList);
return key; return key;
} }
@ -105,7 +105,7 @@ export class TestsBehatBlockingService {
// Remove the key immediately. // Remove the key immediately.
this.pendingList = this.pendingList.filter((x) => x !== key); this.pendingList = this.pendingList.filter((x) => x !== key);
TestsBehatRuntime.log('PENDING-: ' + this.pendingList); TestingBehatRuntime.log('PENDING-: ' + this.pendingList);
// If the only thing left is DELAY, then remove that as well, later... // If the only thing left is DELAY, then remove that as well, later...
if (this.pendingList.length === 1) { if (this.pendingList.length === 1) {
@ -124,7 +124,7 @@ export class TestsBehatBlockingService {
// Only remove it if the pending array is STILL empty after all that. // Only remove it if the pending array is STILL empty after all that.
if (this.pendingList.length === 1) { if (this.pendingList.length === 1) {
this.pendingList = []; this.pendingList = [];
TestsBehatRuntime.log('PENDING-: ' + this.pendingList); TestingBehatRuntime.log('PENDING-: ' + this.pendingList);
} }
} }
} }
@ -221,16 +221,16 @@ export class TestsBehatBlockingService {
try { try {
// Add to the list of pending requests. // Add to the list of pending requests.
TestsBehatBlocking.block(key); TestingBehatBlocking.block(key);
// Detect when it finishes and remove it from the list. // Detect when it finishes and remove it from the list.
this.addEventListener('loadend', () => { this.addEventListener('loadend', () => {
TestsBehatBlocking.unblock(key); TestingBehatBlocking.unblock(key);
}); });
return realOpen.apply(this, args); return realOpen.apply(this, args);
} catch (error) { } catch (error) {
TestsBehatBlocking.unblock(key); TestingBehatBlocking.unblock(key);
throw error; throw error;
} }
}); });
@ -239,4 +239,4 @@ export class TestsBehatBlockingService {
} }
export const TestsBehatBlocking = makeSingleton(TestsBehatBlockingService); export const TestingBehatBlocking = makeSingleton(TestingBehatBlockingService);

View File

@ -12,10 +12,11 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value'; import { CorePromisedValue } from '@classes/promised-value';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { NgZone } from '@singletons'; import { makeSingleton, NgZone } from '@singletons';
import { TestBehatElementLocator } from './behat-runtime'; import { TestingBehatElementLocator, TestingBehatFindOptions } from './behat-runtime';
// Containers that block containers behind them. // Containers that block containers behind them.
const blockingContainers = ['ION-ALERT', 'ION-POPOVER', 'ION-ACTION-SHEET', 'CORE-USER-TOURS-USER-TOUR', 'ION-PAGE']; const blockingContainers = ['ION-ALERT', 'ION-POPOVER', 'ION-ACTION-SHEET', 'CORE-USER-TOURS-USER-TOUR', 'ION-PAGE'];
@ -23,7 +24,8 @@ const blockingContainers = ['ION-ALERT', 'ION-POPOVER', 'ION-ACTION-SHEET', 'COR
/** /**
* Behat Dom Utils helper functions. * Behat Dom Utils helper functions.
*/ */
export class TestsBehatDomUtils { @Injectable({ providedIn: 'root' })
export class TestingBehatDomUtilsService {
/** /**
* Check if an element is visible. * Check if an element is visible.
@ -32,7 +34,7 @@ export class TestsBehatDomUtils {
* @param container Container. * @param container Container.
* @return Whether the element is visible or not. * @return Whether the element is visible or not.
*/ */
static isElementVisible(element: HTMLElement, container: HTMLElement): boolean { isElementVisible(element: HTMLElement, container: HTMLElement): boolean {
if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') { if (element.getAttribute('aria-hidden') === 'true' || getComputedStyle(element).display === 'none') {
return false; return false;
} }
@ -56,7 +58,7 @@ export class TestsBehatDomUtils {
* @param container Container. * @param container Container.
* @return Whether the element is selected or not. * @return Whether the element is selected or not.
*/ */
static isElementSelected(element: HTMLElement, container: HTMLElement): boolean { isElementSelected(element: HTMLElement, container: HTMLElement): boolean {
const ariaCurrent = element.getAttribute('aria-current'); const ariaCurrent = element.getAttribute('aria-current');
if ( if (
(ariaCurrent && ariaCurrent !== 'false') || (ariaCurrent && ariaCurrent !== 'false') ||
@ -79,9 +81,14 @@ export class TestsBehatDomUtils {
* *
* @param container Parent element to search the element within * @param container Parent element to search the element within
* @param text Text to look for * @param text Text to look for
* @param options Search options.
* @return Elements containing the given text with exact boolean. * @return Elements containing the given text with exact boolean.
*/ */
protected static findElementsBasedOnTextWithinWithExact(container: HTMLElement, text: string): ElementsWithExact[] { protected findElementsBasedOnTextWithinWithExact(
container: HTMLElement,
text: string,
options: TestingBehatFindOptions,
): ElementsWithExact[] {
const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"], [placeholder*="${text}"]`; const attributesSelector = `[aria-label*="${text}"], a[title*="${text}"], img[alt*="${text}"], [placeholder*="${text}"]`;
const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector)) const elements = Array.from(container.querySelectorAll<HTMLElement>(attributesSelector))
@ -97,16 +104,23 @@ export class TestsBehatDomUtils {
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT | NodeFilter.SHOW_TEXT, // eslint-disable-line no-bitwise
{ {
acceptNode: node => { acceptNode: node => {
if (node instanceof HTMLStyleElement || if (
node instanceof HTMLStyleElement ||
node instanceof HTMLLinkElement || node instanceof HTMLLinkElement ||
node instanceof HTMLScriptElement) { node instanceof HTMLScriptElement
) {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
if (node instanceof HTMLElement && if (!(node instanceof HTMLElement)) {
(node.getAttribute('aria-hidden') === 'true' || return NodeFilter.FILTER_ACCEPT;
node.getAttribute('aria-disabled') === 'true' || }
getComputedStyle(node).display === 'none')) {
if (options.onlyClickable && (node.getAttribute('aria-disabled') === 'true' || node.hasAttribute('disabled'))) {
return NodeFilter.FILTER_REJECT;
}
if (node.getAttribute('aria-hidden') === 'true' || getComputedStyle(node).display === 'none') {
return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_REJECT;
} }
@ -160,7 +174,7 @@ export class TestsBehatDomUtils {
continue; continue;
} }
elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text)); elements.push(...this.findElementsBasedOnTextWithinWithExact(childNode, text, options));
} }
} }
} }
@ -175,7 +189,7 @@ export class TestsBehatDomUtils {
* @param text Text to check. * @param text Text to check.
* @return If text matches any of the label attributes. * @return If text matches any of the label attributes.
*/ */
protected static checkElementLabel(element: HTMLElement, text: string): boolean { protected checkElementLabel(element: HTMLElement, text: string): boolean {
return element.title === text || return element.title === text ||
element.getAttribute('alt') === text || element.getAttribute('alt') === text ||
element.getAttribute('aria-label') === text || element.getAttribute('aria-label') === text ||
@ -187,10 +201,15 @@ export class TestsBehatDomUtils {
* *
* @param container Parent element to search the element within. * @param container Parent element to search the element within.
* @param text Text to look for. * @param text Text to look for.
* @param options Search options.
* @return Elements containing the given text. * @return Elements containing the given text.
*/ */
protected static findElementsBasedOnTextWithin(container: HTMLElement, text: string): HTMLElement[] { protected findElementsBasedOnTextWithin(
const elements = this.findElementsBasedOnTextWithinWithExact(container, text); container: HTMLElement,
text: string,
options: TestingBehatFindOptions,
): HTMLElement[] {
const elements = this.findElementsBasedOnTextWithinWithExact(container, text, options);
// Give more relevance to exact matches. // Give more relevance to exact matches.
elements.sort((a, b) => Number(b.exact) - Number(a.exact)); elements.sort((a, b) => Number(b.exact) - Number(a.exact));
@ -206,7 +225,7 @@ export class TestsBehatDomUtils {
* @param elements Elements list. * @param elements Elements list.
* @return Top ancestors. * @return Top ancestors.
*/ */
protected static getTopAncestors(elements: HTMLElement[]): HTMLElement[] { protected getTopAncestors(elements: HTMLElement[]): HTMLElement[] {
const uniqueElements = new Set(elements); const uniqueElements = new Set(elements);
for (const element of uniqueElements) { for (const element of uniqueElements) {
@ -230,7 +249,7 @@ export class TestsBehatDomUtils {
* @param element Element. * @param element Element.
* @return Parent element. * @return Parent element.
*/ */
protected static getParentElement(element: HTMLElement): HTMLElement | null { protected getParentElement(element: HTMLElement): HTMLElement | null {
return element.parentElement || return element.parentElement ||
(element.getRootNode() && (element.getRootNode() as ShadowRoot).host as HTMLElement) || (element.getRootNode() && (element.getRootNode() as ShadowRoot).host as HTMLElement) ||
null; null;
@ -244,7 +263,7 @@ export class TestsBehatDomUtils {
* @param container Topmost container to search within. * @param container Topmost container to search within.
* @return Closest matching element. * @return Closest matching element.
*/ */
protected static getClosestMatching(element: HTMLElement, selector: string, container: HTMLElement | null): HTMLElement | null { protected getClosestMatching(element: HTMLElement, selector: string, container: HTMLElement | null): HTMLElement | null {
if (element.matches(selector)) { if (element.matches(selector)) {
return element; return element;
} }
@ -262,7 +281,7 @@ export class TestsBehatDomUtils {
* @param containerName Whether to search inside the a container name. * @param containerName Whether to search inside the a container name.
* @return Found top container elements. * @return Found top container elements.
*/ */
protected static getCurrentTopContainerElements(containerName: string): HTMLElement[] { protected getCurrentTopContainerElements(containerName: string): HTMLElement[] {
const topContainers: HTMLElement[] = []; const topContainers: HTMLElement[] = [];
let containers = Array.from(document.querySelectorAll<HTMLElement>([ let containers = Array.from(document.querySelectorAll<HTMLElement>([
'ion-alert.hydrated', 'ion-alert.hydrated',
@ -325,32 +344,33 @@ export class TestsBehatDomUtils {
* Function to find element based on their text or Aria label. * Function to find element based on their text or Aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param containerName Whether to search only inside a specific container. * @param options Search options.
* @return First found element. * @return First found element.
*/ */
static findElementBasedOnText(locator: TestBehatElementLocator, containerName = ''): HTMLElement { findElementBasedOnText(
return this.findElementsBasedOnText(locator, containerName, true)[0]; locator: TestingBehatElementLocator,
options: TestingBehatFindOptions,
): HTMLElement {
return this.findElementsBasedOnText(locator, options)[0];
} }
/** /**
* Function to find elements based on their text or Aria label. * Function to find elements based on their text or Aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param containerName Whether to search only inside a specific container. * @param options Search options.
* @param stopWhenFound Stop looking in containers once an element is found.
* @return Found elements * @return Found elements
*/ */
protected static findElementsBasedOnText( protected findElementsBasedOnText(
locator: TestBehatElementLocator, locator: TestingBehatElementLocator,
containerName = '', options: TestingBehatFindOptions,
stopWhenFound = false,
): HTMLElement[] { ): HTMLElement[] {
const topContainers = this.getCurrentTopContainerElements(containerName); const topContainers = this.getCurrentTopContainerElements(options.containerName);
let elements: HTMLElement[] = []; let elements: HTMLElement[] = [];
for (let i = 0; i < topContainers.length; i++) { for (let i = 0; i < topContainers.length; i++) {
elements = elements.concat(this.findElementsBasedOnTextInContainer(locator, topContainers[i])); elements = elements.concat(this.findElementsBasedOnTextInContainer(locator, topContainers[i], options));
if (stopWhenFound && elements.length) { if (elements.length) {
break; break;
} }
} }
@ -363,16 +383,18 @@ export class TestsBehatDomUtils {
* *
* @param locator Element locator. * @param locator Element locator.
* @param topContainer Container to search in. * @param topContainer Container to search in.
* @param options Search options.
* @return Found elements * @return Found elements
*/ */
protected static findElementsBasedOnTextInContainer( protected findElementsBasedOnTextInContainer(
locator: TestBehatElementLocator, locator: TestingBehatElementLocator,
topContainer: HTMLElement, topContainer: HTMLElement,
options: TestingBehatFindOptions,
): HTMLElement[] { ): HTMLElement[] {
let container: HTMLElement | null = topContainer; let container: HTMLElement | null = topContainer;
if (locator.within) { if (locator.within) {
const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer); const withinElements = this.findElementsBasedOnTextInContainer(locator.within, topContainer, options);
if (withinElements.length === 0) { if (withinElements.length === 0) {
throw new Error('There was no match for within text'); throw new Error('There was no match for within text');
@ -390,7 +412,10 @@ export class TestsBehatDomUtils {
} }
if (topContainer && locator.near) { if (topContainer && locator.near) {
const nearElements = this.findElementsBasedOnTextInContainer(locator.near, topContainer); const nearElements = this.findElementsBasedOnTextInContainer(locator.near, topContainer, {
...options,
onlyClickable: false,
});
if (nearElements.length === 0) { if (nearElements.length === 0) {
throw new Error('There was no match for near text'); throw new Error('There was no match for near text');
@ -412,7 +437,7 @@ export class TestsBehatDomUtils {
break; break;
} }
const elements = this.findElementsBasedOnTextWithin(container, locator.text); const elements = this.findElementsBasedOnTextWithin(container, locator.text, options);
let filteredElements: HTMLElement[] = elements; let filteredElements: HTMLElement[] = elements;
@ -442,7 +467,7 @@ export class TestsBehatDomUtils {
* *
* @param element Element. * @param element Element.
*/ */
protected static async ensureElementVisible(element: HTMLElement): Promise<DOMRect> { protected async ensureElementVisible(element: HTMLElement): Promise<DOMRect> {
const initialRect = element.getBoundingClientRect(); const initialRect = element.getBoundingClientRect();
element.scrollIntoView(false); element.scrollIntoView(false);
@ -471,7 +496,7 @@ export class TestsBehatDomUtils {
* *
* @param element Element to press. * @param element Element to press.
*/ */
static async pressElement(element: HTMLElement): Promise<void> { async pressElement(element: HTMLElement): Promise<void> {
await NgZone.run(async () => { await NgZone.run(async () => {
const promise = new CorePromisedValue<void>(); const promise = new CorePromisedValue<void>();
@ -516,7 +541,7 @@ export class TestsBehatDomUtils {
* @param element HTML to set. * @param element HTML to set.
* @param value Value to be set. * @param value Value to be set.
*/ */
static async setElementValue(element: HTMLInputElement | HTMLElement, value: string): Promise<void> { async setElementValue(element: HTMLInputElement | HTMLElement, value: string): Promise<void> {
await NgZone.run(async () => { await NgZone.run(async () => {
const promise = new CorePromisedValue<void>(); const promise = new CorePromisedValue<void>();
@ -581,6 +606,8 @@ export class TestsBehatDomUtils {
} }
export const TestingBehatDomUtils = makeSingleton(TestingBehatDomUtilsService);
type ElementsWithExact = { type ElementsWithExact = {
element: HTMLElement; element: HTMLElement;
exact: boolean; exact: boolean;

View File

@ -12,127 +12,106 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { TestsBehatDomUtils } from './behat-dom'; import { TestingBehatDomUtils } from './behat-dom';
import { TestsBehatBlocking } from './behat-blocking'; import { TestingBehatBlocking } from './behat-blocking';
import { CoreCustomURLSchemes } from '@services/urlschemes'; import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes';
import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreLoginHelperProvider } from '@features/login/services/login-helper';
import { CoreConfig } from '@services/config'; import { CoreConfig } from '@services/config';
import { EnvironmentConfig } from '@/types/config'; import { EnvironmentConfig } from '@/types/config';
import { NgZone } from '@singletons'; import { makeSingleton, NgZone } from '@singletons';
import { CoreNetwork } from '@services/network'; import { CoreNetwork, CoreNetworkService } from '@services/network';
import { import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications';
CorePushNotifications, import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron';
CorePushNotificationsNotificationBasicData,
} from '@features/pushnotifications/services/pushnotifications';
import { CoreCronDelegate } from '@services/cron';
import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreLoadingComponent } from '@components/loading/loading';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
import { CoreCoursesDashboardPage } from '@features/courses/pages/dashboard/dashboard'; import { CoreCoursesDashboardPage } from '@features/courses/pages/dashboard/dashboard';
import { Injectable } from '@angular/core';
/** /**
* Behat runtime servive with public API. * Behat runtime servive with public API.
*/ */
export class TestsBehatRuntime { @Injectable({ providedIn: 'root' })
export class TestingBehatRuntimeService {
protected initialized = false;
get cronDelegate(): CoreCronDelegateService {
return CoreCronDelegate.instance;
}
get customUrlSchemes(): CoreCustomURLSchemesProvider {
return CoreCustomURLSchemes.instance;
}
get network(): CoreNetworkService {
return CoreNetwork.instance;
}
get pushNotifications(): CorePushNotificationsProvider {
return CorePushNotifications.instance;
}
/** /**
* Init behat functions and set options like skipping onboarding. * Init behat functions and set options like skipping onboarding.
* *
* @param options Options to set on the app. * @param options Options to set on the app.
*/ */
static init(options?: TestsBehatInitOptions): void { init(options: TestingBehatInitOptions = {}): void {
TestsBehatBlocking.init(); if (this.initialized) {
(window as BehatTestsWindow).behat = {
closePopup: TestsBehatRuntime.closePopup,
find: TestsBehatRuntime.find,
getAngularInstance: TestsBehatRuntime.getAngularInstance,
getHeader: TestsBehatRuntime.getHeader,
isSelected: TestsBehatRuntime.isSelected,
loadMoreItems: TestsBehatRuntime.loadMoreItems,
log: TestsBehatRuntime.log,
press: TestsBehatRuntime.press,
pressStandard: TestsBehatRuntime.pressStandard,
pullToRefresh: TestsBehatRuntime.pullToRefresh,
scrollTo: TestsBehatRuntime.scrollTo,
setField: TestsBehatRuntime.setField,
handleCustomURL: TestsBehatRuntime.handleCustomURL,
notificationClicked: TestsBehatRuntime.notificationClicked,
forceSyncExecution: TestsBehatRuntime.forceSyncExecution,
waitLoadingToFinish: TestsBehatRuntime.waitLoadingToFinish,
network: CoreNetwork.instance,
};
if (!options) {
return; return;
} }
if (options.skipOnBoarding === true) { this.initialized = true;
TestingBehatBlocking.init();
if (options.skipOnBoarding) {
CoreConfig.set(CoreLoginHelperProvider.ONBOARDING_DONE, 1); CoreConfig.set(CoreLoginHelperProvider.ONBOARDING_DONE, 1);
} }
if (options.configOverrides) { if (options.configOverrides) {
// Set the cookie so it's maintained between reloads. // Set the cookie so it's maintained between reloads.
document.cookie = 'MoodleAppConfig=' + JSON.stringify(options.configOverrides); document.cookie = 'MoodleAppConfig=' + JSON.stringify(options.configOverrides);
CoreConfig.patchEnvironment(options.configOverrides); CoreConfig.patchEnvironment(options.configOverrides, { patchDefault: true });
} }
} }
/** /**
* Handles a custom URL. * Check whether the service has been initialized or not.
* *
* @param url Url to open. * @returns Whether the service has been initialized or not.
*/
hasInitialized(): boolean {
return this.initialized;
}
/**
* Run an operation inside the angular zone and return result.
*
* @param operation Operation callback.
* @return OK if successful, or ERROR: followed by message. * @return OK if successful, or ERROR: followed by message.
*/ */
static async handleCustomURL(url: string): Promise<string> { async runInZone(operation: () => unknown, blocking: boolean = false): Promise<string> {
const blockKey = blocking && TestingBehatBlocking.block();
try { try {
await NgZone.run(async () => { await NgZone.run(operation);
await CoreCustomURLSchemes.handleCustomURL(url);
});
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
return 'ERROR: ' + error.message; return 'ERROR: ' + error.message;
}
}
/**
* Function called when a push notification is clicked. Redirect the user to the right state.
*
* @param data Notification data.
* @return Promise resolved when done.
*/
static async notificationClicked(data: CorePushNotificationsNotificationBasicData): Promise<void> {
const blockKey = TestsBehatBlocking.block();
try {
await NgZone.run(async () => {
await CorePushNotifications.notificationClicked(data);
});
} finally { } finally {
TestsBehatBlocking.unblock(blockKey); blockKey && TestingBehatBlocking.unblock(blockKey);
} }
} }
/**
* Force execution of synchronization cron tasks without waiting for the scheduled time.
* Please notice that some tasks may not be executed depending on the network connection and sync settings.
*
* @return Promise resolved if all handlers are executed successfully, rejected otherwise.
*/
static async forceSyncExecution(): Promise<void> {
await NgZone.run(async () => {
await CoreCronDelegate.forceSyncExecution();
});
}
/** /**
* Wait all controlled components to be rendered. * Wait all controlled components to be rendered.
* *
* @return Promise resolved when all components have been rendered. * @return Promise resolved when all components have been rendered.
*/ */
static async waitLoadingToFinish(): Promise<void> { async waitLoadingToFinish(): Promise<void> {
await NgZone.run(async () => { await NgZone.run(async () => {
const elements = Array.from(document.body.querySelectorAll<HTMLElement>('core-loading')) const elements = Array.from(document.body.querySelectorAll<HTMLElement>('core-loading'))
.filter((element) => CoreDom.isElementVisible(element)); .filter((element) => CoreDom.isElementVisible(element));
@ -148,28 +127,32 @@ export class TestsBehatRuntime {
* @param button Type of button to press. * @param button Type of button to press.
* @return OK if successful, or ERROR: followed by message. * @return OK if successful, or ERROR: followed by message.
*/ */
static async pressStandard(button: string): Promise<string> { async pressStandard(button: string): Promise<string> {
this.log('Action - Click standard button: ' + button); this.log('Action - Click standard button: ' + button);
// Find button // Find button
let foundButton: HTMLElement | undefined; let foundButton: HTMLElement | undefined;
const options: TestingBehatFindOptions = {
onlyClickable: true,
containerName: '',
};
switch (button) { switch (button) {
case 'back': case 'back':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Back' }); foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Back' }, options);
break; break;
case 'main menu': // Deprecated name. case 'main menu': // Deprecated name.
case 'more menu': case 'more menu':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ foundButton = TestingBehatDomUtils.findElementBasedOnText({
text: 'More', text: 'More',
selector: 'ion-tab-button', selector: 'ion-tab-button',
}); }, options);
break; break;
case 'user menu' : case 'user menu' :
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'User account' }); foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'User account' }, options);
break; break;
case 'page menu': case 'page menu':
foundButton = TestsBehatDomUtils.findElementBasedOnText({ text: 'Display options' }); foundButton = TestingBehatDomUtils.findElementBasedOnText({ text: 'Display options' }, options);
break; break;
default: default:
return 'ERROR: Unsupported standard button type'; return 'ERROR: Unsupported standard button type';
@ -180,7 +163,7 @@ export class TestsBehatRuntime {
} }
// Click button // Click button
await TestsBehatDomUtils.pressElement(foundButton); await TestingBehatDomUtils.pressElement(foundButton);
return 'OK'; return 'OK';
} }
@ -190,7 +173,7 @@ export class TestsBehatRuntime {
* *
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static closePopup(): string { closePopup(): string {
this.log('Action - Close popup'); this.log('Action - Close popup');
let backdrops = Array.from(document.querySelectorAll('ion-backdrop')); let backdrops = Array.from(document.querySelectorAll('ion-backdrop'));
@ -206,7 +189,7 @@ export class TestsBehatRuntime {
backdrop.click(); backdrop.click();
// Mark busy until the click finishes processing. // Mark busy until the click finishes processing.
TestsBehatBlocking.delay(); TestingBehatBlocking.delay();
return 'OK'; return 'OK';
} }
@ -215,20 +198,24 @@ export class TestsBehatRuntime {
* Function to find an arbitrary element based on its text or aria label. * Function to find an arbitrary element based on its text or aria label.
* *
* @param locator Element locator. * @param locator Element locator.
* @param containerName Whether to search only inside a specific container content. * @param options Search options.
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static find(locator: TestBehatElementLocator, containerName: string): string { find(locator: TestingBehatElementLocator, options: Partial<TestingBehatFindOptions> = {}): string {
this.log('Action - Find', { locator, containerName }); this.log('Action - Find', { locator, ...options });
try { try {
const element = TestsBehatDomUtils.findElementBasedOnText(locator, containerName); const element = TestingBehatDomUtils.findElementBasedOnText(locator, {
onlyClickable: false,
containerName: '',
...options,
});
if (!element) { if (!element) {
return 'ERROR: No element matches locator to find.'; return 'ERROR: No element matches locator to find.';
} }
this.log('Action - Found', { locator, containerName, element }); this.log('Action - Found', { locator, element, ...options });
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
@ -242,11 +229,11 @@ export class TestsBehatRuntime {
* @param locator Element locator. * @param locator Element locator.
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static scrollTo(locator: TestBehatElementLocator): string { scrollTo(locator: TestingBehatElementLocator): string {
this.log('Action - scrollTo', { locator }); this.log('Action - scrollTo', { locator });
try { try {
let element = TestsBehatDomUtils.findElementBasedOnText(locator); let element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' });
if (!element) { if (!element) {
return 'ERROR: No element matches element to scroll to.'; return 'ERROR: No element matches element to scroll to.';
@ -269,7 +256,7 @@ export class TestsBehatRuntime {
* *
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static async loadMoreItems(): Promise<string> { async loadMoreItems(): Promise<string> {
this.log('Action - loadMoreItems'); this.log('Action - loadMoreItems');
try { try {
@ -316,13 +303,13 @@ export class TestsBehatRuntime {
* @param locator Element locator. * @param locator Element locator.
* @return YES or NO if successful, or ERROR: followed by message * @return YES or NO if successful, or ERROR: followed by message
*/ */
static isSelected(locator: TestBehatElementLocator): string { isSelected(locator: TestingBehatElementLocator): string {
this.log('Action - Is Selected', locator); this.log('Action - Is Selected', locator);
try { try {
const element = TestsBehatDomUtils.findElementBasedOnText(locator); const element = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: false, containerName: '' });
return TestsBehatDomUtils.isElementSelected(element, document.body) ? 'YES' : 'NO'; return TestingBehatDomUtils.isElementSelected(element, document.body) ? 'YES' : 'NO';
} catch (error) { } catch (error) {
return 'ERROR: ' + error.message; return 'ERROR: ' + error.message;
} }
@ -334,17 +321,17 @@ export class TestsBehatRuntime {
* @param locator Element locator. * @param locator Element locator.
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static async press(locator: TestBehatElementLocator): Promise<string> { async press(locator: TestingBehatElementLocator): Promise<string> {
this.log('Action - Press', locator); this.log('Action - Press', locator);
try { try {
const found = TestsBehatDomUtils.findElementBasedOnText(locator); const found = TestingBehatDomUtils.findElementBasedOnText(locator, { onlyClickable: true, containerName: '' });
if (!found) { if (!found) {
return 'ERROR: No element matches locator to press.'; return 'ERROR: No element matches locator to press.';
} }
await TestsBehatDomUtils.pressElement(found); await TestingBehatDomUtils.pressElement(found);
return 'OK'; return 'OK';
} catch (error) { } catch (error) {
@ -357,7 +344,7 @@ export class TestsBehatRuntime {
* *
* @return OK if successful, or ERROR: followed by message * @return OK if successful, or ERROR: followed by message
*/ */
static async pullToRefresh(): Promise<string> { async pullToRefresh(): Promise<string> {
this.log('Action - pullToRefresh'); this.log('Action - pullToRefresh');
try { try {
@ -390,11 +377,11 @@ export class TestsBehatRuntime {
* *
* @return OK: followed by header text if successful, or ERROR: followed by message. * @return OK: followed by header text if successful, or ERROR: followed by message.
*/ */
static getHeader(): string { getHeader(): string {
this.log('Action - Get header'); this.log('Action - Get header');
let titles = Array.from(document.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden) > ion-header h1')); let titles = Array.from(document.querySelectorAll<HTMLElement>('.ion-page:not(.ion-page-hidden) > ion-header h1'));
titles = titles.filter((title) => TestsBehatDomUtils.isElementVisible(title, document.body)); titles = titles.filter((title) => TestingBehatDomUtils.isElementVisible(title, document.body));
if (titles.length > 1) { if (titles.length > 1) {
return 'ERROR: Too many possible titles ('+titles.length+').'; return 'ERROR: Too many possible titles ('+titles.length+').';
@ -416,18 +403,19 @@ export class TestsBehatRuntime {
* @param value New value * @param value New value
* @return OK or ERROR: followed by message * @return OK or ERROR: followed by message
*/ */
static async setField(field: string, value: string): Promise<string> { async setField(field: string, value: string): Promise<string> {
this.log('Action - Set field ' + field + ' to: ' + value); this.log('Action - Set field ' + field + ' to: ' + value);
const found: HTMLElement | HTMLInputElement = TestsBehatDomUtils.findElementBasedOnText( const found: HTMLElement | HTMLInputElement = TestingBehatDomUtils.findElementBasedOnText(
{ text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' }, { text: field, selector: 'input, textarea, [contenteditable="true"], ion-select' },
{ onlyClickable: false, containerName: '' },
); );
if (!found) { if (!found) {
return 'ERROR: No element matches field to set.'; return 'ERROR: No element matches field to set.';
} }
await TestsBehatDomUtils.setElementValue(found, value); await TestingBehatDomUtils.setElementValue(found, value);
return 'OK'; return 'OK';
} }
@ -439,7 +427,7 @@ export class TestsBehatRuntime {
* @param className Constructor class name * @param className Constructor class name
* @return Component instance * @return Component instance
*/ */
static getAngularInstance<T = unknown>(selector: string, className: string): T | null { getAngularInstance<T = unknown>(selector: string, className: string): T | null {
this.log('Action - Get Angular instance ' + selector + ', ' + className); this.log('Action - Get Angular instance ' + selector + ', ' + className);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -456,7 +444,7 @@ export class TestsBehatRuntime {
* Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT' * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
* keyword so we can easily filter for it if needed. * keyword so we can easily filter for it if needed.
*/ */
static log(...args: unknown[]): void { log(...args: unknown[]): void {
const now = new Date(); const now = new Date();
const nowFormatted = String(now.getHours()).padStart(2, '0') + ':' + const nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' +
@ -468,24 +456,29 @@ export class TestsBehatRuntime {
} }
export const TestingBehatRuntime = makeSingleton(TestingBehatRuntimeService);
export type BehatTestsWindow = Window & { export type BehatTestsWindow = Window & {
M?: { // eslint-disable-line @typescript-eslint/naming-convention M?: { // eslint-disable-line @typescript-eslint/naming-convention
util?: { util?: {
pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention pending_js?: string[]; // eslint-disable-line @typescript-eslint/naming-convention
}; };
}; };
behatInit?: () => void;
behat?: unknown;
}; };
export type TestBehatElementLocator = { export type TestingBehatFindOptions = {
containerName: string;
onlyClickable: boolean;
};
export type TestingBehatElementLocator = {
text: string; text: string;
within?: TestBehatElementLocator; within?: TestingBehatElementLocator;
near?: TestBehatElementLocator; near?: TestingBehatElementLocator;
selector?: string; selector?: string;
}; };
export type TestsBehatInitOptions = { export type TestingBehatInitOptions = {
skipOnBoarding?: boolean; skipOnBoarding?: boolean;
configOverrides?: Partial<EnvironmentConfig>; configOverrides?: Partial<EnvironmentConfig>;
}; };

View File

@ -18,4 +18,4 @@ import { NgModule } from '@angular/core';
* Stub used in production to avoid including testing code in production bundles. * Stub used in production to avoid including testing code in production bundles.
*/ */
@NgModule({}) @NgModule({})
export class BehatTestingModule {} export class TestingModule {}

View File

@ -12,21 +12,25 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreAppProvider } from '@services/app'; import { CoreAppProvider } from '@services/app';
import { CoreDB, CoreDbProvider } from '@services/db'; import { TestingBehatRuntime, TestingBehatRuntimeService } from './services/behat-runtime';
type AutomatedTestsWindow = Window & { type AutomatedTestsWindow = Window & {
dbProvider?: CoreDbProvider; behat?: TestingBehatRuntimeService;
}; };
function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) { function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) {
window.dbProvider = CoreDB.instance;
}
export default function(): void {
if (!CoreAppProvider.isAutomated()) { if (!CoreAppProvider.isAutomated()) {
return; return;
} }
initializeAutomatedTestsWindow(window); window.behat = TestingBehatRuntime.instance;
} }
@NgModule({
providers: [
{ provide: APP_INITIALIZER, multi: true, useValue: () => initializeAutomatedTestsWindow(window) },
],
})
export class TestingModule {}

View File

@ -0,0 +1,16 @@
@app @javascript
Feature: It has a Behat runtime with testing helpers.
Background:
Given the following "users" exist:
| username |
| student1 |
Scenario: Finds and presses elements
Given I entered the app as "student1"
When I set the following fields to these values in the app:
| Search by activity type or name | Foo bar |
Then I should find "Search" "button" in the app
And I should find "Clear search" in the app
And I should be able to press "Search" "button" in the app
But I should not be able to press "Clear search" in the app