From f2ca22e963d48362bac8e2e33bccfb89671f9c40 Mon Sep 17 00:00:00 2001 From: tshuli Date: Fri, 4 Feb 2022 13:06:48 +0800 Subject: [PATCH] Revert "chore: remove e2e tests (#3146)" This reverts commit bdcdd1d26a89bb68e6c41aed304a8e403d7d6fa5. --- .github/workflows/ci.yml | 18 + README.md | 12 +- package-lock.json | 1140 ++++++++++++++- package.json | 7 + shared/package.json | 1 - .../bounce/__tests__/bounce.service.spec.ts | 2 +- tests/end-to-end/.eslintrc | 7 + tests/end-to-end/email-submission.e2e.js | 250 ++++ tests/end-to-end/encrypt-submission.e2e.js | 236 +++ .../files/att-folder-1/test-att.txt | 1 + .../files/att-folder-2/test-att.txt | 1 + .../files/att-folder-3/test-att.txt | 1 + tests/end-to-end/files/logo.jpg | Bin 0 -> 32796 bytes tests/end-to-end/helpers/all-fields.js | 125 ++ tests/end-to-end/helpers/all-hidden-form.js | 67 + .../end-to-end/helpers/disabled-form-basic.js | 28 + .../helpers/disabled-form-chained.js | 62 + tests/end-to-end/helpers/email-mode.js | 102 ++ tests/end-to-end/helpers/encrypt-mode.js | 252 ++++ tests/end-to-end/helpers/get-mongo-binary.js | 17 + tests/end-to-end/helpers/myinfo-form.js | 24 + tests/end-to-end/helpers/selectors.js | 228 +++ tests/end-to-end/helpers/template-fields.js | 106 ++ tests/end-to-end/helpers/triple-attachment.js | 34 + tests/end-to-end/helpers/util.js | 1281 +++++++++++++++++ .../helpers/verifiable-email-field.js | 12 + tests/end-to-end/login.e2e.js | 211 +++ 27 files changed, 4166 insertions(+), 59 deletions(-) create mode 100644 tests/end-to-end/.eslintrc create mode 100644 tests/end-to-end/email-submission.e2e.js create mode 100644 tests/end-to-end/encrypt-submission.e2e.js create mode 100644 tests/end-to-end/files/att-folder-1/test-att.txt create mode 100644 tests/end-to-end/files/att-folder-2/test-att.txt create mode 100644 tests/end-to-end/files/att-folder-3/test-att.txt create mode 100644 tests/end-to-end/files/logo.jpg create mode 100644 tests/end-to-end/helpers/all-fields.js create mode 100644 tests/end-to-end/helpers/all-hidden-form.js create mode 100644 tests/end-to-end/helpers/disabled-form-basic.js create mode 100644 tests/end-to-end/helpers/disabled-form-chained.js create mode 100644 tests/end-to-end/helpers/email-mode.js create mode 100644 tests/end-to-end/helpers/encrypt-mode.js create mode 100644 tests/end-to-end/helpers/get-mongo-binary.js create mode 100644 tests/end-to-end/helpers/myinfo-form.js create mode 100644 tests/end-to-end/helpers/selectors.js create mode 100644 tests/end-to-end/helpers/template-fields.js create mode 100644 tests/end-to-end/helpers/triple-attachment.js create mode 100644 tests/end-to-end/helpers/util.js create mode 100644 tests/end-to-end/helpers/verifiable-email-field.js create mode 100644 tests/end-to-end/login.e2e.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cb9a51264..b7cc9ef385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,24 @@ jobs: - run: npm ci - run: npm run build + test-e2e: + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Node.js + uses: actions/setup-node@v1 + with: + node-version: '14.x' + - name: Load Node.js modules + uses: actions/cache@v2 + with: + # npm cache files are stored in `~/.npm` on Linux/macOS + path: ~/.npm + key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} + - run: npm ci + - run: npm run test-e2e + test-frontend: needs: lint runs-on: ubuntu-latest diff --git a/README.md b/README.md index 7b584da37a..bd5efb8e73 100755 --- a/README.md +++ b/README.md @@ -145,7 +145,17 @@ npm run test-ci #### End-to-end tests -Removed in [#3146](https://github.com/opengovsg/FormSG/pull/3146). Will be reimplemented when the React app is ready. +```bash +npm run test-e2e +``` + +will build both the frontend and backend then run our end-to-end tests. The tests are located at [`tests/end-to-end`](./tests/end-to-end). You will need to stop the Docker dev container to be able to run the end-to-end tests. + +If you do not need to rebuild the frontend and backend, you can run + +```bash +npm run test-e2e-ci +``` ## Architecture diff --git a/package-lock.json b/package-lock.json index a4e321f184..23941bc410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -259,6 +259,34 @@ } } }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz", + "integrity": "sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.14.5", + "@babel/types": "^7.14.5" + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", + "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", + "dev": true + }, + "@babel/types": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz", + "integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.14.5", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/helper-compilation-targets": { "version": "7.16.3", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.3.tgz", @@ -597,6 +625,33 @@ "@babel/types": "^7.16.0" } }, + "@babel/helper-hoist-variables": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz", + "integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==", + "dev": true, + "requires": { + "@babel/types": "^7.14.5" + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", + "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", + "dev": true + }, + "@babel/types": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz", + "integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.14.5", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/helper-member-expression-to-functions": { "version": "7.16.5", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.5.tgz", @@ -1464,6 +1519,94 @@ } } }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz", + "integrity": "sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.14.7", + "@babel/helper-compilation-targets": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.14.5" + }, + "dependencies": { + "@babel/compat-data": { + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.7.tgz", + "integrity": "sha512-nS6dZaISCXJ3+518CWiBfEr//gHyMO02uDxBkXTKZDN5POruCnOZ1N4YBRZDCabwF8nZMWBpRxIicmXtBs+fvw==", + "dev": true + }, + "@babel/helper-compilation-targets": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz", + "integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.14.5", + "@babel/helper-validator-option": "^7.14.5", + "browserslist": "^4.16.6", + "semver": "^6.3.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz", + "integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==", + "dev": true + }, + "browserslist": { + "version": "4.16.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz", + "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001219", + "colorette": "^1.2.2", + "electron-to-chromium": "^1.3.723", + "escalade": "^3.1.1", + "node-releases": "^1.1.71" + } + }, + "caniuse-lite": { + "version": "1.0.30001244", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001244.tgz", + "integrity": "sha512-Wb4UFZPkPoJoKKVfELPWytRzpemjP/s0pe22NriANru1NoI+5bGNxzKtk7edYL8rmCWTfQO8eRiF0pn1Dqzx7Q==", + "dev": true + }, + "colorette": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", + "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.775", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.775.tgz", + "integrity": "sha512-EGuiJW4yBPOTj2NtWGZcX93ZE8IGj33HJAx4d3ouE2zOfW2trbWU+t1e0yzLr1qQIw81++txbM3BH52QwSRE6Q==", + "dev": true + }, + "node-releases": { + "version": "1.1.73", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", + "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, "@babel/plugin-proposal-optional-catch-binding": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.16.7.tgz", @@ -1618,6 +1761,23 @@ "@babel/helper-plugin-utils": "^7.14.5" } }, + "@babel/plugin-syntax-decorators": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.14.5.tgz", + "integrity": "sha512-c4sZMRWL4GSvP1EXy0woIP7m4jkVcEuG8R1TOZxPBPtp4FSM/kiPZub9UIs/Jrb5ZAOzvTUSGYrWsrSu1JvoPw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + } + } + }, "@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", @@ -1636,6 +1796,23 @@ "@babel/helper-plugin-utils": "^7.8.3" } }, + "@babel/plugin-syntax-flow": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.14.5.tgz", + "integrity": "sha512-9WK5ZwKCdWHxVuU13XNT6X73FGmutAXeor5lGFq6qhOFtMFUF4jkbijuyUdZZlpYq6E2hZeZf/u3959X9wsv0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + } + } + }, "@babel/plugin-syntax-function-sent": { "version": "7.16.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-function-sent/-/plugin-syntax-function-sent-7.16.5.tgz", @@ -1663,6 +1840,23 @@ "@babel/helper-plugin-utils": "^7.8.0" } }, + "@babel/plugin-syntax-jsx": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.14.5.tgz", + "integrity": "sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + } + } + }, "@babel/plugin-syntax-logical-assignment-operators": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", @@ -1917,6 +2111,42 @@ } } }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz", + "integrity": "sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + } + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.14.5.tgz", + "integrity": "sha512-KhcolBKfXbvjwI3TV7r7TkYm8oNXHNBqGOy6JDVwtecFaRoKYsUUqJdS10q0YDKW1c6aZQgO+Ys3LfGkox8pXA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/plugin-syntax-flow": "^7.14.5" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", + "dev": true + } + } + }, "@babel/plugin-transform-for-of": { "version": "7.16.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.16.5.tgz", @@ -5964,6 +6194,15 @@ "acorn-walk": "^7.1.1" } }, + "acorn-hammerhead": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/acorn-hammerhead/-/acorn-hammerhead-0.5.0.tgz", + "integrity": "sha512-TI9TFfJBfduhcM2GggayNhdYvdJ3UgS/Bu3sB7FB2AUmNCmCJ+TSOT6GXu+bodG5/xL74D5zE4XRaqyjgjsYVQ==", + "dev": true, + "requires": { + "@types/estree": "0.0.46" + } + }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6035,6 +6274,12 @@ "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", "dev": true }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, "angular": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/angular/-/angular-1.8.2.tgz", @@ -6210,6 +6455,12 @@ "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", "dev": true }, + "array-find": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-find/-/array-find-1.0.0.tgz", + "integrity": "sha1-bI4obRHtdoMn+OYuzuhzU8o+eLg=", + "dev": true + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -6234,6 +6485,12 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, "array-unique": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", @@ -6268,6 +6525,30 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" }, + "asar": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/asar/-/asar-2.1.0.tgz", + "integrity": "sha512-d2Ovma+bfqNpvBzY/KU8oPY67ZworixTpkjSx0PCXnQi67c2cXmssaTxpFDUM0ttopXoGx/KRxNg/GDThYbXQA==", + "dev": true, + "requires": { + "@types/glob": "^7.1.1", + "chromium-pickle-js": "^0.2.0", + "commander": "^2.20.0", + "cuint": "^0.2.2", + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.1", + "tmp-promise": "^1.0.5" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + } + } + }, "asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -6321,6 +6602,12 @@ "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", "dev": true }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -6360,6 +6647,12 @@ "dev": true, "optional": true }, + "async-exit-hook": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-1.1.2.tgz", + "integrity": "sha1-gJXXXkiMKazuBVH+hyUhadeJz7o=", + "dev": true + }, "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -6680,6 +6973,73 @@ "@types/babel__traverse": "^7.0.6" } }, + "babel-plugin-module-resolver": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-4.1.0.tgz", + "integrity": "sha512-MlX10UDheRr3lb3P0WcaIdtCSRlxdQsB1sBqL7W0raF070bGl1HQQq5K3T2vf2XAYie+ww+5AKC/WrkjRO2knA==", + "dev": true, + "requires": { + "find-babel-config": "^1.2.0", + "glob": "^7.1.6", + "pkg-up": "^3.1.0", + "reselect": "^4.0.0", + "resolve": "^1.13.1" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + } + } + }, "babel-plugin-polyfill-corejs2": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.0.tgz", @@ -6718,6 +7078,12 @@ "@babel/helper-define-polyfill-provider": "^0.3.0" } }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=", + "dev": true + }, "babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", @@ -6911,6 +7277,12 @@ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", "dev": true }, + "bin-v8-flags-filter": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bin-v8-flags-filter/-/bin-v8-flags-filter-1.2.0.tgz", + "integrity": "sha512-g8aeYkY7GhyyKRvQMBsJQZjhm2iCX3dKYvfrMpwVR8IxmUGrkpCBFoKbB9Rh0o3sTLCjU/1tFpZ4C7j3f+D+3g==", + "dev": true + }, "binary": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", @@ -7018,6 +7390,12 @@ "moment": "^2.9.0" } }, + "bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "dev": true + }, "boxicons": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/boxicons/-/boxicons-1.8.0.tgz", @@ -7047,6 +7425,15 @@ "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", "dev": true }, + "brotli": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.2.tgz", + "integrity": "sha1-UlqcrU/LqWR119OI9q7LE+7VL0Y=", + "dev": true, + "requires": { + "base64-js": "^1.1.2" + } + }, "browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -7348,6 +7735,22 @@ "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=", "dev": true }, + "callsite-record": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/callsite-record/-/callsite-record-4.1.3.tgz", + "integrity": "sha512-otAcPmu8TiHZ38cIL3NjQa1nGoSQRRe8WDDUgj5ZUwJWn1wzOYBwVSJbpVyzZ0sesQeKlYsPu9DG70fhh6AK9g==", + "dev": true, + "requires": { + "@types/error-stack-parser": "^1.3.18", + "@types/lodash": "^4.14.72", + "callsite": "^1.0.0", + "chalk": "^2.4.0", + "error-stack-parser": "^1.3.3", + "highlight-es": "^1.0.0", + "lodash": "4.6.1 || ^4.16.1", + "pinkie-promise": "^2.0.0" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -7441,6 +7844,20 @@ "lodash": "4.17.x" } }, + "chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + } + }, "chainsaw": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", @@ -7484,6 +7901,12 @@ "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", "dev": true }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, "chokidar": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", @@ -7505,12 +7928,36 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, + "chrome-remote-interface": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.30.1.tgz", + "integrity": "sha512-emKaqCjYAgrT35nm6PvTUKJ++2NX9qAmrcNRPRGyryG9Kc7wlkvO0bmvEdNMrr8Bih2e149WctJZFzUiM1UNwg==", + "dev": true, + "requires": { + "commander": "2.11.x", + "ws": "^7.2.0" + }, + "dependencies": { + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true + } + } + }, "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true }, + "chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha1-BKEGZywYsIWrd02YPfo+oTjyIgU=", + "dev": true + }, "ci-info": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", @@ -7718,6 +8165,12 @@ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, + "coffeescript": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.5.1.tgz", + "integrity": "sha512-J2jRPX0eeFh5VKyVnoLrfVFgLZtnnmp96WQSLAS8OrLm2wtQLcnikYKe1gViJKDH7vucjuhHvBKKBP3rKcD1tQ==", + "dev": true + }, "collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", @@ -8435,6 +8888,35 @@ "randomfill": "^1.0.3" } }, + "crypto-md5": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-md5/-/crypto-md5-1.0.0.tgz", + "integrity": "sha1-zMjadQx1PH7curxUKWdHKjhOhrs=", + "dev": true + }, + "css": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/css/-/css-2.2.3.tgz", + "integrity": "sha512-0W171WccAjQGGTKLhw4m2nnl0zPHUlTO/I8td4XzJgIB8Hg3ZZx71qT4G4eX8OVsSiaAKiUMy73E3nsbPlg2DQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "source-map": "^0.1.38", + "source-map-resolve": "^0.5.1", + "urix": "^0.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz", + "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, "css-color-names": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", @@ -8666,6 +9148,12 @@ "resolved": "https://registry.npmjs.org/csv-string/-/csv-string-4.0.1.tgz", "integrity": "sha512-nCdK+EWDbqLvZ2MmVQhHTmidMEsHbK3ncgTJb4oguNRpkmH5OOr+KkDRB4nqsVrJ7oK0AdO1QEsBp0+z7KBtGQ==" }, + "cuint": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz", + "integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs=", + "dev": true + }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", @@ -8863,11 +9351,26 @@ } } }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=", + "dev": true + }, "dedent-js": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", "integrity": "sha1-vuX7fJ5yfYXf+iRZDRDsGrElUwU=" }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, "deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8963,6 +9466,73 @@ } } }, + "del": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz", + "integrity": "sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=", + "dev": true, + "requires": { + "globby": "^6.1.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "p-map": "^1.1.1", + "pify": "^3.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "p-map": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz", + "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -9039,6 +9609,12 @@ } } }, + "device-specs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/device-specs/-/device-specs-1.0.0.tgz", + "integrity": "sha512-fYXbFSeilT7bnKWFi4OERSPHdtaEoDGn4aUhV5Nly6/I+Tp6JZ/6Icmd7LVIF5euyodGpxz2e/bfUmDnIdSIDw==", + "dev": true + }, "devtools-protocol": { "version": "0.0.818844", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.818844.tgz", @@ -9261,6 +9837,12 @@ "integrity": "sha512-cA1YwlRzO6TGp7yd3+KAqh9Tt6Z4CuuKqsAJP6uF/H5MQryjAGDhMhnY5cEXo8MaRCczpzSBhMPdqRIodkbZYw==", "dev": true }, + "elegant-spinner": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", + "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", + "dev": true + }, "elliptic": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", @@ -9308,7 +9890,34 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", "requires": { - "once": "^1.4.0" + "once": "^1.4.0" + } + }, + "endpoint-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/endpoint-utils/-/endpoint-utils-1.0.2.tgz", + "integrity": "sha1-CAjDNppyfNeWejn/NOvJJriBRqg=", + "dev": true, + "requires": { + "ip": "^1.1.3", + "pinkie-promise": "^1.0.0" + }, + "dependencies": { + "pinkie": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-1.0.0.tgz", + "integrity": "sha1-Wkfyi6EBXQIBvae/DzWOR77Ix+Q=", + "dev": true + }, + "pinkie-promise": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-1.0.0.tgz", + "integrity": "sha1-0dpn9UglY7t89X8oauKCLs+/NnA=", + "dev": true, + "requires": { + "pinkie": "^1.0.0" + } + } } }, "engine.io": { @@ -9496,6 +10105,15 @@ } } }, + "error-stack-parser": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-1.3.6.tgz", + "integrity": "sha1-4Oc7k+QXE40c18C3RrGkoUhUwpI=", + "dev": true, + "requires": { + "stackframe": "^0.3.1" + } + }, "es-abstract": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz", @@ -10091,6 +10709,15 @@ "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", "dev": true }, + "esotope-hammerhead": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/esotope-hammerhead/-/esotope-hammerhead-0.6.1.tgz", + "integrity": "sha512-RG4orJ1xy+zD6fTEKuDYaqCuL1ymYa1/Bp+j9c7b/u7B8yI6+Qgg8o4lT1EDAOG9eBzBtwtTWR0chqt3hr0hZw==", + "dev": true, + "requires": { + "@types/estree": "0.0.46" + } + }, "espree": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", @@ -10856,6 +11483,24 @@ } } }, + "find-babel-config": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-1.2.0.tgz", + "integrity": "sha512-jB2CHJeqy6a820ssiqwrKMeyC6nNdmrcgkKWJWmpoxpE8RKciYJXCcXRq1h2AzCo5I5BJeN2tkGEO3hLTuePRA==", + "dev": true, + "requires": { + "json5": "^0.5.1", + "path-exists": "^3.0.0" + }, + "dependencies": { + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + } + } + }, "find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -11323,6 +11968,12 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, "get-intrinsic": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", @@ -11612,6 +12263,15 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz", "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==" }, + "graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "dev": true, + "requires": { + "lodash": "^4.17.15" + } + }, "growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", @@ -11819,6 +12479,25 @@ "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", "dev": true }, + "highlight-es": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/highlight-es/-/highlight-es-1.0.3.tgz", + "integrity": "sha512-s/SIX6yp/5S1p8aC/NRDC1fwEb+myGIfp8/TzZz0rtAv8fzsdX7vGl3Q1TrXCsczFq8DI3CBFBCySPClfBSdbg==", + "dev": true, + "requires": { + "chalk": "^2.4.0", + "is-es2016-keyword": "^1.0.0", + "js-tokens": "^3.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + } + } + }, "hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -12132,6 +12811,12 @@ "resolved": "https://registry.npmjs.org/humanize/-/humanize-0.0.9.tgz", "integrity": "sha1-GZT/rs3+nEQe0r2sdFK3u0yeQaQ=" }, + "humanize-duration": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.27.0.tgz", + "integrity": "sha512-qLo/08cNc3Tb0uD7jK0jAcU5cnqCM0n568918E7R2XhMr/+7F37p4EY062W/stg7tmzvknNn9b/1+UhVRzsYrQ==", + "dev": true + }, "husky": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", @@ -12505,6 +13190,12 @@ "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=", "dev": true }, + "is-es2016-keyword": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-es2016-keyword/-/is-es2016-keyword-1.0.0.tgz", + "integrity": "sha1-9uVOEQxeT40mXmnS7Q6vjPX0dxg=", + "dev": true + }, "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -12517,6 +13208,12 @@ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "dev": true }, + "is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true + }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -12543,6 +13240,12 @@ "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", "dev": true }, + "is-jquery-obj": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-jquery-obj/-/is-jquery-obj-0.1.1.tgz", + "integrity": "sha512-18toSebUVF7y717dgw/Dzn6djOCqrkiDp3MhB8P6TdKyCVkbD1ZwE7Uz8Hwx6hUPTvKjbyYH9ncXT4ts4qLaSA==", + "dev": true + }, "is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -12576,6 +13279,30 @@ "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", "dev": true }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, "is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -15690,6 +16417,15 @@ } } }, + "linux-platform-info": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/linux-platform-info/-/linux-platform-info-0.0.3.tgz", + "integrity": "sha1-La4yQ4Xmbj11W+yD+Gx77qYc64M=", + "dev": true, + "requires": { + "os-family": "^1.0.0" + } + }, "listr2": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", @@ -16032,6 +16768,51 @@ } } }, + "log-update-async-hook": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/log-update-async-hook/-/log-update-async-hook-2.0.2.tgz", + "integrity": "sha512-HQwkKFTZeUOrDi1Duf2CSUa/pSpcaCHKLdx3D/Z16DsipzByOBffcg5y0JZA1q0n80dYgLXe2hFM9JGNgBsTDw==", + "dev": true, + "requires": { + "ansi-escapes": "^2.0.0", + "async-exit-hook": "^1.1.2", + "onetime": "^2.0.1", + "wrap-ansi": "^2.1.0" + }, + "dependencies": { + "ansi-escapes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-2.0.0.tgz", + "integrity": "sha1-W65SvkJIeN2Xg+iRDj/Cki6DyBs=", + "dev": true + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + } + } + } + }, "logform": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.3.0.tgz", @@ -16187,6 +16968,15 @@ "object-visit": "^1.0.0" } }, + "match-url-wildcard": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/match-url-wildcard/-/match-url-wildcard-0.0.4.tgz", + "integrity": "sha512-R1XhQaamUZPWLOPtp4ig5j+3jctN+skhgRmEQTUamMzmNtRG69QEirQs0NZKLtHMR7tzWpmtnS4Eqv65DcgXUA==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, "mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -16635,6 +17425,12 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" }, + "moment-duration-format-commonjs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/moment-duration-format-commonjs/-/moment-duration-format-commonjs-1.0.1.tgz", + "integrity": "sha512-KhKZRH21/+ihNRWrmdNFOyBptFi7nAWZFeFsRRpXkzgk/Yublb4fxyP0jU6EY1VDxUL/VUPdCmm/wAnpbfXdfw==", + "dev": true + }, "moment-timezone": { "version": "0.5.34", "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.34.tgz", @@ -16945,6 +17741,12 @@ "resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz", "integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==" }, + "nanoid": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-1.3.4.tgz", + "integrity": "sha512-4ug4BsuHxiVHoRUe1ud6rUFT3WUMmjXt1W0quL0CviZQANdan7D8kqN5/maw53hmAApY/jfzMRkC57BNNs60ZQ==", + "dev": true + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -17617,6 +18419,18 @@ "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", "dev": true }, + "os-family": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/os-family/-/os-family-1.1.0.tgz", + "integrity": "sha512-E3Orl5pvDJXnVmpaAA2TeNNpNhTMl4o5HghuWhOivBjEiTnJSrMYSa5uZMek1lBEvu8kKEsa2YgVcGFVDqX/9w==", + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, "p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -17916,6 +18730,12 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -17981,6 +18801,21 @@ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "dev": true }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, "pirates": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.4.tgz", @@ -18040,6 +18875,12 @@ } } }, + "pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "dev": true + }, "pop-iterate": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pop-iterate/-/pop-iterate-1.0.1.tgz", @@ -18692,6 +19533,12 @@ } } }, + "pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true + }, "private": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", @@ -18728,6 +19575,15 @@ "retry": "^0.12.0" } }, + "promisify-event": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/promisify-event/-/promisify-event-1.0.0.tgz", + "integrity": "sha1-vXUj6ga3AWLzcJeQFrU6aGxg6Q8=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -18901,6 +19757,12 @@ "weak-map": "^1.0.5" } }, + "qrcode-terminal": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.10.0.tgz", + "integrity": "sha1-p2pI4mEKGPl/o6K9UytoKs/4bFM=", + "dev": true + }, "qs": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.2.tgz", @@ -19300,6 +20162,21 @@ "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", "dev": true }, + "repeating": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz", + "integrity": "sha1-PUEUIYh3U3SU+X93+Xhfq4EPpKw=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "replicator": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/replicator/-/replicator-1.0.5.tgz", + "integrity": "sha512-saxS4y7NFkLMa92BR4bPHR41GD+f/qoDAwD2xZmN+MpDXgibkxwLO2qk7dCHYtskSkd/bWS8Jy6kC5MZUkg1tw==", + "dev": true + }, "request": { "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", @@ -19387,6 +20264,12 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, + "reselect": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz", + "integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==", + "dev": true + }, "resolve": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", @@ -19764,6 +20647,15 @@ } } }, + "sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "requires": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "saslprep": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz", @@ -20599,6 +21491,12 @@ } } }, + "stackframe": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-0.3.1.tgz", + "integrity": "sha1-M6qE8Rd6VUjIk1Uzy/6zQgl19aQ=", + "dev": true + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -21275,31 +22173,43 @@ "readable-stream": "^3.1.1" } }, - "terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "dev": true, - "requires": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - } - }, - "terser": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.0.tgz", - "integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==", + "testcafe-hammerhead": { + "version": "24.4.1", + "resolved": "https://registry.npmjs.org/testcafe-hammerhead/-/testcafe-hammerhead-24.4.1.tgz", + "integrity": "sha512-+H3+tz4n3hoFVDHsyNXHkNOi5QYVBWGcCXu2kxMwNsfsk31njjJvmPPl3Ew1trpkWgnekwZVGRPJzQwcmbsJZQ==", "dev": true, "requires": { - "commander": "^2.20.0", - "source-map": "~0.6.1", - "source-map-support": "~0.5.12" + "acorn-hammerhead": "0.5.0", + "asar": "^2.0.1", + "bowser": "1.6.0", + "brotli": "^1.3.1", + "crypto-md5": "^1.0.0", + "css": "2.2.3", + "debug": "4.3.1", + "esotope-hammerhead": "0.6.1", + "http-cache-semantics": "^4.1.0", + "iconv-lite": "0.5.1", + "lodash": "^4.17.20", + "lru-cache": "2.6.3", + "match-url-wildcard": "0.0.4", + "merge-stream": "^1.0.1", + "mime": "~1.4.1", + "mustache": "^2.1.1", + "nanoid": "^3.1.12", + "os-family": "^1.0.0", + "parse5": "2.2.3", + "pinkie": "2.0.4", + "read-file-relative": "^1.2.0", + "semver": "5.5.0", + "tough-cookie": "2.3.3", + "tunnel-agent": "0.6.0", + "webauth": "^1.1.0" }, "dependencies": { - "commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "bowser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.6.0.tgz", + "integrity": "sha1-N/w4e2Fstq7zcNq01r1AK3TFxU0=", "dev": true } } @@ -21327,21 +22237,7 @@ "integrity": "sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==", "dev": true, "requires": { - "bluebird": "^3.5.5", - "chownr": "^1.1.1", - "figgy-pudding": "^3.5.1", - "glob": "^7.1.4", - "graceful-fs": "^4.1.15", - "infer-owner": "^1.0.3", - "lru-cache": "^5.1.1", - "mississippi": "^3.0.0", - "mkdirp": "^0.5.1", - "move-concurrently": "^1.0.1", - "promise-inflight": "^1.0.1", - "rimraf": "^2.6.3", - "ssri": "^6.0.1", - "unique-filename": "^1.1.1", - "y18n": "^4.0.0" + "safer-buffer": ">= 2.1.2 < 3" } }, "chownr": { @@ -21392,7 +22288,7 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "requires": { - "yallist": "^3.0.2" + "readable-stream": "^2.0.1" } }, "make-dir": { @@ -21447,16 +22343,11 @@ "glob": "^7.1.3" } }, - "schema-utils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", - "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", - "dev": true, - "requires": { - "ajv": "^6.1.0", - "ajv-errors": "^1.0.0", - "ajv-keywords": "^3.1.0" - } + "mustache": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", + "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==", + "dev": true }, "serialize-javascript": { "version": "4.0.0", @@ -21473,7 +22364,7 @@ "integrity": "sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==", "dev": true, "requires": { - "figgy-pudding": "^3.5.1" + "punycode": "^1.4.1" } }, "y18n": { @@ -21490,17 +22381,99 @@ } } }, - "test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "testcafe-legacy-api": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/testcafe-legacy-api/-/testcafe-legacy-api-5.0.2.tgz", + "integrity": "sha512-2BWjCIN5YOUOTyOT4B0wy2TiaJgV8dWhIGpKqE3S34RjNEH62WR+JNhcnh4BSE+btp6H8n1TefcP/AObqSDSDQ==", "dev": true, "requires": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "async": "0.2.6", + "dedent": "^0.6.0", + "highlight-es": "^1.0.0", + "is-jquery-obj": "^0.1.0", + "lodash": "^4.14.0", + "moment": "^2.14.1", + "mustache": "^2.2.1", + "os-family": "^1.0.0", + "parse5": "^2.1.5", + "pify": "^2.3.0", + "pinkie": "^2.0.1", + "read-file-relative": "^1.2.0", + "strip-bom": "^2.0.0", + "testcafe-hammerhead": ">=19.4.0" + }, + "dependencies": { + "async": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.6.tgz", + "integrity": "sha1-rT83PZJJrjJIgVZVgryQ4VKrvWg=", + "dev": true + }, + "dedent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.6.0.tgz", + "integrity": "sha1-Dm2o8M5Sg471zsXI+TlrDBtko8s=", + "dev": true + }, + "mustache": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-2.3.2.tgz", + "integrity": "sha512-KpMNwdQsYz3O/SBS1qJ/o3sqUJ5wSb8gb0pul8CO0S56b9Y2ALm8zCfsjPXsqGFfoNBkDwZuZIAjhsZI03gYVQ==", + "dev": true + }, + "parse5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-2.2.3.tgz", + "integrity": "sha1-DE/EHBAAxea5PUiwP4CDg3g06fY=", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + } } }, + "testcafe-reporter-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/testcafe-reporter-json/-/testcafe-reporter-json-2.2.0.tgz", + "integrity": "sha512-wfpNaZgGP2WoqdmnIXOyxcpwSzdH1HvzXSN397lJkXOrQrwhuGUThPDvyzPnZqxZSzXdDUvIPJm55tCMWbfymQ==", + "dev": true + }, + "testcafe-reporter-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/testcafe-reporter-list/-/testcafe-reporter-list-2.1.0.tgz", + "integrity": "sha1-n6ifcbl9Pf5ktDAtXiJ97mmuxrk=", + "dev": true + }, + "testcafe-reporter-minimal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/testcafe-reporter-minimal/-/testcafe-reporter-minimal-2.1.0.tgz", + "integrity": "sha1-Z28DVHY0FDxurzq1KGgnOkvr9CE=", + "dev": true + }, + "testcafe-reporter-spec": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/testcafe-reporter-spec/-/testcafe-reporter-spec-2.1.1.tgz", + "integrity": "sha1-gVb87Q9RMkhlWa1WC8gGdkaSdew=", + "dev": true + }, + "testcafe-reporter-xunit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/testcafe-reporter-xunit/-/testcafe-reporter-xunit-2.1.0.tgz", + "integrity": "sha1-5tZsVyzhWvJmcGrw/WELKoQd1EM=", + "dev": true + }, "text-encoding": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", @@ -21570,6 +22543,12 @@ } } }, + "time-limit-promise": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/time-limit-promise/-/time-limit-promise-1.0.4.tgz", + "integrity": "sha512-FLHDDsIDducw7MBcRWlFtW2Tm50DoKOSFf0Nzx17qwXj8REXCte0eUkHrJl9QU3Bl9arG3XNYX0PcHpZ9xyuLw==", + "dev": true + }, "timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -21755,6 +22734,15 @@ "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", "dev": true }, + "truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", + "dev": true, + "requires": { + "utf8-byte-length": "^1.0.1" + } + }, "ts-essentials": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-9.1.2.tgz", @@ -22830,6 +23818,12 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz", "integrity": "sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==" }, + "webauth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/webauth/-/webauth-1.1.0.tgz", + "integrity": "sha1-ZHBPa4AmmGYFvDymKZUubib90QA=", + "dev": true + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -23364,6 +24358,40 @@ "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", "dev": true }, + "which-promise": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-promise/-/which-promise-1.0.0.tgz", + "integrity": "sha1-ILch3wWzW3Bhdv+hCwkJq6RgMDU=", + "dev": true, + "requires": { + "pify": "^2.2.0", + "pinkie-promise": "^1.0.0", + "which": "^1.1.2" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-1.0.0.tgz", + "integrity": "sha1-Wkfyi6EBXQIBvae/DzWOR77Ix+Q=", + "dev": true + }, + "pinkie-promise": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-1.0.0.tgz", + "integrity": "sha1-0dpn9UglY7t89X8oauKCLs+/NnA=", + "dev": true, + "requires": { + "pinkie": "^1.0.0" + } + } + } + }, "wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index 475730fbb3..6eda1e65c2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,12 @@ "dev": "docker-compose up --build", "docker-dev": "npm run build-frontend-dev:watch & ts-node-dev --respawn --transpile-only --inspect=0.0.0.0 --exit-child -r dotenv/config -- src/app/server.ts", "test": "npm run test-backend && npm run test-frontend", + "download-binary": "node tests/end-to-end/helpers/get-mongo-binary.js", + "test-e2e": "npm run test-e2e-build && npm run test-e2e-ci", + "test-e2e-build": "npm run build-backend && npm run build-frontend-dev", + "test-e2e-ci": "env-cmd -f tests/.test-env --use-shell \"npm run download-binary && npm run testcafe-command\"", + "testcafe-command": "testcafe --skip-js-errors -c 2 chrome:headless ./tests/end-to-end --app \"npm run test-e2e-server\" --app-init-delay 10000", + "test-e2e-server": "concurrently --success last --kill-others \"mockpass\" \"maildev\" \"node dist/backend/src/app/server.js\" \"node ./tests/mock-webhook-server.js\"", "lint-code": "eslint src/ --quiet --fix", "lint-style": "stylelint '*/**/*.css' --quiet --fix", "lint-html": "htmlhint && prettier --write './src/public/**/*.html' --ignore-path './dist/**' --loglevel silent", @@ -238,6 +244,7 @@ "supertest-session": "^4.1.0", "terser-webpack-plugin": "^1.2.3", "ts-essentials": "^9.1.2", + "testcafe": "=1.15.1", "ts-jest": "^26.5.6", "ts-loader": "^7.0.5", "ts-node": "^10.4.0", diff --git a/shared/package.json b/shared/package.json index 64f2051f51..0d373b07c3 100644 --- a/shared/package.json +++ b/shared/package.json @@ -13,7 +13,6 @@ "zod": "^3.11.6" }, "devDependencies": { - "@types/lodash": "^4.14.177", "@typescript-eslint/eslint-plugin": "^4.28.2", "@typescript-eslint/parser": "^4.28.2", "eslint-config-prettier": "^8.3.0", diff --git a/src/app/modules/bounce/__tests__/bounce.service.spec.ts b/src/app/modules/bounce/__tests__/bounce.service.spec.ts index 845fbb42a0..1c9c807c8a 100644 --- a/src/app/modules/bounce/__tests__/bounce.service.spec.ts +++ b/src/app/modules/bounce/__tests__/bounce.service.spec.ts @@ -2,7 +2,7 @@ import axios from 'axios' import { ObjectId } from 'bson' import crypto from 'crypto' -import dedent from 'dedent-js' +import dedent from 'dedent' import { cloneDeep, omit, pick } from 'lodash' import mongoose from 'mongoose' import { errAsync, okAsync } from 'neverthrow' diff --git a/tests/end-to-end/.eslintrc b/tests/end-to-end/.eslintrc new file mode 100644 index 0000000000..ccdb15c147 --- /dev/null +++ b/tests/end-to-end/.eslintrc @@ -0,0 +1,7 @@ +{ + "globals": { + "fixture": true, + "test": true, + "window": true + } +} diff --git a/tests/end-to-end/email-submission.e2e.js b/tests/end-to-end/email-submission.e2e.js new file mode 100644 index 0000000000..b5d7c62f56 --- /dev/null +++ b/tests/end-to-end/email-submission.e2e.js @@ -0,0 +1,250 @@ +const { + makeMongooseFixtures, + makeModel, + deleteDocById, + createFormFromTemplate, + createForm, + getOptionalVersion, + getBlankVersion, + verifySubmissionDisabled, + getFeatureState, + makeField, +} = require('./helpers/util') + +const { verifySubmissionE2e } = require('./helpers/email-mode') +const { verifiableEmailField } = require('./helpers/verifiable-email-field') +const { allFields } = require('./helpers/all-fields') +const { templateFields } = require('./helpers/template-fields') +const { + hiddenFieldsData, + hiddenFieldsLogicData, +} = require('./helpers/all-hidden-form') +const { tripleAttachment } = require('./helpers/triple-attachment') +const chainDisabled = require('./helpers/disabled-form-chained') + +const { cloneDeep } = require('lodash') +const { myInfoFields } = require('./helpers/myinfo-form') + +let db +let User +let Form +let Agency +let govTech +const testSpNric = 'S9912374E' +const testCpNric = 'S8979373D' +const testCpUen = '123456789A' +let captchaEnabled +fixture('Email mode submissions') + .before(async () => { + db = await makeMongooseFixtures() + Agency = makeModel(db, 'agency.server.model', 'Agency') + User = makeModel(db, 'user.server.model', 'User') + Form = makeModel(db, 'form.server.model', 'Form') + govTech = await Agency.findOne({ shortName: 'govtech' }).exec() + // Check whether captcha is enabled in environment + captchaEnabled = await getFeatureState('captcha') + }) + .after(async () => { + // Delete models defined by mongoose and close connection + db.models = {} + await db.close() + }) + .beforeEach(async (t) => { + await t.resizeWindow(1280, 800) + }) + .afterEach(async (t) => { + await deleteDocById(User, t.ctx.formData.user._id) + await deleteDocById(Form, t.ctx.form._id) + }) + +// For each of the following tests, a public form is created in the DB +// using the fields and options passed to createForm. + +// The tests check that a browser is able to navigate to the form start +// page, fill in the form using the values given in formFields.val, submit +// the form and reach the end page. + +// Form with all basic field types +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(allFields) + t.ctx.formData = formData +})('Create and submit form with all form fields', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form where all basic field types are hidden by logic +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(hiddenFieldsData) + formData.logicData = cloneDeep(hiddenFieldsLogicData) + t.ctx.formData = formData +})('Create and submit form with all field types hidden', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form where all fields are optional and no field is answered +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = allFields.map((field) => { + return getBlankVersion(getOptionalVersion(field)) + }) + t.ctx.formData = formData +})('Create and submit form with all field types optional', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form with three attachments to test de-duplication of attachment names +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(tripleAttachment) + t.ctx.formData = formData +})('Create and submit form with identical attachment names', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form with optional attachment in between mandatory ones +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(tripleAttachment) + // Modify middle attachment field to be optional and unfilled + formData.formFields[1] = getBlankVersion( + getOptionalVersion(formData.formFields[1]), + ) + // Modify first filename to account for middle field left blank + formData.formFields[0].val = '1-test-att.txt' + t.ctx.formData = formData +})( + 'Create and submit form with optional and required attachments', + async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) + }, +) + +// Form where submission is prevented using chained logic +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(chainDisabled.fields) + formData.logicData = cloneDeep(chainDisabled.logicData) + t.ctx.formData = formData +})('Create and disable form with chained logic', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionDisabled( + t, + t.ctx.form, + t.ctx.formData, + chainDisabled.toastMessage, + ) +}) + +test.before(async (t) => { + const formData = await getDefaultFormOptions() + t.ctx.formData = formData + // cloneDeep in case other tests in future import and modify templateFields + t.ctx.formData.formFields = cloneDeep(templateFields) +})('Create a form from COVID19 Templates', async (t) => { + t.ctx.form = await createFormFromTemplate( + t, + t.ctx.formData, + Form, + captchaEnabled, + ) + t.ctx.endPageTitle = 'Thank you for registering your interest.' + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Basic form with only one field and CP authentication +test.before(async (t) => { + const formData = await getDefaultFormOptions({ + authType: 'CP', + status: 'PRIVATE', + esrvcId: 'Test-eServiceId-Cp', + }) + formData.formFields = [ + { + title: 'short text', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + ].map(makeField) + t.ctx.formData = formData +})('Create and submit basic form with CorpPass authentication', async (t) => { + let authData = { testCpNric, testCpUen } + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData, authData) +}) + +// Basic form with only one field and SP authentication +test.before(async (t) => { + const formData = await getDefaultFormOptions({ + authType: 'SP', + status: 'PRIVATE', + esrvcId: 'Test-eServiceId-Sp', + }) + formData.formFields = [ + { + title: 'short text', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + ].map(makeField) + t.ctx.formData = formData +})('Create and submit basic form with SingPass authentication', async (t) => { + let authData = { testSpNric } + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData, authData) +}) + +// Form with a mix of autofilled and non-autofilled MyInfo fields +test.before(async (t) => { + const formData = await getDefaultFormOptions({ + authType: 'MyInfo', + esrvcId: 'Test-eServiceId-Sp', + }) + formData.formFields = myInfoFields + t.ctx.formData = formData +})('Create and submit basic MyInfo form', async (t) => { + let authData = { testSpNric } + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData, authData) +}) + +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(verifiableEmailField) + t.ctx.formData = formData +})('Create and submit form with verifiable email field', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Creates an object with default form options, with optional modifications. +const getDefaultFormOptions = async ({ + title = 'Submission e2e Form', + authType = 'NIL', + status = 'PUBLIC', + esrvcId = '', +} = {}) => { + title += String(Date.now()) + const user = await User.create({ + email: String(Date.now()) + '@data.gov.sg', + agency: govTech._id, + contact: '+6587654321', + }) + return { + user, + formOptions: { + responseMode: 'email', + hasCaptcha: false, + status, + title, + authType, + esrvcId, + }, + } +} diff --git a/tests/end-to-end/encrypt-submission.e2e.js b/tests/end-to-end/encrypt-submission.e2e.js new file mode 100644 index 0000000000..75c30a7aab --- /dev/null +++ b/tests/end-to-end/encrypt-submission.e2e.js @@ -0,0 +1,236 @@ +const { + makeMongooseFixtures, + makeModel, + deleteDocById, + createForm, + getOptionalVersion, + getBlankVersion, + verifySubmissionDisabled, + getFeatureState, + makeField, +} = require('./helpers/util') +const { + verifySubmissionE2e, + clearDownloadsFolder, + verifyWebhookSubmission, + createWebhookConfig, + removeWebhookConfig, +} = require('./helpers/encrypt-mode') +const { allFieldsEncrypt } = require('./helpers/all-fields') +const { verifiableEmailField } = require('./helpers/verifiable-email-field') +const { + hiddenFieldsDataEncrypt, + hiddenFieldsLogicDataEncrypt, +} = require('./helpers/all-hidden-form') + +const chainDisabled = require('./helpers/disabled-form-chained') + +const { cloneDeep } = require('lodash') + +let User +let Form +let Agency +let Submission +let govTech +let db +const testSpNric = 'S6005038D' +const testCpNric = 'S8979373D' +const testCpUen = '123456789A' +let captchaEnabled + +fixture('Storage mode submissions') + .before(async () => { + db = await makeMongooseFixtures() + Agency = makeModel(db, 'agency.server.model', 'Agency') + User = makeModel(db, 'user.server.model', 'User') + Form = makeModel(db, 'form.server.model', 'Form') + Submission = makeModel(db, 'submission.server.model', 'Submission') + govTech = await Agency.findOne({ shortName: 'govtech' }).exec() + // Check whether captcha is enabled in environment + captchaEnabled = await getFeatureState('captcha') + }) + .after(async () => { + // Delete models defined by mongoose and close connection + db.models = {} + await db.close() + }) + .beforeEach(async (t) => { + await t.resizeWindow(1280, 800) + }) + .afterEach(async (t) => { + await deleteDocById(User, t.ctx.formData.user._id) + await deleteDocById(Form, t.ctx.form._id) + // Clear used files + clearDownloadsFolder(t.ctx.form.title, t.ctx.form._id) + }) + +// Form with all field types available in storage mode +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(allFieldsEncrypt) + t.ctx.formData = formData +})('Create and submit form with all field types', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form where all basic field types are hidden by logic +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(hiddenFieldsDataEncrypt) + formData.logicData = cloneDeep(hiddenFieldsLogicDataEncrypt) + t.ctx.formData = formData +})('Create and submit form with all field types hidden', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form where all fields are optional and no field is answered +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = allFieldsEncrypt.map((field) => { + return getBlankVersion(getOptionalVersion(field)) + }) + t.ctx.formData = formData +})('Create and submit form with all field types optional', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Form where submission is prevented using chained logic +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(chainDisabled.fields) + formData.logicData = cloneDeep(chainDisabled.logicData) + t.ctx.formData = formData +})('Create and disable form with chained logic', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionDisabled( + t, + t.ctx.form, + t.ctx.formData, + chainDisabled.toastMessage, + ) +}) + +// Basic form with only one field and SP authentication +test.before(async (t) => { + const formData = await getDefaultFormOptions({ + authType: 'SP', + status: 'PRIVATE', + esrvcId: 'Test-eServiceId-Sp', + }) + formData.formFields = [ + { + title: 'short text', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + ].map(makeField) + t.ctx.formData = formData +})('Create and submit basic form with SingPass authentication', async (t) => { + let authData = { testSpNric } + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData, authData) +}) + +// Basic form with only one field and CP authentication +test.before(async (t) => { + const formData = await getDefaultFormOptions({ + authType: 'CP', + status: 'PRIVATE', + esrvcId: 'Test-eServiceId-Cp', + }) + formData.formFields = [ + { + title: 'short text', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + ].map(makeField) + t.ctx.formData = formData +})('Create and submit basic form with CorpPass authentication', async (t) => { + let authData = { testCpNric, testCpUen } + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData, authData) +}) + +// Basic form with verifiable email field +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = cloneDeep(verifiableEmailField) + t.ctx.formData = formData +})('Create and submit form with verifiable email field', async (t) => { + t.ctx.form = await createForm(t, t.ctx.formData, Form, captchaEnabled) + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) +}) + +// Basic form with only one field +test.before(async (t) => { + const formData = await getDefaultFormOptions() + formData.formFields = [ + { + title: 'short text', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + ].map(makeField) + t.ctx.formData = formData +})('Submit form with webhook integration', async (t) => { + // Create webhookUrl and write webhook configuration to disk for mock webhook server to access + const webhookUrl = await createWebhookConfig(t.ctx.formData.formOptions.title) + // Create form + t.ctx.form = await createForm( + t, + t.ctx.formData, + Form, + captchaEnabled, + webhookUrl, + ) + // Make and verify submission + await verifySubmissionE2e(t, t.ctx.form, t.ctx.formData) + // Verify webhook submission (request body is returned and stored as webhook response) + let submission = await Submission.findOne({ form: t.ctx.form._id }) + await t + .expect(submission.webhookResponses.length) + .eql(1) + .expect(submission.webhookResponses[0].webhookUrl) + .eql(webhookUrl) + .expect(submission.webhookResponses[0].response.status) + .eql(200) + const webhookRequestData = JSON.parse( + submission.webhookResponses[0].response.data, + ) + await verifyWebhookSubmission(t, t.ctx.formData, webhookRequestData) + // Remove webhook config + await removeWebhookConfig(webhookUrl) +}) + +// Creates an object with default encrypt-mode form options, with optional modifications. +// Note that a new user needs to be created for each test, otherwise the extractOTP function +// may get the wrong OTP due to a concurrency issue where it grabs the wrong email from the +// user inbox. +const getDefaultFormOptions = async ({ + title = 'Submission e2e Form', + authType = 'NIL', + status = 'PUBLIC', + esrvcId = '', +} = {}) => { + title += String(Date.now()) + const user = await User.create({ + email: String(Date.now()) + '@data.gov.sg', + agency: govTech._id, + contact: '+6587654321', + }) + return { + user, + formOptions: { + responseMode: 'encrypt', + hasCaptcha: false, + status, + title, + authType, + esrvcId, + }, + } +} diff --git a/tests/end-to-end/files/att-folder-1/test-att.txt b/tests/end-to-end/files/att-folder-1/test-att.txt new file mode 100644 index 0000000000..6a5b9689a7 --- /dev/null +++ b/tests/end-to-end/files/att-folder-1/test-att.txt @@ -0,0 +1 @@ +att-folder-1 \ No newline at end of file diff --git a/tests/end-to-end/files/att-folder-2/test-att.txt b/tests/end-to-end/files/att-folder-2/test-att.txt new file mode 100644 index 0000000000..66eb7161ae --- /dev/null +++ b/tests/end-to-end/files/att-folder-2/test-att.txt @@ -0,0 +1 @@ +att-folder-2 \ No newline at end of file diff --git a/tests/end-to-end/files/att-folder-3/test-att.txt b/tests/end-to-end/files/att-folder-3/test-att.txt new file mode 100644 index 0000000000..00f17494ea --- /dev/null +++ b/tests/end-to-end/files/att-folder-3/test-att.txt @@ -0,0 +1 @@ +att-folder-3 \ No newline at end of file diff --git a/tests/end-to-end/files/logo.jpg b/tests/end-to-end/files/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5ebf43c17d35122bcf583ed764c3c2d885b03d39 GIT binary patch literal 32796 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 v$ovMB{s#PBBLDmIHv)en@HYZ~Bk(r@e field.fieldType !== 'attachment', + ), +} diff --git a/tests/end-to-end/helpers/all-hidden-form.js b/tests/end-to-end/helpers/all-hidden-form.js new file mode 100644 index 0000000000..9c4c79cbce --- /dev/null +++ b/tests/end-to-end/helpers/all-hidden-form.js @@ -0,0 +1,67 @@ +// Exports data for a form containing all basic field types, where all the fields are +// hidden due to logic depending on the value of the first field. + +const { allFields, allFieldsEncrypt } = require('./all-fields') +const { + getBlankVersion, + getHiddenVersion, + makeField, + listIntsInclusive, +} = require('./util') +const shownFields = [ + { + title: 'Yes/No', + fieldType: 'yes_no', + val: 'No', + }, +].map(makeField) +const hiddenFields = allFields.map((field) => + getHiddenVersion(getBlankVersion(field)), +) +const hiddenFieldsEncrypt = allFieldsEncrypt.map((field) => + getHiddenVersion(getBlankVersion(field)), +) + +const hiddenFieldsLogicData = [ + { + showFieldIndices: listIntsInclusive( + shownFields.length, + shownFields.length + hiddenFields.length - 1, + ), + conditions: [ + { + fieldIndex: 0, + state: 'is equals to', + value: 'Yes', + ifValueType: 'single-select', + }, + ], + logicType: 'showFields', + }, +] +const hiddenFieldsLogicDataEncrypt = [ + { + showFieldIndices: listIntsInclusive( + shownFields.length, + shownFields.length + hiddenFieldsEncrypt.length - 1, + ), + conditions: [ + { + fieldIndex: 0, + state: 'is equals to', + value: 'Yes', + ifValueType: 'single-select', + }, + ], + logicType: 'showFields', + }, +] +const hiddenFieldsData = [...shownFields, ...hiddenFields] +const hiddenFieldsDataEncrypt = [...shownFields, ...hiddenFieldsEncrypt] + +module.exports = { + hiddenFieldsData, + hiddenFieldsLogicData, + hiddenFieldsDataEncrypt, + hiddenFieldsLogicDataEncrypt, +} diff --git a/tests/end-to-end/helpers/disabled-form-basic.js b/tests/end-to-end/helpers/disabled-form-basic.js new file mode 100644 index 0000000000..d48b18ac83 --- /dev/null +++ b/tests/end-to-end/helpers/disabled-form-basic.js @@ -0,0 +1,28 @@ +const { makeField } = require('./util') +const fields = [ + { + title: 'Yes/No', + fieldType: 'yes_no', + val: 'Yes', + }, +].map(makeField) +const logicData = [ + { + conditions: [ + { + fieldIndex: 0, + value: 'Yes', + state: 'is equals to', + ifValueType: 'single-select', + }, + ], + logicType: 'preventSubmit', + preventSubmitMessage: 'You shall not pass', + }, +] + +module.exports = { + fields, + logicData, + toastMessage: logicData[0].preventSubmitMessage, +} diff --git a/tests/end-to-end/helpers/disabled-form-chained.js b/tests/end-to-end/helpers/disabled-form-chained.js new file mode 100644 index 0000000000..87643738c8 --- /dev/null +++ b/tests/end-to-end/helpers/disabled-form-chained.js @@ -0,0 +1,62 @@ +const { makeField } = require('./util') +const fields = [ + { + title: 'Number', + fieldType: 'number', + val: '10', + }, + { + title: 'Favourite Food', + fieldType: 'dropdown', + fieldOptions: ['Rice', 'Chocolate', 'Ice-Cream'], + val: 'Chocolate', + }, + { + title: 'Yes/No', + fieldType: 'yes_no', + val: 'Yes', + }, +].map(makeField) +const logicData = [ + { + showFieldIndices: [1], + conditions: [ + { + fieldIndex: 0, + state: 'is more than or equal to', + value: '10', + ifValueType: 'number', + }, + ], + logicType: 'showFields', + }, + { + showFieldIndices: [2], + conditions: [ + { + fieldIndex: 1, + state: 'is either', + value: ['Rice', 'Chocolate'], + ifValueType: 'multi-select', + }, + ], + logicType: 'showFields', + }, + { + conditions: [ + { + fieldIndex: 2, + state: 'is equals to', + value: 'Yes', + ifValueType: 'single-select', + }, + ], + logicType: 'preventSubmit', + preventSubmitMessage: 'Bring me a shrubbery', + }, +] +module.exports = { + fields, + logicData, + toastMessage: logicData[2].preventSubmitMessage, +} diff --git a/tests/end-to-end/helpers/email-mode.js b/tests/end-to-end/helpers/email-mode.js new file mode 100644 index 0000000000..72c9f7794b --- /dev/null +++ b/tests/end-to-end/helpers/email-mode.js @@ -0,0 +1,102 @@ +const { + getSubmission, + expectStartPage, + fillInForm, + submitForm, + expectEndPage, + appUrl, + getSubstringBetween, + decodeHtmlEntities, + expectContains, + getResponseArray, + getResponseTitle, + expectSpcpLogin, + getAuthFields, +} = require('./util') + +// Checks that an attachment field's attachment is contained in the email. +const expectAttachment = async (t, field, attachments) => { + // Attachments can only be in fields that are visible AND either required + // or optional but filled + if (field.isVisible && (field.required || !field.isLeftBlank)) { + const attachment = attachments.find((att) => att.fileName === field.val) + // Check that attachment exists + await t.expect(attachment).ok() + // Check that contents match + await t.expect(attachment.content).eql(field.content) + } +} + +// Grab submission email and verify its contents +const verifySubmission = async (t, testFormData, authData) => { + let { user, formFields, formOptions } = testFormData + const authType = formOptions ? formOptions.authType : 'NIL' + // Add verified authentication data (NRIC/UEN/UID) at the end + formFields = formFields.concat(getAuthFields(authType, authData)) + formFields = formFields.filter((f) => f.fieldType !== 'section') + + const title = formOptions ? formOptions.title : '' + const { html, subject, to, from, attachments } = await getSubmission(title) + // Verify recipient of email + await t.expect(to).eql(user.email) + + // Verify sender of email + await t.expect(from).eql('FormSG ') + + // Verify subject of email + await t.expect(subject).contains(`formsg-auto: ${title} (#`) + // Verify form content in email + for (let field of formFields) { + const contained = [ + getResponseTitle(field, false, 'email'), + ...getResponseArray(field, 'email'), + ] + await expectContains(t, html, contained) + if (field.fieldType === 'attachment') { + await expectAttachment(t, field, attachments) + } + } + + // Verify JSON for data collation + const emailJSONStr = getSubstringBetween( + html, + '

-- Start of JSON --

', + '

-- End of JSON --

', + ) + await t.expect(emailJSONStr).notEql(null) + const emailJSON = JSON.parse(decodeHtmlEntities(emailJSONStr)) + const response = emailJSON.filter( + ({ question }) => !/(Response ID|Timestamp)/.test(question), + ) + await t.expect(response.length).notEql(0) + let rowIdx = 0 // A table could result in multiple rows of answers, so there might be more answers than form fields + for (let field of formFields) { + await t + .expect(response[rowIdx].question) + .eql(getResponseTitle(field, true, 'email')) + for (let expectedAnswer of getResponseArray(field, 'email')) { + await t.expect(response[rowIdx].answer).eql(expectedAnswer) + rowIdx++ + } + } +} + +// Open form, fill it in, submit and verify submission +const verifySubmissionE2e = async (t, form, formData, authData) => { + // Verify that form can be accessed + await expectStartPage(t, form, formData, appUrl) + if (authData) { + await expectSpcpLogin(t, formData.formOptions.authType, authData) + } + // Fill in form= + await fillInForm(t, form, formData) + await submitForm(t) + // Verify that end page is shown + await expectEndPage(t) + // Verify that submission is as expected + await verifySubmission(t, formData, authData) +} + +module.exports = { + verifySubmissionE2e, +} diff --git a/tests/end-to-end/helpers/encrypt-mode.js b/tests/end-to-end/helpers/encrypt-mode.js new file mode 100644 index 0000000000..71418fc989 --- /dev/null +++ b/tests/end-to-end/helpers/encrypt-mode.js @@ -0,0 +1,252 @@ +const { adminTabs, dataTab } = require('./selectors') + +const { + expectStartPage, + fillInForm, + submitForm, + expectEndPage, + appUrl, + getResponseArray, + getResponseTitle, + expectContains, + expectSpcpLogin, + getAuthFields, + getDownloadsFolder, +} = require('./util') + +const fs = require('fs') +const rimraf = require('rimraf') +const parse = require('csv-parse/sync').parse +const ngrok = require('ngrok') + +// Index of the column headers in the exported CSV. The first 4 rows are +// metadata about the number of responses decrypted. +const CSV_HEADER_ROW_INDEX = 5 +// The first two columns are "Response ID" and "Timestamp", so the +// fields to check only start from the third column. +const CSV_ANSWER_COL_INDEX = 3 + +const WEBHOOK_PORT = process.env.MOCK_WEBHOOK_PORT +const WEBHOOK_CONFIG_FILE = process.env.MOCK_WEBHOOK_CONFIG_FILE + +// We can't just call getResponseArray because CSVs use different +// delimiters for checkbox and table. Hence we treat checbox and table +// specially, and call getResponseArray for the rest. +const addExpectedCsvAnswer = (field, answers) => { + const numCols = field.fieldType === 'table' ? field.val.length : 1 + switch (field.fieldType) { + case 'table': + for (let i = 0; i < numCols; i++) { + answers.push(field.val[i].join(';')) + } + break + case 'checkbox': + if (!field.isVisible || field.isLeftBlank) { + answers.push('') + } else { + answers.push( + field.val + .map((selected) => { + return field.fieldOptions.includes(selected) + ? selected + : `Others: ${selected}` + }) + .join(';'), + ) + } + break + default: + answers.push(...getResponseArray(field)) + } +} + +// We can't just call getResponseTitle because the prefixes are different +// for verifiable fields in CSVs, and the number of repetitions of the title +// depends on the number of rows in the answer for table fields. +const addExpectedCsvTitle = (field, headers) => { + let responseTitle = getResponseTitle(field, false, 'encrypt') + const numCols = field.fieldType === 'table' ? field.val.length : 1 + for (let i = 0; i < numCols; i++) { + headers.push(responseTitle) + } +} + +const addExpectedCsvResponse = (field, headers, answers) => { + addExpectedCsvTitle(field, headers) + addExpectedCsvAnswer(field, answers) +} + +const emptyCallback = () => {} + +const clearDownloadsFolder = (formTile, formId) => { + const downloadsFolder = getDownloadsFolder() + rimraf( + `${downloadsFolder}/Form Secret Key - ${formTile}.txt`, + { + glob: false, + }, + emptyCallback, + ) + rimraf( + `${downloadsFolder}/${formTile}-${formId}.csv`, + { + glob: false, + }, + emptyCallback, + ) +} + +const createWebhookConfig = async (formTitle) => { + const encodedTitle = encodeURI(formTitle) + const webhookUrl = await ngrok.connect(WEBHOOK_PORT) + const downloadsFolder = getDownloadsFolder() + fs.writeFileSync( + `${downloadsFolder}/${WEBHOOK_CONFIG_FILE}`, + `${encodedTitle},${webhookUrl}`, + 'utf8', + ) + return webhookUrl +} + +const removeWebhookConfig = async (url) => { + await ngrok.disconnect(url) + const downloadsFolder = getDownloadsFolder() + rimraf( + `${downloadsFolder}/${WEBHOOK_CONFIG_FILE}`, + { + glob: false, + }, + emptyCallback, + ) +} + +// Download the CSV and verify its contents +async function checkDownloadCsv(t, formData, authData, formId) { + let { formFields, formOptions } = formData + formFields = formFields.concat(getAuthFields(formOptions.authType, authData)) + await t.click(dataTab.exportBtn) + await t.click(dataTab.exportBtnDropdownResponses) + await t.wait(5000) + const csvContent = await fs.promises.readFile( + `${getDownloadsFolder()}/${formOptions.title}-${formId}.csv`, + ) + const records = parse(csvContent, { relax_column_count: true }) + const headers = ['Response ID', 'Timestamp', 'Download Status'] + const answers = [] + formFields.forEach((field) => addExpectedCsvResponse(field, headers, answers)) + await t.expect(records[CSV_HEADER_ROW_INDEX]).eql(headers) + const actualAnswers = + records[CSV_HEADER_ROW_INDEX + 1].slice(CSV_ANSWER_COL_INDEX) + await t.expect(actualAnswers).eql(answers) +} + +// Check that a table response in the responses view is correct +async function checkTableResponse(t, field, index) { + for (let i = 0; i < field.val.length; i++) { + const row = field.val[i] + for (let j = 0; j < row.length; j++) { + await t + .expect(dataTab.getNthFieldTableCell(index, i, j).value) + .eql(row[j]) + } + } +} + +// Download file and check that its contents are correct +async function checkAttachmentContent(t, field, fieldIndex) { + await t.click(dataTab.getNthFieldDownloadLink(fieldIndex)) + const filePath = `${getDownloadsFolder()}/${field.val}` + const fileContent = await fs.promises.readFile(filePath, 'ascii') + await t.expect(fileContent).eql(field.content) + rimraf(filePath, { glob: false }, emptyCallback) +} + +// Click on the given row +async function clickResponseRow(t, index) { + await t.click(dataTab.getNthSubmission(index)) +} + +// Navigate to the results tab +async function navigateToResults(t, id) { + await t.navigateTo(`${appUrl}/#!/${id}/admin`).click(adminTabs.data) +} + +// Type in the secretKey +async function enterSecretKey(t, secretKey) { + await t + .typeText(dataTab.secretKeyInput, secretKey, { paste: true }) + .click(dataTab.unlockResponsesBtn) +} + +// Checking if the decrypted response is correct +async function checkDecryptedResponses(t, formData, authData) { + let { formFields, formOptions } = formData + formFields = formFields.concat(getAuthFields(formOptions.authType, authData)) + for (let i = 0; i < formFields.length; i++) { + const field = formFields[i] + await t + .expect(dataTab.getNthFieldTitle(i).textContent) + .contains(getResponseTitle(field, false, 'encrypt')) + if (field.fieldType === 'table') { + await checkTableResponse(t, field, i) + } else { + await expectContains( + t, + dataTab.getNthFieldAnswer(i).textContent, + getResponseArray(field, 'encrypt'), + ) + } + if ( + field.fieldType === 'attachment' && + field.isVisible && + !field.isLeftBlank + ) { + await checkAttachmentContent(t, field, i) + } + } +} + +// Open form, fill it in, submit, then log in and verify response +// in data tab +const verifySubmissionE2e = async (t, form, formData, authData) => { + // Going to the form, filling it in and submitting + await expectStartPage(t, form, formData, appUrl) + if (authData) { + await expectSpcpLogin(t, formData.formOptions.authType, authData) + } + await fillInForm(t, form, formData) + await submitForm(t) + await expectEndPage(t) + + // Ensuring that submission is decrypted and values are correct + // No need to log in again as signin was already done to create form + const { _id } = form + await navigateToResults(t, _id) + await enterSecretKey(t, formData.formOptions.secretKey) + await checkDownloadCsv(t, formData, authData, _id) + await clickResponseRow(t, 0) + await checkDecryptedResponses(t, formData, authData) +} + +const verifyWebhookSubmission = async (t, formData, webhookRequestData) => { + let { formFields } = formData + for (let i = 0; i < formFields.length; i++) { + await t + .expect(formFields[i].title) + .eql(webhookRequestData.responses[i].question) + await t + .expect(formFields[i].fieldType) + .eql(webhookRequestData.responses[i].fieldType) + await t + .expect(formFields[i].val) + .eql(webhookRequestData.responses[i].answer) + } +} + +module.exports = { + verifySubmissionE2e, + clearDownloadsFolder, + verifyWebhookSubmission, + createWebhookConfig, + removeWebhookConfig, +} diff --git a/tests/end-to-end/helpers/get-mongo-binary.js b/tests/end-to-end/helpers/get-mongo-binary.js new file mode 100644 index 0000000000..719add314f --- /dev/null +++ b/tests/end-to-end/helpers/get-mongo-binary.js @@ -0,0 +1,17 @@ +const MongoBinary = require('mongodb-memory-server-core').MongoBinary +if (!process.env.MONGO_BINARY_VERSION) { + console.error('Environment var MONGO_BINARY_VERSION is missing') + process.exit(1) +} + +// mongodb-memory-server-core does not automatically download any binary version +// Therefore, trigger download of binary before tests run +MongoBinary.getPath({ version: String(process.env.MONGO_BINARY_VERSION) }) + .then((binPath) => { + console.info(`mongodb-memory-server: binary path is ${binPath}`) + }) + .catch((err) => { + console.error(`failed to download/install MongoDB binaries. The error: +${err}`) + process.exit(1) + }) diff --git a/tests/end-to-end/helpers/myinfo-form.js b/tests/end-to-end/helpers/myinfo-form.js new file mode 100644 index 0000000000..0a277cc80f --- /dev/null +++ b/tests/end-to-end/helpers/myinfo-form.js @@ -0,0 +1,24 @@ +const { makeField } = require('./util') + +const myInfoFields = [ + { + myInfo: { attr: 'name' }, + disabled: true, + title: '[MyInfo] Name', + val: 'TIMOTHY TAN CHENG GUAN', + }, + { + myInfo: { attr: 'sex' }, + disabled: true, + title: '[MyInfo] Gender', + val: 'MALE', + }, + { + myInfo: { attr: 'workpassstatus' }, + val: 'Live', + title: 'Workpass status', + fieldType: 'dropdown', + }, +].map(makeField) + +module.exports = { myInfoFields } diff --git a/tests/end-to-end/helpers/selectors.js b/tests/end-to-end/helpers/selectors.js new file mode 100644 index 0000000000..8aab05329e --- /dev/null +++ b/tests/end-to-end/helpers/selectors.js @@ -0,0 +1,228 @@ +const { Selector } = require('testcafe') +const { + types: basicTypes, +} = require('../../../dist/backend/shared/constants/field/basic') +const { + types: myInfoTypes, +} = require('../../../dist/backend/shared/constants/field/myinfo') + +const landingPage = { + tagline: Selector('#tagline'), +} + +// Signin page +const signInPage = { + emailInput: Selector('#email-input'), + getStartedBtn: Selector('.btn-grp').child('button').withText('GET STARTED'), + otpMsg: Selector('.alert-custom'), + otpInput: Selector('#otp-input'), + signInBtn: Selector('.btn-grp').child('button').withText('SIGN IN'), + emailErrorMsg: Selector('.alert-error'), + resendOtpLink: Selector('a').withText('Resend OTP'), +} +// Form list dashboard and create form modal +const formList = { + createFormBtn: Selector('#list-form #create-new, #list-form #welcome-btn'), + welcomeMessage: Selector('#list-form #welcome'), + avatarDropdown: Selector('.navbar__avatar'), + logOutBtn: Selector('.navbar__dropdown__logout'), +} +const createFormModal = { + startFromScratchBtn: Selector('#start-from-scratch-button'), + templateCard: Selector('#form-card').nth(3), // volunteer registration + formTitleInput: Selector('#settings-name').parent(), + emailModeRadio: Selector('#settings-form input[value="email"]').parent(), + encryptModeRadio: Selector('#settings-form input[value="encrypt"]').parent(), + emailListInput: Selector('#settings-email').parent(), + startBtn: Selector('#btn-create'), + secretKeyDiv: Selector('.copy-key .text'), + downloadKeyBtn: Selector('#create-form-secret-key i.bx-download').parent(), + encryptModeContinueBtn: Selector('#btn-continue'), +} +const createFormTemplateModal = { + useTemplateBtn: Selector('.use-template-btn'), +} +// Admin tabs navbar +const adminTabParent = Selector('#admin-tabs-container') +const adminTabs = { + build: adminTabParent.child('li[heading="Build"]'), + logic: adminTabParent.child('li[heading="Logic"]'), + settings: adminTabParent.child('li[heading="Settings"]'), + share: adminTabParent.child('li[heading="Share"]'), + data: adminTabParent.child('li[heading="Data"]'), +} +// Maps fieldType property to text of field panels in the Build tab +const BASIC_FIELD_TYPE_TO_VALUE = {} +basicTypes.forEach((type) => { + BASIC_FIELD_TYPE_TO_VALUE[type.name] = type.value +}) +const MYINFO_ATTR_TO_VALUE = {} +myInfoTypes.forEach((type) => { + MYINFO_ATTR_TO_VALUE[type.name] = type.value +}) +// Admin tab contents +const buildTab = { + basicTab: Selector('#add-field .nav-tabs li[heading="Basic"]'), + myInfoTab: Selector('#add-field .nav-tabs li').withText('MyInfo'), + getFieldPanel: (fieldType) => + // We need withExactText otherwise Mobile Number will match Number + Selector('.add-field-panel .add-field-text').withExactText( + BASIC_FIELD_TYPE_TO_VALUE[fieldType], + ), + getMyInfoPanel: (myInfoAttr) => + Selector('.add-field-panel .add-field-text').withExactText( + MYINFO_ATTR_TO_VALUE[myInfoAttr], + ), +} +const logicTab = { + addLogicBtn: Selector('#add-new-logic'), +} + +const settingsTab = { + formStatus: Selector('#golive-option'), + activateBtn: Selector('#btn-live'), + getAuthRadioInput: (authType) => Selector(`#auth-type-${authType}`), + getAuthRadioLabel: (authType) => Selector(`#auth-type-${authType}`).parent(), + esrvcIdInput: Selector('#enable-auth-options input[type="text"]'), + captchaToggleInput: Selector('#enable-captcha input'), + captchaToggleLabel: Selector('#enable-captcha input').parent(), + formTitleInput: Selector('#settings-name'), + emailListInput: Selector('#settings-email'), + webhookUrlInput: Selector('#settings-webhook-url'), +} + +const dataTab = { + secretKeyInput: Selector('#secretKeyInput'), + unlockResponsesBtn: Selector('button').withText('UNLOCK RESPONSES'), + getNthSubmission: (n) => Selector('#responses-tab tbody tr').nth(n), + getNthFieldTitle: (n) => Selector('.response-title-container').nth(n), + getNthFieldAnswer: (n) => Selector('.response-answer').nth(n), + getNthFieldDownloadLink: (n) => Selector('.response-answer').nth(n).find('a'), + exportBtn: Selector('#btn-export-dropdown'), + exportBtnDropdownResponses: Selector('#btn-export-dropdown-responses'), + getNthFieldTableCell: (n, row, col) => + Selector('.response-answer') + .nth(n) + .find('.table-row') + .nth(row + 1) // Header is also a row + .find('.table-column') + .nth(col) + .find('input'), +} + +// Edit field modal +const editFieldModal = { + title: Selector('input[name="title"]'), + saveBtn: Selector('.modal-save-btn'), + getToggle: (toggleText) => + Selector('.toggle-option div') + .withText(toggleText) + .nextSibling() + .find('.toggle-selector'), + getOptionInput: (n) => Selector('.option-panel .option').nth(n).find('input'), + optionTextArea: Selector('.optionFrom').find('textarea'), + minValInput: Selector('.min-val-input'), + maxValInput: Selector('.max-val-input'), + addOption: Selector('a').withText('Add Option'), + rating: { + ratingStepsDropdown: Selector('.ui-select-container').nth(0), + ratingShapeDropdown: Selector('.ui-select-container').nth(1), + }, + table: { + tableMinRows: Selector('input[type="number"]').parent(), + getNthColTitle: (n) => + Selector('.table-column-card').nth(n).find('input[type="text"]'), + getNthColFieldType: (n) => + Selector('.table-column-card').nth(n).find('.table-column-dropdown'), + getNthColRequiredToggle: (n) => + Selector('.table-column-card').nth(n).find('.toggle-selector'), + getNthColTextArea: (n) => + Selector('.table-column-card').nth(n).find('textarea'), + addColumn: Selector('#add-column'), + }, + attachmentSizeDropdown: Selector('.ui-select-container'), +} + +// Activate form modal +const activateFormModal = { + secretKeyInput: Selector('#secretKeyInput'), + acknowledgementInput: Selector('#acknowledgementInput'), + activateFormBtn: Selector('button').withText('ACTIVATE FORM'), + closeModalBtn: Selector('#btn-close'), +} + +// Edit logic modal +const editLogicModal = { + getNthConditionField: (n) => + Selector('.if-container').nth(n).find('.field-input').nth(0), + getNthConditionState: (n) => + Selector('.if-container').nth(n).find('.field-input').nth(1), + getNthConditionValue: (n) => + Selector('.if-container').nth(n).find('.field-input').nth(2), + // The multi option is used for ifValueType=multi-select, when + // selecting more than one element + getNthConditionValueMulti: (n) => + Selector('.if-container') + .nth(n) + .find('.field-input') + .nth(2) + .find('input[type="search"]'), + addConditionBtn: Selector('.if-btn-container') + .nth(0) + .find('.add-condition-btn'), + showFieldsBtn: Selector('.show-fields-option'), + preventSubmitBtn: Selector('.prevent-submit-option'), + showFieldsDropdown: Selector('.show-fields-container .field-input'), + showFieldsSearch: Selector('.show-fields-container input[type="search"]'), + preventSubmitTextArea: Selector('.prevent-submit-container textarea'), + saveBtn: Selector('.modal-save-btn'), +} + +// Form submission +const formPage = { + startTitle: Selector('#start-page-title'), + endTitle: Selector('#end-page-title'), + getFieldElement: (id, element = '') => + Selector(`div[data-id="${id}"] ${element}`), + getTableCell: (id, row, col) => { + return Selector(`div[data-id="${id}"] .table-row`) + .nth(row + 1) // + 1 because the header is also a row + .find(`.table-column`) + .nth(col) + }, + submitBtn: Selector('#form-submit button'), + submitPreventedMessage: Selector('#submit-prevented-message'), + spcpLoginBtn: Selector('#start-page-btn-container button span').withText( + 'LOGIN', + ), + spcpLogoutBtn: Selector('#start-page-btn-container button span').withText( + 'LOG OUT', + ), +} + +const mockpass = { + loginBtn: Selector('.container.visible-lg #loginModelbtn'), + consentBtn: Selector('input[type=submit]'), + nricDropdownBtn: Selector('#dropdownMenuButton'), + getNricOption: (nric) => Selector('.dropdown-menu li').withText(nric), + getNricUenOption: (nric, uen) => + Selector('.dropdown-menu li').withText(nric).withText(uen), +} + +module.exports = { + formList, + createFormModal, + createFormTemplateModal, + adminTabs, + settingsTab, + signInPage, + dataTab, + landingPage, + formPage, + buildTab, + editFieldModal, + activateFormModal, + logicTab, + editLogicModal, + mockpass, +} diff --git a/tests/end-to-end/helpers/template-fields.js b/tests/end-to-end/helpers/template-fields.js new file mode 100644 index 0000000000..f3e5ee8435 --- /dev/null +++ b/tests/end-to-end/helpers/template-fields.js @@ -0,0 +1,106 @@ +// Exports data for all basic field types. +const { makeField } = require('./util') + +const templateFieldsInfo = [ + { + title: 'Personal Particulars', + fieldType: 'section', + val: '', + }, + { + title: 'Full Name', + fieldType: 'textfield', + val: 'Lorem Ipsum', + }, + { + title: 'Email Address', + fieldType: 'email', + val: 'user@domain.com', + }, + { + title: 'Contact Number', + fieldType: 'mobile', + val: '+6581234567', + }, + { + title: 'Residential Address', + fieldType: 'textarea', + val: 'some place i am living', + }, + { + title: 'Postal Code', + fieldType: 'number', + val: '123456', + }, + { + title: 'Availability', + fieldType: 'section', + val: '', + }, + { + fieldOptions: [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ], + title: 'Preferred Days', + fieldType: 'checkbox', + fieldValue: [false, false, false, false, false, false, false, false], + val: ['Monday'], + }, + { + fieldOptions: ['1 Jul 2020', '2 Jul 2020', '3 Jul 2020'], + title: 'Preferred Dates', + fieldType: 'checkbox', + fieldValue: [false, false, false, false], + val: ['1 Jul 2020'], + }, + { + fieldOptions: ['9.00am - 12.00pm', '1.00pm - 4.00pm', '6.00pm - 9.00pm'], + title: 'Preferred Timeslots', + fieldType: 'checkbox', + fieldValue: [false, false, false, false], + val: ['9.00am - 12.00pm'], + }, + { + fieldOptions: ['North', 'South', 'East', 'West', 'Central'], + title: 'Preferred Locations', + fieldType: 'checkbox', + fieldValue: [false, false, false, false, false, false], + val: ['North'], + }, + { + title: 'Health Declaration', + fieldType: 'section', + val: '', + }, + { + title: 'Have you, or anyone living with you, been diagnosed with COVID-19?', + fieldType: 'yes_no', + val: 'No', + }, + { + title: + 'Are you, or anyone living with you, experiencing respiratory or flu-like symptoms?', + fieldType: 'yes_no', + val: 'No', + }, + { + title: + 'Are you, or anyone living with you, under a Home Quarantine Order, Stay Home Notice, or Leave of Absence?', + fieldType: 'yes_no', + val: 'No', + }, + { + title: + 'Within the past 14 days, have you or anyone living with you had close contact with', + fieldType: 'yes_no', + val: 'No', + }, +] +const templateFields = templateFieldsInfo.map(makeField) +module.exports = { templateFields } diff --git a/tests/end-to-end/helpers/triple-attachment.js b/tests/end-to-end/helpers/triple-attachment.js new file mode 100644 index 0000000000..4bf8cd3b45 --- /dev/null +++ b/tests/end-to-end/helpers/triple-attachment.js @@ -0,0 +1,34 @@ +// Form fields in a form with multiple attachments with the same filename. +// Meant to test de-duplication of attachment filenames. Note that the appending +// of the number to the file name is reversed: if attachment field 1 and attachment +// field 2 get the same filename 'fn.jpg', then attachment field 2 retains the old +// filename, while attachment field 1 gets renamed to '1-fn.jpg'. +const { makeField } = require('./util') +module.exports = { + tripleAttachment: [ + { + title: 'Attachment 1', + fieldType: 'attachment', + attachmentSize: '1', + val: '2-test-att.txt', + path: '../files/att-folder-1/test-att.txt', + content: 'att-folder-1', + }, + { + title: 'Attachment 2', + fieldType: 'attachment', + attachmentSize: '1', + val: '1-test-att.txt', + path: '../files/att-folder-2/test-att.txt', + content: 'att-folder-2', + }, + { + title: 'Attachment 3', + fieldType: 'attachment', + attachmentSize: '1', + val: 'test-att.txt', + path: '../files/att-folder-3/test-att.txt', + content: 'att-folder-3', + }, + ].map(makeField), +} diff --git a/tests/end-to-end/helpers/util.js b/tests/end-to-end/helpers/util.js new file mode 100644 index 0000000000..0f0bf14ef8 --- /dev/null +++ b/tests/end-to-end/helpers/util.js @@ -0,0 +1,1281 @@ +const axios = require('axios') +const { ClientFunction } = require('testcafe') +const _ = require('lodash') +const mongoose = require('mongoose') +mongoose.Promise = global.Promise +const fs = require('fs') + +const appUrl = 'http://localhost:5000' +const mailUrl = 'http://localhost:1080' +const dbUri = 'mongodb://127.0.0.1:3000/formsg' +const DATE = 17 + +const { + formList, + signInPage, + formPage, + createFormModal, + createFormTemplateModal, + adminTabs, + settingsTab, + activateFormModal, + buildTab, + editFieldModal, + logicTab, + editLogicModal, + mockpass, +} = require('./selectors') + +const { types } = require('../../../dist/backend/shared/constants/field/basic') + +const { SPCPFieldTitle } = require('../../../dist/backend/src/types/field') + +const NON_SUBMITTED_FIELDS = types + .filter((field) => !field.submitted) + .map((field) => field.name) + +/** + * Utility to manage receiving of emails + */ +const mailClient = { + getAll: () => axios.get(`${mailUrl}/email`).then((res) => res.data), + + deleteAll: () => axios.delete(`${mailUrl}/email/all`), + + deleteById: (id) => axios.delete(`${mailUrl}/email/${id}`), + + getAttachment: (id, filename) => + axios.get(`${mailUrl}/email/${id}/attachment/${filename}`), +} + +const getDownloadsFolder = () => { + let downloadsFolder = `${process.env.HOME}/Downloads` + try { + fs.statSync(downloadsFolder) + } catch (e) { + downloadsFolder = '/tmp' + } + return downloadsFolder +} + +/** + * Enters given email in sign-in page. + * @param {Object} t Testcafe browser + * @param {string} email Email to log in + */ +async function enterEmail(t, email) { + await t + .typeText(signInPage.emailInput, email, { paste: true }) + .click(signInPage.getStartedBtn) +} + +/** + * Returns whether a particular bonus feature is enabled + * @param {string} feature + * @returns boolean + */ +async function getFeatureState(feature) { + const featureStates = await axios.get(`${appUrl}/api/v3/client/features`) + return featureStates.data[feature] +} + +/** + * Retrieves an email sent by FormSG. + * @param {string} formName Title of form + * @returns Object containing subject, sender, recipient and html + * content of email + */ +async function getSubmission(formName) { + let submission + let lastEmail + let subject = `formsg-auto: ${formName}` + try { + let inbox = await mailClient.getAll() + let emails = getEmailsWithSubject(inbox, subject) + lastEmail = emails.pop() + submission = { + html: lastEmail.html, + subject: lastEmail.subject, + to: lastEmail.headers.to, + from: lastEmail.headers.from, + attachments: await getSubmissionAttachments(lastEmail), + } + } catch (e) { + throw Error('Failed to get submission email') + } finally { + if (lastEmail) { + await mailClient.deleteById(lastEmail.id) + } + } + return submission +} + +// Fetches the file contents of each attachment and adds it to email.attachments +async function getSubmissionAttachments(email) { + if (!email.attachments) { + return [] + } + for (let att of email.attachments) { + const response = await mailClient.getAttachment(email.id, att.fileName) + att.content = response.data + } + return email.attachments +} + +function getEmailsTo(inbox, toEmail) { + return inbox + .filter((e) => _.get(e, 'to[0].address') === toEmail) + .sort((a, b) => a.time > b.time) +} + +function getEmailsWithSubject(inbox, subject) { + return inbox + .filter((e) => _.get(e, 'subject').indexOf(subject) !== -1) + .sort((a, b) => a.time > b.time) +} + +/** + * Retrieves an OTP from an email's inbox. + * @param {string} email + */ +async function extractOTP(email) { + let otp + let lastEmail + try { + let inbox = await mailClient.getAll() + let emails = getEmailsTo(inbox, email) + lastEmail = emails.pop() + otp = lastEmail.html.match(/\d{6}/)[0] + } catch (e) { + throw Error('otp was not found in email') + } finally { + if (lastEmail) { + await mailClient.deleteById(lastEmail.id) + } + } + return otp +} + +/** + * Tests for either valid or invalid OTP to log in to FormSG. + * @param {Object} t Testcafe browser + * @param {string} otp + * @param {boolean} isValid Whether OTP is expected to be valid + * @param {string} email Email address of user + */ +async function enterOTPAndExpect({ t, otp, isValid, email }) { + let userName = email + ? email + .charAt(0) + .toUpperCase() + .concat(email.toLowerCase().substring(1, email.indexOf('@'))) + : '' + await t + .typeText(signInPage.otpInput, otp, { paste: true }) + .click(signInPage.signInBtn) + if (isValid) { + await t + .expect(getPageUrl()) + .eql(appUrl + '/#!/forms') + .expect(formList.welcomeMessage.textContent) + .contains('Welcome ' + userName + '!') + } else { + await t + .expect(signInPage.otpMsg.textContent) + .contains('OTP is invalid. Please try again.') + } +} + +/** + * Tests that login OTP was sent. + * @param {Object} t Testcafe browser + * @param {string} email User's email + */ +async function expectOtpSent(t, email) { + await t.expect(signInPage.otpMsg.textContent).contains('OTP sent to ' + email) +} + +/** + * Tests that verification OTP was sent. + * @param {Object} t Testcafe browser + * @param {string} fieldId ID of verifiable field + */ +async function expectVfnOtpSent(t, fieldId) { + await t.expect(formPage.getFieldElement(fieldId, '.vfn-section').visible).ok() +} + +// Grab page url +const getPageUrl = ClientFunction(() => window.location.href) + +// Get absolute path of file +function spec(path) { + let fullPath = `${process.env.PWD}/${path}` + return require(fullPath) +} + +/** + * Connects to mongo-memory-server instance. + */ +async function makeMongooseFixtures() { + const connection = await mongoose.createConnection(dbUri, { + reconnectTries: 5, + useNewUrlParser: true, + }) + return connection +} + +/** + * Creates Mongoose model. + * @param {Object} db Return value of makeMongooseFixtures + * @param {string} modelFilename Name of file which exports model in app/models + * @param {string} modelName Name of exported model + */ +function makeModel(db, modelFilename, modelName) { + if (modelName !== undefined && modelName !== null) { + // check if model has already been compiled + try { + return db.model(modelName) + } catch (error) { + if (error.name !== 'MissingSchemaError') { + console.error(error) + } + // else fail silently as we will create the model + } + } + + // Need this try catch block as some schemas may have been converted to + // TypeScript and use default exports instead. + try { + return spec(`dist/backend/src/app/models/${modelFilename}`)(db) + } catch (e) { + return spec(`dist/backend/src/app/models/${modelFilename}`).default(db) + } +} + +/** + * Create login credentials of user with specified email. + * @param {string} email + */ +const logInWithEmail = async (t, email) => { + await t.navigateTo(`${appUrl}/#!/signin`) + await enterEmail(t, email) + await expectOtpSent(t, email) + const otp = await extractOTP(email) + await enterOTP(t, otp) +} + +/** + * Clears a collection of a document with the given id. + * Usually used in 'after' hooks in tests. + * @param {Object} collection MongoDB collection + * @param {Object} id Document's ID as given by its _id field. + */ +function deleteDocById(collection, id) { + return new Promise((resolve, reject) => { + collection.deleteOne({ _id: id }, (err, _result) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) +} + +/** + * Creates a form by selecting a template + * @param {Object} user User object from DB + * @param {Object} formOptions Example as follows: + * basicFormData.formOptions = { + responseMode: 'encrypt', + status: 'PUBLIC', + authType: 'NIL', + hasCaptcha: false, + title: 'Basic Form', + publicKey: publicKey, + * } + * @param {Object} Form Mongoose model of Form + */ +async function createFormFromTemplate( + t, + { user, formOptions }, + Form, + captchaEnabled, +) { + const { email } = user + await logInWithEmail(t, email) + await addNewFormBySelectTemplate(t, formOptions) + await addSettings(t, formOptions, captchaEnabled) + const formId = getFormIdFromUrl(await getPageUrl()) + return Form.findOne({ _id: formId }) +} + +/** + * Creates a form with the given specifications. + * @param {Object} user User object from DB + * @param {Object} formOptions Example as follows: + * basicFormData.formOptions = { + responseMode: 'encrypt', + status: 'PUBLIC', + authType: 'NIL', + hasCaptcha: false, + title: 'Basic Form', + publicKey: publicKey, + * } + * @param {Array} formFields List of fields to be created + * @param {Object} Form Mongoose model of Form + */ +async function createForm( + t, + { user, formOptions, formFields, logicData }, + Form, + captchaEnabled, + webhookUrl, +) { + const { email } = user + await logInWithEmail(t, email) + await addNewForm(t, formOptions) + // Need to add settings first so MyInfo fields can be added + await addSettings(t, formOptions, captchaEnabled, webhookUrl) + await addFields(t, formFields) + if (logicData) { + await addLogic(t, logicData, formFields) + } + const formId = getFormIdFromUrl(await getPageUrl()) + return Form.findOne({ _id: formId }) +} + +async function addFields(t, formFields) { + await t.click(adminTabs.build) + for (let field of formFields) { + await createField(t, field) + } +} + +async function addLogic(t, logicData, formFields) { + await t.click(adminTabs.logic) + for (let logicUnit of logicData) { + await t.click(logicTab.addLogicBtn) + await addLogicUnit(t, logicUnit, formFields) + } +} + +async function addLogicUnit(t, logicUnit, formFields) { + await addLogicCondition(t, 0, logicUnit.conditions[0], formFields) + for (let i = 1; i < logicUnit.conditions.length; i++) { + await t.click(editLogicModal.addConditionBtn) + await addLogicCondition(t, i, logicUnit.conditions[i], formFields) + } + if (logicUnit.logicType === 'showFields') { + await t.click(editLogicModal.showFieldsBtn) + for (let i = 0; i < logicUnit.showFieldIndices.length; i++) { + await addLogicShowField(t, i, logicUnit.showFieldIndices[i], formFields) + } + } else { + await t.click(editLogicModal.preventSubmitBtn) + await t.typeText( + editLogicModal.preventSubmitTextArea, + logicUnit.preventSubmitMessage, + ) + } + await t.click(editLogicModal.saveBtn) +} + +async function addLogicShowField( + t, + indexInShowFields, + indexInForm, + formFields, +) { + if (indexInShowFields === 0) { + await selectDropdownOption( + t, + editLogicModal.showFieldsDropdown, + formFields[indexInForm].title, + ) + } else { + await selectDropdownOptionSeparate( + t, + editLogicModal.showFieldsSearch, + editLogicModal.showFieldsDropdown, + formFields[indexInForm].title, + ) + } +} + +// n is the index of the condition in the conditions array +async function addLogicCondition(t, n, condition, formFields) { + const fieldTitle = formFields[condition.fieldIndex].title + await selectDropdownOption( + t, + editLogicModal.getNthConditionField(n), + fieldTitle, + ) + await selectDropdownOption( + t, + editLogicModal.getNthConditionState(n), + condition.state, + ) + if (condition.ifValueType === 'number') { + await t.typeText(editLogicModal.getNthConditionValue(n), condition.value) + } else if (condition.ifValueType === 'single-select') { + await selectDropdownOption( + t, + editLogicModal.getNthConditionValue(n), + condition.value, + ) + } else { + await selectDropdownOption( + t, + editLogicModal.getNthConditionValue(n), + condition.value[0], + ) + for (let i = 1; i < condition.value.length; i++) { + // These gymnastics are necessary because if there is already one field + // selected, the dropdown doesn't show if you click exactly in the middle + // of the div, as is TestCafe's default. Hence we click one div to open the + // dropdown, and get the option to click from another div. + await selectDropdownOptionSeparate( + t, + editLogicModal.getNthConditionValueMulti(n), + editLogicModal.getNthConditionValue(n), + condition.value[i], + ) + } + } +} + +// Note that this assumes that you don't switch between MyInfo and Basic fields. +async function createField(t, field) { + if (_.get(field, 'myInfo.attr')) { + await t + .click(buildTab.myInfoTab) + .click(buildTab.getMyInfoPanel(field.myInfo.attr)) + .click(editFieldModal.saveBtn) + } else { + await createBasicField(t, field) + } +} + +async function createBasicField(t, field) { + await t.click(buildTab.getFieldPanel(field.fieldType)) + // Enter field title + if (!NON_SUBMITTED_FIELDS.includes(field.fieldType)) { + await t.selectText(editFieldModal.title).pressKey('delete') + await t.typeText(editFieldModal.title, field.title) + } + switch (field.fieldType) { + case 'email': + if (field.isVerifiable) { + await t.click(editFieldModal.getToggle('OTP verification')) + } + break + case 'radiobutton': + await setRatingCheckboxOptions(t, 0, field.fieldOptions[0]) + for (let i = 1; i < field.fieldOptions.length; i++) { + await t.click(editFieldModal.addOption) + await setRatingCheckboxOptions(t, i, field.fieldOptions[i]) + } + if (field.othersRadioButton) { + await t.click(editFieldModal.getToggle('Others option')) + } + break + case 'checkbox': + await t.selectText(editFieldModal.optionTextArea).pressKey('delete') + // The wait(500) is necessary because of the debounce time in reloadDropdownField + await t + .typeText(editFieldModal.optionTextArea, field.fieldOptions.join('\n')) + .wait(500) + if (field.othersRadioButton) { + await t.click(editFieldModal.getToggle('Others option')) + } + break + case 'dropdown': + await t.selectText(editFieldModal.optionTextArea).pressKey('delete') + // The wait(500) is necessary because of the debounce time in reloadDropdownField + await t + .typeText(editFieldModal.optionTextArea, field.fieldOptions.join('\n')) + .wait(500) + break + case 'rating': + await selectDropdownOption( + t, + editFieldModal.rating.ratingStepsDropdown, + field.ratingOptions.steps, + ) + await selectDropdownOption( + t, + editFieldModal.rating.ratingShapeDropdown, + field.ratingOptions.shape, + ) + break + case 'table': + await setTableColOptions(t, 0, field.columns[0]) + for (let i = 1; i < field.columns.length; i++) { + await t.click(editFieldModal.table.addColumn) + await setTableColOptions(t, i, field.columns[i]) + } + break + case 'decimal': + if (field.validateByValue) { + await t.click(editFieldModal.getToggle('Range Validation')) + if (field.ValidationOptions.customMin) { + await t.typeText( + editFieldModal.minValInput, + String(field.ValidationOptions.customMin), + ) + } + if (field.ValidationOptions.customMax) { + await t.typeText( + editFieldModal.maxValInput, + String(field.ValidationOptions.customMax), + ) + } + } + break + case 'attachment': + await selectDropdownOption( + t, + editFieldModal.attachmentSizeDropdown, + field.attachmentSize, + ) + break + default: + break + } + // Set field to optional. Ignore tables as the required option is + // set for individual columns. + if (!field.required && field.fieldType !== 'table') { + await t.click(editFieldModal.getToggle('Required')) + } + await t.click(editFieldModal.saveBtn) +} + +async function setRatingCheckboxOptions(t, n, option) { + const input = editFieldModal.getOptionInput(n) + await t.selectText(input).pressKey('delete') + await t.typeText(input, option) +} + +async function setTableColOptions(t, n, colOptions) { + await t.selectText(editFieldModal.table.getNthColTitle(n)).pressKey('delete') + await t.typeText(editFieldModal.table.getNthColTitle(n), colOptions.title) + if (colOptions.columnType === 'textfield') { + await selectDropdownOption( + t, + editFieldModal.table.getNthColFieldType(n), + 'Text Field', + ) + } else { + await selectDropdownOption( + t, + editFieldModal.table.getNthColFieldType(n), + 'Dropdown', + ) + await t + .typeText( + editFieldModal.table.getNthColTextArea(n), + colOptions.fieldOptions.join('\n'), + ) + .wait(400) // necessary due to debounce time in textarea + } + if (!colOptions.required) { + await t.click(editFieldModal.table.getNthColRequiredToggle(n)) + } +} + +async function selectDropdownOption(t, selector, text) { + await t.click(selector).click(getDropdownOption(selector, text)) +} + +// This is for the case where a separate element has to be clicked to +// open the dropdown vs select the option, e.g. in the logic modal +async function selectDropdownOptionSeparate( + t, + dropdownSelector, + optionSelector, + text, +) { + await t.click(dropdownSelector).click(getDropdownOption(optionSelector, text)) +} + +function getDropdownOption(selector, text) { + return selector.find('.ui-select-choices-row').withText(String(text)) +} + +function getFormIdFromUrl(url) { + return getSubstringBetween(url, '#!/', '/admin') +} + +async function addSettings(t, formOptions, captchaEnabled, webhookUrl) { + await t.click(adminTabs.settings) + + // Expect auth type to be none by default, then change it + await t.expect(settingsTab.getAuthRadioInput('NIL').checked).ok() + if (formOptions.authType !== 'NIL') { + await t.click(settingsTab.getAuthRadioLabel(formOptions.authType)) + await t.typeText(settingsTab.esrvcIdInput, formOptions.esrvcId) + } + + // Expect form title to be correct + await t.expect(settingsTab.formTitleInput.value).eql(formOptions.title) + + if (captchaEnabled) { + // Expect captcha to be active, then set it + await t.expect(settingsTab.captchaToggleInput.checked).ok() + if (!formOptions.hasCaptcha) { + await t.click(settingsTab.captchaToggleLabel) + } + } + + if (webhookUrl) { + await t.typeText(settingsTab.webhookUrlInput, webhookUrl) + } + + // Expect form to be inactive, then activate it + await t + .expect(settingsTab.formStatus.textContent) + .contains('Your form is inactive') + await t.click(settingsTab.activateBtn) + if (formOptions.responseMode === 'encrypt') { + await t + .typeText(activateFormModal.secretKeyInput, formOptions.secretKey) + .typeText( + activateFormModal.acknowledgementInput, + 'I have shared my secret key with a colleague', + ) + .click(activateFormModal.activateFormBtn) + } else if (formOptions.authType !== 'NIL') { + await t.click(activateFormModal.closeModalBtn) + } + await t + .expect(settingsTab.formStatus.textContent) + .contains('Your form is active') +} + +async function addNewFormBySelectTemplate(t, formOptions) { + await t + .click(formList.createFormBtn) + .click(createFormModal.templateCard) // temperature taking template + .click(createFormTemplateModal.useTemplateBtn) + if (formOptions.responseMode === 'email') { + await addNewEmailForm(t, formOptions) + } else { + await addNewEncryptForm(t, formOptions) + } +} + +async function addNewForm(t, formOptions) { + await t + .click(formList.createFormBtn) + .click(createFormModal.startFromScratchBtn) + if (formOptions.responseMode === 'email') { + await addNewEmailForm(t, formOptions) + } else { + await addNewEncryptForm(t, formOptions) + } +} + +async function addNewEmailForm(t, formOptions) { + await t + .typeText(createFormModal.formTitleInput, formOptions.title, { + paste: true, + }) + .click(createFormModal.emailModeRadio) + .click(createFormModal.startBtn) +} + +async function addNewEncryptForm(t, formOptions) { + await t + .typeText(createFormModal.formTitleInput, formOptions.title, { + paste: true, + }) + .click(createFormModal.encryptModeRadio) + .click(createFormModal.startBtn) + formOptions.secretKey = await createFormModal.secretKeyDiv.innerText + await t + .click(createFormModal.downloadKeyBtn) + .click(createFormModal.encryptModeContinueBtn) +} + +/** + * Expect start page to be shown with form title. + * @param {Object} t Testcafe browser + * @param {Object} testForm Form object from DB + * @param {Object} testFormData.formOptions Object specifying form options as per createForm + * @param {string} appUrl URL of app + */ +async function expectStartPage(t, testForm, testFormData, appUrl) { + let { _id } = testForm + let { formOptions } = testFormData + // Client side redirect from /123 to /#!/123 should work + await t + .navigateTo(`${appUrl}/${_id}`) + .expect(getPageUrl()) + .contains(_id) + .expect(formPage.startTitle.textContent) + .contains(formOptions.title) +} + +/** + * Fill in form based in field type. + * @param {Object} t Testcafe browser + * @param {Object} testForm Form object from DB + * @param {Object} testFormData.formFields Object specifying responses to each field + * @param {string} appUrl URL of app + */ +async function fillInForm(t, testForm, testFormData) { + let { formFields } = testFormData + for (let i in formFields) { + const field = formFields[i] + const fieldId = testForm.form_fields[i]._id + if (field.isLeftBlank || !field.isVisible || field.disabled) { + continue + } + switch (field.fieldType) { + case 'textarea': + await t.typeText( + formPage.getFieldElement(fieldId, 'textarea'), + field.val, + { + paste: true, + }, + ) + break + case 'nric': + case 'number': + case 'email': + case 'textfield': + case 'decimal': + case 'mobile': + await t.typeText( + formPage.getFieldElement(fieldId, 'input'), + field.val, + { + paste: true, + }, + ) + if (field.isVerifiable) { + await verifyField(t, fieldId, field.val) + } + break + case 'radiobutton': { + let radioLabels = formPage.getFieldElement(fieldId, 'label') + let radio = radioLabels.withText(field.val) + let radioExists = await radio.exists + if (radioExists) { + await t.click(radio.child('.radiomark')) + } else { + radio = radioLabels.withText('Others') + await t + .click(radio) + .typeText( + formPage.getFieldElement(fieldId, 'input[type="text"]'), + field.val, + { paste: true }, + ) + } + break + } + case 'yes_no': { + let yesNo = formPage.getFieldElement(fieldId) + let toggle = yesNo.find('label').withText(field.val.toUpperCase()) + await t.click(toggle) + break + } + case 'checkbox': { + let checkboxLabels = formPage.getFieldElement(fieldId, 'label') + for (let option of field.val) { + let checkbox = checkboxLabels.withText(option) + let checkboxExists = await checkbox.exists + if (checkboxExists) { + await t.click(checkbox) + } else { + checkbox = checkboxLabels.withText('Others') + await t + .click(checkbox) + .typeText( + formPage.getFieldElement(fieldId, 'input[type="text"]'), + option, + { + paste: true, + }, + ) + } + } + break + } + case 'dropdown': { + await selectDropdownOption( + t, + formPage.getFieldElement(fieldId), + field.val, + ) + break + } + case 'date': { + let datePicker = formPage.getFieldElement(fieldId, 'input') + let today = datePicker + .nextSibling(0) + .find('button') + .withText(new RegExp(`^(${DATE})$`)) + await t.click(datePicker).click(today) + break + } + case 'rating': { + let rating = formPage.getFieldElement(fieldId, 'ul') + let star = rating.child(parseInt(field.val) - 1) + await t.click(star) + break + } + case 'table': { + const tableData = field + const table = testForm.form_fields[i] + + for (let col = 0; col < table.columns.length; col++) { + for (let row = 0; row < table.minimumRows; row++) { + let valueToFillIn = tableData.val[row][col] + switch (table.columns[col].columnType) { + case 'textfield': + await t.typeText( + formPage.getTableCell(fieldId, row, col), + valueToFillIn, + { + paste: true, + }, + ) + break + case 'dropdown': { + await selectDropdownOption( + t, + formPage.getTableCell(fieldId, row, col), + valueToFillIn, + ) + } + } + } + } + break + } + case 'attachment': + // Note that the given path must be relative to the file containing the test + await t.setFilesToUpload( + formPage.getFieldElement(fieldId, 'input'), + field.path, + ) + break + default: + continue + } + } +} + +/** + * Verifies a verifiable field. + * @param {Object} t Testcafe browser + * @param {string} fieldId ID of field to verify + * @param {string} email Email used in field response + */ +async function verifyField(t, fieldId, email) { + await t.click(formPage.getFieldElement(fieldId, '.vfn-btn')) + await expectVfnOtpSent(t, fieldId) + const otp = await extractOTP(email) + await t.typeText(formPage.getFieldElement(fieldId, '.vfn-section input'), otp) + await t.click(formPage.getFieldElement(fieldId, '.vfn-section button')) +} + +/** + * Expect end page to be shown with thank you message. + * @param {Object} t Testcafe browser + */ +async function expectEndPage(t) { + let endPageTitle + if (_.isUndefined(t.ctx.endPageTitle)) { + endPageTitle = 'Thank you for filling out the form.' + } else { + endPageTitle = t.ctx.endPageTitle + } + + await t.expect(formPage.endTitle.textContent).contains(endPageTitle) +} + +/** + * Clicks the submit button. + * @param {Object} t Testcafe browser + */ +async function submitForm(t) { + await t.click(formPage.submitBtn) +} + +/** + * Types an OTP into the sign-in text box. + * @param {Object} t Testcafe browser + * @param {string} otp + */ +async function enterOTP(t, otp) { + await t + .typeText(signInPage.otpInput, otp, { paste: true }) + .click(signInPage.signInBtn) +} + +// Fills nested arrays with given value. +function fillRecursive(obj, value) { + if (Array.isArray(obj)) { + return obj.map((elt) => fillRecursive(elt, value)) + } + return value +} + +/** + * Returns an optional version of a field. + * @param {Object} field + */ +function getOptionalVersion(field) { + const optionalField = _.cloneDeep(field) + optionalField.required = false + if (optionalField.fieldType === 'table') { + optionalField.columns.forEach((column) => { + column.required = false + }) + } + return optionalField +} + +/** + * Returns a field with a blank response. + * @param {Object} field + */ +function getBlankVersion(field) { + const blankField = _.cloneDeep(field) + blankField.isLeftBlank = true + blankField.val = + blankField.fieldType === 'table' ? fillRecursive(blankField.val, '') : '' + return blankField +} + +/** + * Returns a hidden version of a field. + * @param {Object} field + */ +function getHiddenVersion(field) { + const hiddenField = _.cloneDeep(field) + hiddenField.isVisible = false + return hiddenField +} + +/** + * Creates a field for e2e tests with default options (visible, required, not blank). + * @param {Object} fieldObj Custom options of the field. + * @param {string} fieldObj.title Mandatory title of field + * @param {string} fieldObj.fieldType Type of field. Mandatory unless makeField is + * being called to create an artificial field for verified SingPass/CorpPass data. + * @param {string} fieldObj.val Mandatory answer to field + */ +function makeField(fieldObj) { + return Object.assign( + { + required: true, + isVisible: true, + isLeftBlank: false, + }, + fieldObj, + ) +} + +/** + * Fetches a substring of text between two string markers. + * @param {string} text Text to substring + * @param {string} markerStart String after which result starts + * @param {string} markerEnd String before which result ends + */ +const getSubstringBetween = (text, markerStart, markerEnd) => { + const start = text.indexOf(markerStart) + if (start === -1) { + return null + } else { + const end = text.indexOf(markerEnd, start) + return end === -1 ? null : text.substring(start + markerStart.length, end) + } +} + +/** + * Converts given string from encoded HTML to plain text. + * @param {string} html + */ +const decodeHtmlEntities = (html) => + html.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec)) + +/** + * Tests that container contains all the values in contained. + * @param {Object} t Testcafe browser + * @param {string} container String in which to search + * @param {Array[string]} containedArray Array of values to search for + */ +const expectContains = async (t, container, containedArray) => { + for (let contained of containedArray) { + await t.expect(container).contains(contained) + } +} + +/** + * Creates a list of integers, inclusive of the parameters. + * @param {number} from starting index (inclusive) + * @param {number} to ending index (inclusive) + */ +const listIntsInclusive = (from, to) => { + const result = [] + for (let i = from; i <= to; i++) { + result.push(i) + } + return result +} + +// Utility for getting responses for table +const tableHandler = { + getName: (tableField, formMode) => { + const { title, columns } = tableField + let tableTitle = `${title} (${columns.map((x) => x.title).join(', ')})` + if (formMode === 'email') { + tableTitle = '[table] ' + tableTitle + } + return tableTitle + }, + getValues: (tableField, formMode) => { + if (formMode === 'email') { + return tableField.val.map((row) => row.join(',')) + } else { + // storage mode has a space + return tableField.val.map((row) => row.join(', ')) + } + }, +} + +/** + * Gets answers for a field as displayed in response email or decrypted submission. + * Always returns an array, so caller must always loop through result. + * @param {Object} field Field as created by makeField + * @param {string} formMode 'email' or 'encrypt' + */ +const getResponseArray = (field, formMode) => { + // Deal with table first to avoid special cases later + if (field.fieldType === 'table') { + return tableHandler.getValues(field, formMode) + } + // Only allow blanks if field is not visible or optional + if (!field.isVisible || (!field.required && field.isLeftBlank)) { + return [''] + } + switch (field.fieldType) { + case 'checkbox': + return [ + field.val + .map((selected) => { + return field.fieldOptions.includes(selected) + ? selected + : `Others: ${selected}` + }) + .join(', '), + ] + case 'radiobutton': + return [ + field.fieldOptions.includes(field.val) + ? field.val + : `Others: ${field.val}`, + ] + default: + return [ + field.val instanceof Array ? field.val.join(', ') : String(field.val), + ] + } +} + +/** + * Gets the title of a field as it is displayed in a response. + * @param {Object} field Field as created by makeField + * @param {boolean} isInJson Whether the title is within the + * JSON data for email submissions + * @param {string} formMode 'email' or 'encrypt' + */ +const getResponseTitle = (field, isInJson, formMode) => { + if (field.fieldType === 'table') { + return tableHandler.getName(field, formMode) + } else if (field.fieldType === 'attachment') { + let title = field.title + if (formMode === 'email') { + title = `[attachment] ${title}` + } + return title + } else { + if (isInJson) { + if (field.title.startsWith('[verified] ')) { + return field.title.substr(11) + } else if (field.title.startsWith('[MyInfo] ')) { + return field.title.substr(9) + } + } + return field.title + } +} + +/** + * Tests for the SP/CP login page and logs in. + * @param {Object} t Testcafe browser + * @param {string} authType SP, CP or NIL + * @param {Object} authData Must contain a testSpNric or (testCpNric and testCpUen) + */ +const expectSpcpLogin = async (t, authType, authData) => { + const { testSpNric, testCpNric, testCpUen } = authData + switch (authType) { + case 'MyInfo': + await t + .expect(formPage.spcpLoginBtn.textContent) + .contains(`Login with Singpass`) + .click(formPage.spcpLoginBtn) + .click(mockpass.loginBtn) + .click(mockpass.nricDropdownBtn) + .click(mockpass.getNricOption(testSpNric)) + .click(mockpass.consentBtn) + .expect(formPage.spcpLogoutBtn.textContent) + .contains(`${testSpNric} - Log out`) + break + case 'SP': + await t + .expect(formPage.spcpLoginBtn.textContent) + .contains(`Login with Singpass`) + .click(formPage.spcpLoginBtn) + .click(mockpass.loginBtn) + .click(mockpass.nricDropdownBtn) + .click(mockpass.getNricOption(testSpNric)) + .expect(formPage.spcpLogoutBtn.textContent) + .contains(`${testSpNric} - Log out`) + break + case 'CP': + await t + .expect(formPage.spcpLoginBtn.textContent) + .contains(`Login with Singpass (Corporate)`) + .click(formPage.spcpLoginBtn) + .click(mockpass.loginBtn) + .click(mockpass.nricDropdownBtn) + .click(mockpass.getNricUenOption(testCpNric, testCpUen)) + .expect(formPage.spcpLogoutBtn.textContent) + .contains(`${testCpUen} - Log out`) + break + default: + throw new Error('Invalid authentication type!') + } +} + +/** + * Creates a field to imitate the verified NRIC for SingPass or verified + * UEN or UID for CorpPass. Used to validate submissions for SP/CP + * authenticated forms. + * @param {string} authType one of 'NIL', 'SP' or 'CP' + * @param {Object} authData Contains testSpNric for authType === 'SP' and + * both testCpNric and testCpUen for authType === 'CP' + * @returns {Array} Array of new artificial authenticated fields + */ +const getAuthFields = (authType, authData) => { + switch (authType) { + case 'NIL': + return [] + case 'MyInfo': + case 'SP': + return [ + makeField({ + title: SPCPFieldTitle.SpNric, + val: authData.testSpNric, + }), + ] + case 'CP': + return [ + { title: SPCPFieldTitle.CpUen, val: authData.testCpUen }, + { + title: SPCPFieldTitle.CpUid, + val: authData.testCpNric, + }, + ].map(makeField) + default: + throw new Error('Invalid authentication type!') + } +} + +/** + * Expects form submisision to be disabled and toast message to be shown. + * @param {Object} browser Testcafe browser + * @param {string} disabledMessage message to be shown underneath the submit button when form is disabled + */ +async function expectFormDisabled(browser, disabledMessage) { + await browser.expect(formPage.submitBtn.getAttribute('disabled')).ok() + await browser + .expect(formPage.submitPreventedMessage.textContent) + .eql(disabledMessage) +} + +/** + * Opens a form and verifies that submission is disabled when the given answers are filled. + * @param {Object} browser Testcafe browser + * @param {Object} form Form object from DB + * @param {Object} formData Data based on which to fill in form + * @param {Object} formData.formOptions Object specifying form options as per createForm + * @param {string} disabledMessage Message to show in toaster when form is disabled + * @param {Object} [authData] Authentication data for SingPass/CorpPass if form is authenticated + * @param {string} [authData.testSpNric] NRIC for SingPass login + * @param {string} [authData.testCpNric] NRIC for CorpPass login + * @param {string} [authData.testCpUen] UEN for CorpPass login + */ +const verifySubmissionDisabled = async ( + browser, + form, + formData, + disabledMessage, + authData, +) => { + // Verify that form can be accessed + await expectStartPage(browser, form, formData, appUrl) + if (authData) { + await expectSpcpLogin(browser, formData.formOptions.authType, authData) + } + // Fill in form + await fillInForm(browser, form, formData) + // Expect form to be disabled with message + await expectFormDisabled(browser, disabledMessage) +} + +module.exports = { + mailClient, + enterEmail, + extractOTP, + enterOTPAndExpect, + expectOtpSent, + getPageUrl, + makeMongooseFixtures, + makeModel, + logInWithEmail, + appUrl, + deleteDocById, + getSubmission, + emptyMailServer: mailClient.deleteAll, + createFormFromTemplate, + createForm, + expectStartPage, + fillInForm, + submitForm, + expectEndPage, + enterOTP, + addLogic, + getOptionalVersion, + getBlankVersion, + getHiddenVersion, + makeField, + getSubstringBetween, + decodeHtmlEntities, + expectContains, + listIntsInclusive, + getResponseArray, + getResponseTitle, + expectSpcpLogin, + getAuthFields, + verifySubmissionDisabled, + getDownloadsFolder, + getFeatureState, +} diff --git a/tests/end-to-end/helpers/verifiable-email-field.js b/tests/end-to-end/helpers/verifiable-email-field.js new file mode 100644 index 0000000000..ef0f0eab76 --- /dev/null +++ b/tests/end-to-end/helpers/verifiable-email-field.js @@ -0,0 +1,12 @@ +// Exports data for just verifiable email field +const { makeField } = require('./util') +const verifiableEmailFieldInfo = [ + { + title: 'Personal Email', + fieldType: 'email', + isVerifiable: true, + val: 'test@test.gov.sg', + }, +] +const verifiableEmailField = verifiableEmailFieldInfo.map(makeField) +module.exports = { verifiableEmailField } diff --git a/tests/end-to-end/login.e2e.js b/tests/end-to-end/login.e2e.js new file mode 100644 index 0000000000..e0345b296d --- /dev/null +++ b/tests/end-to-end/login.e2e.js @@ -0,0 +1,211 @@ +const { signInPage, formList, landingPage } = require('./helpers/selectors') +const { + getPageUrl, + enterEmail, + extractOTP, + enterOTPAndExpect, + expectOtpSent, + makeMongooseFixtures, + makeModel, + appUrl, + deleteDocById, +} = require('./helpers/util') + +let db +let User +let Agency +let Token +let govTech +let createUser +let deleteToken +fixture('login') + .page(appUrl + '/#!/signin') + .before(async () => { + db = await makeMongooseFixtures() + Agency = makeModel(db, 'agency.server.model', 'Agency') + User = makeModel(db, 'user.server.model', 'User') + Token = makeModel(db, 'token.server.model', 'Token') + govTech = await Agency.findOne({ shortName: 'govtech' }).exec() + createUser = async (email) => { + return new User({ + email, + agency: govTech._id, + contact: '+6587654321', + }) + .save() + .catch((error) => console.error(error)) + } + deleteToken = async (email) => { + return Token.deleteOne({ email }).catch((error) => console.error(error)) + } + }) + .after(async () => { + // Delete models defined by mongoose and close connection + db.models = {} + await db.close() + }) + +test('Reject emails that do not have white-listed domains', async (t) => { + // Enter email + await enterEmail(t, 'user@non-white-listed-agency.com') + + // Ensure error message is seen + await t + .expect(signInPage.emailErrorMsg.textContent) + .contains( + 'Please log in with your official government or government-linked email address.', + ) +}) + +test + + .before(async (t) => { + t.ctx.user = await createUser('existinguser@data.gov.sg') + }) + .after(async (t) => { + await deleteToken(t.ctx.user.email) + await deleteDocById(User, t.ctx.user._id) + })( + 'Send otp to emails that have white-listed domains (Current User flow)', + async (t) => { + let email = t.ctx.user.email + // Enter email + await enterEmail(t, email) + + // Ensure that 'OTP sent' success message is shown + await expectOtpSent(t, email) + + // Enter OTP + let otp = await extractOTP(email) + await enterOTPAndExpect({ t, otp, isValid: true, email }) + }, +) + +test + + .before((t) => { + t.ctx.email = 'newuser@data.gov.sg' + }) + .after(async (t) => { + await deleteToken(t.ctx.email) + })( + 'Send otp to emails that have white-listed domains (New User flow)', + async (t) => { + let email = t.ctx.email + // Enter email + await enterEmail(t, email) + + // Ensure that 'OTP sent' success message is shown + await expectOtpSent(t, email) + + // Enter OTP + let otp = await extractOTP(email) + await enterOTPAndExpect({ t, otp, isValid: true, email }) + }, +) + +test + + .before(async (t) => { + t.ctx.user = await createUser('preventuseremail@data.gov.sg') + }) + .after(async (t) => { + await deleteToken(t.ctx.user.email) + await deleteDocById(User, t.ctx.user._id) + })('Prevent sign-in if OTP is incorrect', async (t) => { + let email = t.ctx.user.email + // Enter email + await enterEmail(t, email) + + // Ensure that 'OTP sent' success message is shown + await expectOtpSent(t, email) + + // Get correct otp + let correctOtp = await extractOTP(email) + + // Generate incorrect otp by replacing digit in first index with its complement + // i.e. 123456 becomes 923456 + let incorrectOtp = + 9 - parseInt(correctOtp.substring(0, 1)) + correctOtp.substring(1) + + // Ensure that invalid otp is not accepted + await enterOTPAndExpect({ t, otp: incorrectOtp, isValid: false, email }) + + // Remove current OTP and enter correct OTP + await t.selectText(signInPage.otpInput).pressKey('delete') + await enterOTPAndExpect({ t, otp: correctOtp, isValid: true, email }) +}) + +test + + .before(async (t) => { + t.ctx.user = await createUser('resenduseremail@data.gov.sg') + }) + .after(async (t) => { + await deleteToken(t.ctx.user.email) + await deleteDocById(User, t.ctx.user._id) + })('Resend OTP on request and invalidate old OTP', async (t) => { + let email = t.ctx.user.email + // Enter email + await enterEmail(t, email) + + // Ensure that 'OTP sent' success message is shown + await expectOtpSent(t, email) + + // Extract first OTP + let firstOtp = await extractOTP(email) + + // Click resend Otp + await t.click(signInPage.resendOtpLink) + await expectOtpSent(t, email) + + // Extract second OTP + let secondOtp = await extractOTP(email) + + // Reject invalidated OTP + await enterOTPAndExpect({ t, otp: firstOtp, isValid: false, email }) + + // Remove current OTP and enter correct OTP + await t.selectText(signInPage.otpInput).pressKey('delete') + await enterOTPAndExpect({ t, otp: secondOtp, isValid: true, email }) +}) + +test + + .before(async (t) => { + t.ctx.user = await createUser('logoutuseremail@data.gov.sg') + }) + .after(async (t) => { + await deleteToken(t.ctx.user.email) + await deleteDocById(User, t.ctx.user) + })('Should logout to main screen', async (t) => { + let email = t.ctx.user.email + // Enter email + await enterEmail(t, email) + + // Ensure that 'OTP sent' success message is shown + await expectOtpSent(t, email) + + // Extract OTP and log in + let otp = await extractOTP(email) + await enterOTPAndExpect({ t, otp, isValid: true, email }) + + await t.click(formList.avatarDropdown) + await t.click(formList.logOutBtn) + await t + .expect(getPageUrl()) + .eql(appUrl + '/#!/') + // Due to text spanning multiple spans, remove all whitespace + .expect((await landingPage.tagline.textContent).replace(/ +/g, ' ')) + .contains('Build government forms in minutes') +}) + +test('Prevent sign-in if email is invalid', async (t) => { + let email = 'ani@open.gov.rg' + // Enter email + await enterEmail(t, email) + + await t + .expect(signInPage.emailErrorMsg().textContent) + .contains('Please enter a valid email') +})