From 9e246485da346f0875c5056f5190e66b8e0898ad Mon Sep 17 00:00:00 2001 From: Frederik Rothenberger Date: Tue, 7 Nov 2023 15:21:17 +0100 Subject: [PATCH] Run selenium tests without docker (#1995) * Run selenium tests without docker This PR removes the docker compose based setup of running selenium test and replaces it with infrastructure based on native chromium and chromedriver. The new setup uses chromium with options to override DNS resolution so that all request are hitting the vite dev server. This dev server is reconfigured to terminate TLS and forward request to the replica (or, if hot reloading is enabled, serve assets directly). Two new config env variables are introduced for the `npm run dev` command: * TLS_DEV_SERVER: when set to 1, the dev server will handle https instead of http. * NO_HOT_RELOAD: when set to 1, all requests are forwarded to the replica. Assets are not hot reloaded. There are some changes to the test: * the copy / paste of the seed phrase has been replaced by reading the phrase from the DOM directly, because chromium now runs in headless mode (as non-headless is not supported on github action runners natively), which makes chromium behave differently with regards to the clipboard. * The check that the raw URL is inaccessible now returns a slightly different error message, so the assert on the message was changed. Still to do (in a separate PR): * Documentation * Disable /dev/shm usage * Fix chrome version * Fix chrome options * Apply suggestions from code review Co-authored-by: Nicolas Mattia * Add comment about host resolver rules * Simplify browser logs indexing Co-authored-by: Nicolas Mattia * Simplify getting recovery phrase from DOM * TS ignore code executed in browser * Fix formatting * Fix seed phrase simplification * Simplify replica forwarding plugin * Remove obsolete env variable * Name parameters of replica forward plugin * Remove file output for chromedriver logs * Revert migration to lockfile version v3 * Don't apply forwarding rule on nullish host * Replace @ts-ignore with cast * Deny access to all hosts that include a .raw subdomain * Remove cast * Remove outdated comment Co-authored-by: Nicolas Mattia * Run formatter * Update comments regarding accessing raw URLs --------- Co-authored-by: Nicolas Mattia --- .github/workflows/canister-tests.yml | 27 ++-- docker-test-env/README.md | 27 ---- docker-test-env/docker-compose.yml | 44 ------ docker-test-env/reverse_proxy/Dockerfile | 11 -- docker-test-env/reverse_proxy/README.md | 15 --- docker-test-env/reverse_proxy/certs/dummy.crt | 33 ----- docker-test-env/reverse_proxy/certs/dummy.key | 51 ------- docker-test-env/reverse_proxy/nginx.conf | 126 ------------------ package-lock.json | 92 ++++++++++++- package.json | 2 + scripts/start-selenium-env | 69 ---------- scripts/with-docker-env | 43 ------ .../alternativeOrigin/endpointFormat.test.ts | 8 +- src/frontend/src/test-e2e/util.ts | 12 +- src/frontend/src/test-e2e/views.ts | 46 +++---- vite.config.ts | 46 ++++++- vite.plugins.ts | 71 +++++++++- 17 files changed, 237 insertions(+), 486 deletions(-) delete mode 100644 docker-test-env/README.md delete mode 100644 docker-test-env/docker-compose.yml delete mode 100644 docker-test-env/reverse_proxy/Dockerfile delete mode 100644 docker-test-env/reverse_proxy/README.md delete mode 100644 docker-test-env/reverse_proxy/certs/dummy.crt delete mode 100644 docker-test-env/reverse_proxy/certs/dummy.key delete mode 100644 docker-test-env/reverse_proxy/nginx.conf delete mode 100755 scripts/start-selenium-env delete mode 100755 scripts/with-docker-env diff --git a/.github/workflows/canister-tests.yml b/.github/workflows/canister-tests.yml index 7ba24d54a3..8156a5c9b9 100644 --- a/.github/workflows/canister-tests.yml +++ b/.github/workflows/canister-tests.yml @@ -420,8 +420,6 @@ jobs: dfx --version echo node --version node --version - echo docker --version - docker --version - name: 'Run dfx' run: dfx start --background @@ -449,30 +447,23 @@ jobs: dfx canister create --all dfx canister install test_app --wasm test_app.wasm - - name: Start docker compose - run: scripts/start-selenium-env --no-hot-reload - - run: npm ci - run: npm test + - name: Run dev server + id: dev-server-start + run: | + TLS_DEV_SERVER=1 NO_HOT_RELOAD=1 npm run dev& + dev_server_pid=$! + echo "dev_server_pid=$dev_server_pid" >> "$GITHUB_OUTPUT" - run: "II_URL=${{ matrix.domain }} SCREEN=${{ matrix.device }} npm run test:e2e -- --shard=$(tr <<<'${{ matrix.shard }}' -s _ /)" # replace 1_N with 1/N - - name: Collect docker logs - working-directory: docker-test-env - if: ${{ always() }} - run: docker compose logs > ../docker-compose.log - name: Stop dfx + if: ${{ always() }} run: dfx stop - - name: Shut down docker services - working-directory: docker-test-env - run: docker compose down - - - name: Archive test logs + - name: Stop dev server if: ${{ always() }} - uses: actions/upload-artifact@v3 - with: - name: e2e-test-log-${{ matrix.device }}-${{ matrix.shard }} - path: ./*.log + run: kill ${{ steps.dev-server-start.outputs.dev_server_pid }} - name: Archive test failures if: ${{ always() }} diff --git a/docker-test-env/README.md b/docker-test-env/README.md deleted file mode 100644 index 98f16a92c6..0000000000 --- a/docker-test-env/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Docker Compose Test Environment - -Docker compose setup to run selenium tests with. The setup consists of the following components: -* nginx container - * Required to terminate TLS. - * Forwards requests to the test app to `dfx` running on host. - * Forwards requests to the internet identity to the dev server running on host. - * Translates domains `.ic0.app` to the corresponding `.localhost` domains. - * Translates mapped domains (i.e. `identity.internetcomputer.org`) to the corresponding `.localhost` domain. -* selenium container - * Runs chromium browser. - * Connects to nginx to access pages of the canister hosted on `dfx` or the dev server - -When selenium tests are run, the tests are executed on the host machine natively and connect to the selenium container using the webdriver interface. - -To run selenium tests, do the following: -1. Run `dfx start` from the repository root. - 1. The `--background` flag can be added to run in the background. -2. Run `II_FETCH_ROOT_KEY=1 II_DUMMY_CAPTCHA=1 dfx deploy --no-wallet`. -3. Switch to the `demos/test-app` directory and run `dfx deploy --no-wallet`. -4. Run `npm run dev` from the repository root. -5. Run `scripts/start-selenium-env` from the repository root. - 1. The docker compose setup can be shut down by running `docker compose down` in the `docker-test-env` directory. - 2. The docker compose setup has to be restarted only if the canister ids change. Additional `dfx deploy` commands or changes to the front-end of II do not require a restart of the docker compose project. -6. Run `npm run test:e2e-desktop` or `npm run test:e2e-mobile` from the repository root to run the selenium tests. - -It is possible to connect to the selenium container to watch the tests being executed by opening `http://localhost:7900/` in the browser. The password is `secret`. diff --git a/docker-test-env/docker-compose.yml b/docker-test-env/docker-compose.yml deleted file mode 100644 index 4c0a247d73..0000000000 --- a/docker-test-env/docker-compose.yml +++ /dev/null @@ -1,44 +0,0 @@ -version: "3.9" -services: - # service hosting dfx running II and the test app - # canister ids are assigned deterministically on startup, so we can hardcode them - - # Nginx proxy to terminate tls and transform domains to *.localhost - nginx: - build: reverse_proxy - ports: - - "8443:443" - extra_hosts: - - "host.docker.internal:host-gateway" - networks: - ic: - aliases: - - icp-api.io - # internet identity - - identity.internetcomputer.org - - identity.ic0.app - # test app, TEST_APP_CANISTER_ID is substituted by the start-selenium-env script - - TEST_APP_CANISTER_ID.icp0.io - # test app as above, but on legacy domain - - TEST_APP_CANISTER_ID.ic0.app - - nice-name.com - # also handle the *.raw origins, but nginx will just respond with status 400 - - TEST_APP_CANISTER_ID.raw.icp0.io - - # Selenium container with chromedriver and chrome - selenium: - # use the seleniarm image that provides multiple architecture variants including one for M1 chips - image: seleniarm/standalone-chromium:116.0 - ports: - - "4444:4444" # port for the test runner to connect to chromedriver - - "7900:7900" # port to access the page to watch what chrome is doing (http://localhost:7900, pw is secret) - shm_size: '2gb' # allow more memory for chrome to actually render the pages - environment: # default number of sessions is 1. We need more because of flows involving multiple devices (which we simulate using parallel sessions). - - SE_NODE_OVERRIDE_MAX_SESSIONS=true - - SE_NODE_MAX_SESSIONS=5 - - SCREEN_WIDTH=1920 - - SCREEN_HEIGHT=1080 - networks: - - ic -networks: - ic: diff --git a/docker-test-env/reverse_proxy/Dockerfile b/docker-test-env/reverse_proxy/Dockerfile deleted file mode 100644 index 006da9eebc..0000000000 --- a/docker-test-env/reverse_proxy/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM nginx:latest - -ARG TEST_APP_CANISTER_ID -ARG II_CANISTER_ID -ARG II_PORT -COPY ./nginx.conf /etc/nginx/nginx.conf - -RUN sed -i "s/II_CANISTER_ID/${II_CANISTER_ID}/g" /etc/nginx/nginx.conf -RUN sed -i "s/TEST_APP_CANISTER_ID/${TEST_APP_CANISTER_ID}/g" /etc/nginx/nginx.conf -RUN sed -i "s/II_PORT/${II_PORT}/g" /etc/nginx/nginx.conf -COPY ./certs /etc/nginx/certs diff --git a/docker-test-env/reverse_proxy/README.md b/docker-test-env/reverse_proxy/README.md deleted file mode 100644 index 1cd0f14a82..0000000000 --- a/docker-test-env/reverse_proxy/README.md +++ /dev/null @@ -1,15 +0,0 @@ -This is the docker setup for a nginx proxy server used for Selenium tests. - -The setups is as follows: - -`Selenium -> Browser -> Nginx Proxy -> dfx (local replica)` - -Nginx is used to terminate tls. This allows the use of the actual domain `https://identity.ic0.app` in the tests (dfx does not support `https`). Note that we cannot use `http://identity.ic0.app` because of preloaded HSTS. -We still need the `--ignore-certificate-errors` because we don't add the certificates to the browser truststore. Having this option enabled also allows us to use the same certificate for all domains, even if the CN does not match. - -The certificate is self-signed and created using the following commands: -1. `openssl genrsa -aes256 -passout pass:foo -out server.key 4096` -2. Strip away password protection: `openssl rsa -in server.key -out server.key` (using password `foo`) -3. `openssl req -new -key server.key -out server.csr` - * This will ask for information. Note that the `Common Name (CN)` must match the host name. -4. `openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt` diff --git a/docker-test-env/reverse_proxy/certs/dummy.crt b/docker-test-env/reverse_proxy/certs/dummy.crt deleted file mode 100644 index 271cafb00b..0000000000 --- a/docker-test-env/reverse_proxy/certs/dummy.crt +++ /dev/null @@ -1,33 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFqjCCA5ICCQDqc1h4tDj4+DANBgkqhkiG9w0BAQsFADCBljELMAkGA1UEBhMC -Y2gxDzANBgNVBAgMBlp1cmljaDEPMA0GA1UEBwwGWnVyaWNoMRAwDgYDVQQKDAdE -RklOSVRZMQwwCgYDVQQLDANHSVgxEzARBgNVBAMMCmljcC1hcGkuaW8xMDAuBgkq -hkiG9w0BCQEWIWZyZWRlcmlrLnJvdGhlbmJlcmdlckBkZmluaXR5Lm9yZzAeFw0y -MzAyMTcwOTE5MjRaFw0yNDAyMTcwOTE5MjRaMIGWMQswCQYDVQQGEwJjaDEPMA0G -A1UECAwGWnVyaWNoMQ8wDQYDVQQHDAZadXJpY2gxEDAOBgNVBAoMB0RGSU5JVFkx -DDAKBgNVBAsMA0dJWDETMBEGA1UEAwwKaWNwLWFwaS5pbzEwMC4GCSqGSIb3DQEJ -ARYhZnJlZGVyaWsucm90aGVuYmVyZ2VyQGRmaW5pdHkub3JnMIICIjANBgkqhkiG -9w0BAQEFAAOCAg8AMIICCgKCAgEAyWV74X1EGnlXN0z6138QArv61DXXl/ncUg8b -dPuH/7POb/Nglhx8LEqxHuNFBQVdKMdMXGUoe1ury3hvc5qEYR61//GXnhDh6OgD -6KBKNkE59H33lgMK54mVTUraKILWY6eifFtJbVjVgON2s5fHQwsYQKfn/S/aiMu3 -oAsP8E6kbYFfRaBjD+VW2uHj9pb6lxBr53a1b+4YUSqHv9NN+jhdLEyfTTn4R9o6 -JhBX/jKjfvvMT6JMR2ctgY3AdQanes8HhV2BzNl0J08Zt6eKjmNV/mXYFdHE6ITR -yEDh+Txqhfi+6ys0HnfxjyC2klaDRJb8P96Y+n5E6KpJFiJXe1SuxLsfI85pmh1i -40yid/YBnGSJytajcK4+zHEiRTejo/frLf7eugu5NJTY/PkJXJUjj2C0EWE5/Wrj -8S3SHrhuBzmCPcSptNB1/onvLIeqFu+LeF77TX7jUxLjsh35sSm2GgaBuAl//S1S -xlOwzlXaXpe1K2i2QCv7ngh5E+BJeLFHlwdtOAf9hZ/FIVQj3id+5hId8oVGIuyM -SI9BSnA/GB+16W/DOTM0Uv3GhXeYCa4MkWMuzam3RDqG2TsYyCq0ORci9LA62tut -pxCMz19IFukWKaAs1IVxOxO/CCfFUZHximeikb73j2Fb+t+Nzzvzu0M9cIm+PnlA -U9lLbuMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAhbpEkP/oHN/YZCGcZqdeINb1 -4ux2UnScSiuvuxYHP481oDRSOnde+IHdYe5BJAum7GahU8m87YOwTc0qpAYyt8ap -4JXNt6L8G629NHFA6hIS8C7aj12xuXDdHcJVu7qEKkPVgIn7iEsQNKDCDGWZ+2Qs -87WvxsS7isFHpjuV12NBVn9J8urCUAsHkzTa1KTcNkwKKhk2Pv0OyeCqhM0oGQOb -YMVwAT0Cw+5946lMxdgJ04iV9Ju+BhaKaPdF8sMN2d87HTnWxhJJ6TLcNZBovwii -9SksUzW3mM0YMjogqtwvq1w6lE+YHXnTe2aChMwmlu6bumaQ5fH8bM+LDx8lQ0cM -8ZELY2IvFrIPcoGLWmzERsLsGLnlBGq2dV8tvzmXnjF2MQOxjQUE7FGli1V+p28B -5ro0exRRB4P0wdcGv07qNBjlBF5LOmt8mJRoZNzrqxJtc9A7FTouUdwEbymbCyML -kD7QMIWK3HspoYgdNif3LX1BHjltPkc2AZLXYnRnF1wM1dMcs12pcRidjxhrZ0Lg -r+Jdh/jZUayNnogAHfqa7dDUuI3cWwcoJojEm9U6zzakjaK8EQUxjJ/1iZpdlnbm -GPd9aMMMKSIJkpUU5ELeqTLoTY+rSiiSzlAix3Ltb1l0RYvlbFCa4hFHe0ks6fJc -ObNOJvucNIzDY3fzbz8= ------END CERTIFICATE----- diff --git a/docker-test-env/reverse_proxy/certs/dummy.key b/docker-test-env/reverse_proxy/certs/dummy.key deleted file mode 100644 index d4f13cd7e4..0000000000 --- a/docker-test-env/reverse_proxy/certs/dummy.key +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- diff --git a/docker-test-env/reverse_proxy/nginx.conf b/docker-test-env/reverse_proxy/nginx.conf deleted file mode 100644 index 4790c0f25b..0000000000 --- a/docker-test-env/reverse_proxy/nginx.conf +++ /dev/null @@ -1,126 +0,0 @@ -worker_processes 1; -events { - worker_connections 1024; -} -http { - include mime.types; - default_type application/octet-stream; - sendfile on; - keepalive_timeout 65; - - # The certificate is self-signed and does not match all the server_names, but tests don't validate certificates - ssl_certificate /etc/nginx/certs/dummy.crt; - ssl_certificate_key /etc/nginx/certs/dummy.key; - - # (a mock of) the IC's HTTP API endpoint - server { - listen 443 ssl; - server_name icp-api.io; - - location / { - proxy_pass http://host.docker.internal:4943; - proxy_set_header Host $http_host; - - # include details about the original request - proxy_set_header X-Original-Host $http_host; - proxy_set_header X-Original-Scheme $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_redirect off; - } - } - - # (a mock of) the official internet identity server/domain, i.e. where the webapp is served - server { - listen 443 ssl; - server_name identity.internetcomputer.org; - - location / { - proxy_pass http://host.docker.internal:II_PORT; - proxy_set_header Host II_CANISTER_ID.localhost; - - # include details about the original request - proxy_set_header X-Original-Host $http_host; - proxy_set_header X-Original-Scheme $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_redirect off; - } - } - - # (a mock of) the legacy internet identity server/domain, used to ensure we're still compatible - server { - listen 443 ssl; - server_name identity.ic0.app; - - location / { - proxy_pass http://host.docker.internal:II_PORT; - proxy_set_header Host II_CANISTER_ID.localhost; - - # include details about the original request - proxy_set_header X-Original-Host $http_host; - proxy_set_header X-Original-Scheme $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_redirect off; - } - } - - # This makes all requests to raw return 400 - server { - listen 443 ssl; - server_name "~^(.*)\.raw\.icp0\.io$"; - - location / { - add_header "Access-Control-Allow-Origin" "*" always; - return 400; - } - } - - # (a mock of) the IC, which we use to e.g. query for alternative origins - server { - listen 443 ssl; - server_name "~^(.*)\.icp0\.io"; - - location / { - proxy_pass http://host.docker.internal:4943; - proxy_set_header Host $1.localhost; - - # include details about the original request - proxy_set_header X-Original-Host $http_host; - proxy_set_header X-Original-Scheme $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_redirect off; - } - } - - # (a mock of) the IC as above, but used for ensuring consistency between legacy - # and official domain - server { - listen 443 ssl; - server_name "~^(.*)\.ic0\.app"; - - location / { - proxy_pass http://host.docker.internal:4943; - proxy_set_header Host $1.localhost; - - # include details about the original request - proxy_set_header X-Original-Host $http_host; - proxy_set_header X-Original-Scheme $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_redirect off; - } - } - server { - listen 443 ssl; - server_name nice-name.com; - - location / { - proxy_pass http://host.docker.internal:4943; - proxy_set_header Host TEST_APP_CANISTER_ID.localhost; - - # include details about the original request - proxy_set_header X-Original-Host $http_host; - proxy_set_header X-Original-Scheme $scheme; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_redirect off; - } - } -} diff --git a/package-lock.json b/package-lock.json index 45847bcc4b..455e5fb236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,11 +30,13 @@ "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "6.7.5", "@typescript-eslint/parser": "6.7.5", + "@vitejs/plugin-basic-ssl": "^1.0.1", "@wdio/globals": "^8.6.9", "astro": "^2.4.1", "eslint": "8.51.0", "fake-indexeddb": "^4.0.2", "html-minifier-terser": "^7.2.0", + "http-proxy": "^1.18.1", "prettier": "2.8.0", "prettier-plugin-organize-imports": "^3.2.2", "ts-loader": "9.4.1", @@ -2133,6 +2135,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.1.tgz", + "integrity": "sha512-pcub+YbFtFhaGRTo1832FQHQSHvMrlb43974e2eS8EKleR3p1cDdkJFPci1UhwkEf1J9Bz+wKBSzqpKp7nNj2A==", + "dev": true, + "engines": { + "node": ">=14.6.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0" + } + }, "node_modules/@vitest/expect": { "version": "0.31.0", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.31.0.tgz", @@ -6608,6 +6622,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6890,6 +6910,26 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -7744,6 +7784,20 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -11739,9 +11793,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/resolve": { "version": "1.22.2", @@ -16413,6 +16465,13 @@ "eslint-visitor-keys": "^3.4.1" } }, + "@vitejs/plugin-basic-ssl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.1.tgz", + "integrity": "sha512-pcub+YbFtFhaGRTo1832FQHQSHvMrlb43974e2eS8EKleR3p1cDdkJFPci1UhwkEf1J9Bz+wKBSzqpKp7nNj2A==", + "dev": true, + "requires": {} + }, "@vitest/expect": { "version": "0.31.0", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.31.0.tgz", @@ -19879,6 +19938,12 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -20098,6 +20163,12 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "dev": true + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -20752,6 +20823,17 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -23580,9 +23662,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "resolve": { "version": "1.22.2", diff --git a/package.json b/package.json index 412cb95050..cad620c30a 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "typescript": "5.2.2", "vite": "^4.2.1", "vite-plugin-compression": "^0.5.1", + "@vitejs/plugin-basic-ssl": "^1.0.1", "vitest": "^0.31.0", + "http-proxy": "^1.18.1", "webdriverio": "^8.21.0" }, "dependencies": { diff --git a/scripts/start-selenium-env b/scripts/start-selenium-env deleted file mode 100755 index 501798d338..0000000000 --- a/scripts/start-selenium-env +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Make sure we always run from the root -SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "$SCRIPTS_DIR/.." - -######### -# USAGE # -######### - -function title() { - echo "Launch docker based Selenium test infrastructure (selenium and nginx containers)" -} - -function usage() { - cat << EOF - -Usage: - $0 [--no-hot-reload] - -Options: - --no-hot-reload Uses dfx as the II host thus removing the dependency on the II dev server. By doing so, II will no longer hot reload front-end changes. -EOF -} - -function help() { - cat << EOF - -Launches docker based Selenium test infrastructure (selenium and nginx containers). -Run "docker compose down" in the docker-test-env folder to stop again. - -NOTE: This requires docker, docker-compose, a running dfx replica with II and the test app deployed and running II dev server (npm run dev). -EOF - -} -II_PORT=5173 -while [[ $# -gt 0 ]] -do - case "$1" in - -h|--help) - title - usage - help - exit 0 - ;; - --no-hot-reload) - II_PORT=4943 - shift - ;; - *) - echo "ERROR: unknown argument $1" - usage - echo - echo "Use '$0 --help' for more information" - exit 1 - ;; - esac -done - -II_CANISTER_ID=$( jq -r .internet_identity.local .dfx/local/canister_ids.json ) -TEST_APP_CANISTER_ID=$( jq -r .test_app.local demos/test-app/.dfx/local/canister_ids.json ) -echo "II canister id: $II_CANISTER_ID" -echo "test app canister id: $TEST_APP_CANISTER_ID" - -cd "docker-test-env" -COMPOSE_CONFIG=$(sed "s/II_CANISTER_ID/$II_CANISTER_ID/g; s/TEST_APP_CANISTER_ID/$TEST_APP_CANISTER_ID/g" docker-compose.yml) -echo "$COMPOSE_CONFIG" | docker compose -f - build --build-arg II_CANISTER_ID="$II_CANISTER_ID" --build-arg II_PORT="$II_PORT" --build-arg TEST_APP_CANISTER_ID="$TEST_APP_CANISTER_ID" -echo "$COMPOSE_CONFIG" | docker compose -f - up -d --wait diff --git a/scripts/with-docker-env b/scripts/with-docker-env deleted file mode 100755 index aa57dd8950..0000000000 --- a/scripts/with-docker-env +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Make sure we always run from the root -SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cd "$SCRIPTS_DIR/.." - -######### -# USAGE # -######### - -function title() { - echo "Execute a command between starting and stopping the docker compose environment" -} - -function usage() { - cat << EOF - -Usage: - $0 - -EOF -} - -function help() { - cat << EOF - -Starts the docker compose environment, executes the subcommand and stops docker compose again. - -NOTE: This requires docker, docker-compose, a running dfx replica with II and the test app deployed and running II dev server (npm run dev). -EOF -} - -scripts/start-selenium-env - -ret=0 -"$@" || ret="$?" - -echo "'$*' returned with $ret" - -cd docker-test-env -docker compose down || echo "docker compose failed to shut down!" -exit "$ret" \ No newline at end of file diff --git a/src/frontend/src/test-e2e/alternativeOrigin/endpointFormat.test.ts b/src/frontend/src/test-e2e/alternativeOrigin/endpointFormat.test.ts index 897337b1e7..1df47d82e7 100644 --- a/src/frontend/src/test-e2e/alternativeOrigin/endpointFormat.test.ts +++ b/src/frontend/src/test-e2e/alternativeOrigin/endpointFormat.test.ts @@ -134,13 +134,13 @@ test("Should fetch /.well-known/ii-alternative-origins using the non-raw url", a ); // Selenium has _no_ connectivity to the raw url + // We want accessing raw urls to fail because it would be a security issue on mainnet await browser.execute( - `console.log(await fetch("https://${TEST_APP_CANISTER_ID}.raw.icp0.io/.well-known/ii-alternative-origins"))` + `try{await fetch("https://${TEST_APP_CANISTER_ID}.raw.icp0.io/.well-known/ii-alternative-origins")}catch(e){e.message}` ); + let logs = (await browser.getLogs("browser")) as { message: string }[]; - expect(logs[logs.length - 1].message).toEqual( - `https://${TEST_APP_CANISTER_ID}.raw.icp0.io/.well-known/ii-alternative-origins - Failed to load resource: the server responded with a status of 400 (Bad Request)` - ); + expect(logs.at(-1)?.message).toContain(`Failed to load resource`); // This works anyway --> fetched using non-raw const authenticateView = new AuthenticateView(browser); diff --git a/src/frontend/src/test-e2e/util.ts b/src/frontend/src/test-e2e/util.ts index b4b978bb9b..20bf302ac6 100644 --- a/src/frontend/src/test-e2e/util.ts +++ b/src/frontend/src/test-e2e/util.ts @@ -36,6 +36,12 @@ export async function runInBrowser( args: [ "--ignore-certificate-errors", // allow self-signed certificates "--disable-gpu", + "--disable-dev-shm-usage", // disable /dev/shm usage because chrome is prone to crashing otherwise + "--headless", + // Map all hosts to localhost:5173 (which is the vite dev server) in the context of DNS resolution. + // The dev server will then terminate TLS and either forward the request to the local replica or serve + // assets directly. + "--host-resolver-rules=MAP * localhost:5173", ...(nonNullish(userAgent) ? [`--user-agent=${userAgent}`] : []), ], @@ -62,13 +68,9 @@ export async function runInBrowser( const browser = await remote({ capabilities: { browserName: "chrome", + browserVersion: "119.0.6045.105", // More information about available versions can be found here: https://github.com/GoogleChromeLabs/chrome-for-testing "goog:chromeOptions": chromeOptions, }, - automationProtocol: "webdriver", - // explicitly set host and port, because otherwise webdriverio will start their own chromedriver - hostname: "127.0.0.1", - port: 4444, - path: "/wd/hub", }); // setup test suite diff --git a/src/frontend/src/test-e2e/views.ts b/src/frontend/src/test-e2e/views.ts index 2b7dacb189..266416d69f 100644 --- a/src/frontend/src/test-e2e/views.ts +++ b/src/frontend/src/test-e2e/views.ts @@ -185,37 +185,21 @@ export class RecoveryMethodSelectorView extends View { } async getSeedPhrase(): Promise { - // This tries to read the recovery phrase by first copying it to the clipboard. - - await this.copySeedPhrase(); - - // Our CSP policy prevents us from directly reading the clipboard. - // Instead, we mock user input to paste the clipboard content in textarea element and - // read the element's value. - - // First, create a new textarea element where the phrase will be pasted - await this.browser.execute(() => { - const elem = document.createElement("textarea"); - elem.setAttribute("id", "my-paste-area"); - document.body.prepend(elem); - }); - - // Select the element and mock "Ctrl + V" for pasting the clipboard content into said element - await this.browser.$("#my-paste-area").click(); - await this.browser.keys(["Control", "v"]); - - // Read the element's value and clean up - const seedPhrase = await this.browser.execute(() => { - const elem = document.querySelector( - "#my-paste-area" - ) as HTMLTextAreaElement; - // NOTE: we could also query the value with wdio's $(..).getValue(), but since we have - // the element here might as well. - const seedPhrase = elem.value!; - elem.remove(); - return seedPhrase; - }); - + // Ideally, we could press the copy button on the page and the read the + // clipboard. However, this is not possible due to the content security + // policy. + // The alternative solution of simulating key presses does not work either, + // since chromium does not allow to interact with the clipboard via keyboard + // shortcuts when run in headless mode (which is the only mode accepted by CI). + // For the lack of a better solution, we read the seed phrase from the DOM. + + const seedPhrase = (await this.browser.execute(() => + Array.from(document.querySelectorAll(".c-list--recovery-word")) + .map((e) => (e as HTMLElement).innerText) + .join(" ") + )) as string; + + assert(seedPhrase?.length > 0, "Seed phrase is empty!"); return seedPhrase; } diff --git a/vite.config.ts b/vite.config.ts index de172b9ad7..6be5ca005f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,14 @@ +import { nonNullish } from "@dfinity/utils"; +import basicSsl from "@vitejs/plugin-basic-ssl"; +import { existsSync } from "fs"; import { resolve } from "path"; -import { AliasOptions, defineConfig, UserConfig } from "vite"; +import { AliasOptions, UserConfig, defineConfig } from "vite"; import { compression, injectCanisterIdPlugin, minifyHTML, + readCanisterId, + replicaForwardPlugin, } from "./vite.plugins"; export const aliasConfig: AliasOptions = { @@ -25,6 +30,15 @@ export default defineConfig(({ mode }: UserConfig): UserConfig => { II_VERSION: `${process.env.II_VERSION ?? ""}`, }; + const testAppCanisterId = existsSync( + "demos/test-app/.dfx/local/canister_ids.json" + ) + ? readCanisterId({ + canisterName: "test_app", + canisterIdsJsonFile: "demos/test-app/.dfx/local/canister_ids.json", + }) + : undefined; + // Path "../../" have to be expressed relative to the "root". // e.g. // root = src/frontend @@ -59,6 +73,35 @@ export default defineConfig(({ mode }: UserConfig): UserConfig => { plugins: [ [...(mode === "development" ? [injectCanisterIdPlugin()] : [])], [...(mode === "production" ? [minifyHTML(), compression()] : [])], + [...(process.env.TLS_DEV_SERVER === "1" ? [basicSsl()] : [])], + replicaForwardPlugin({ + replicaOrigin: "127.0.0.1:4943", + forwardRules: [ + ...(nonNullish(testAppCanisterId) + ? [ + { + hosts: [ + "nice-name.com", + `${testAppCanisterId}.ic0.app`, + `${testAppCanisterId}.icp0.io`, + ], + canisterId: testAppCanisterId, + }, + ] + : []), + ...(process.env.NO_HOT_RELOAD === "1" + ? [ + { + hosts: ["identity.ic0.app", "identity.internetcomputer.org"], + canisterId: readCanisterId({ + canisterName: "internet_identity", + canisterIdsJsonFile: ".dfx/local/canister_ids.json", + }), + }, + ] + : []), + ], + }), ], optimizeDeps: { esbuildOptions: { @@ -68,6 +111,7 @@ export default defineConfig(({ mode }: UserConfig): UserConfig => { }, }, server: { + https: process.env.TLS_DEV_SERVER === "1", proxy: { "/api": "http://127.0.0.1:4943", }, diff --git a/vite.plugins.ts b/vite.plugins.ts index 9059816c40..1a6cb819cd 100644 --- a/vite.plugins.ts +++ b/vite.plugins.ts @@ -1,9 +1,11 @@ -import { assertNonNullish } from "@dfinity/utils"; +import { assertNonNullish, isNullish } from "@dfinity/utils"; import { readFileSync } from "fs"; import { minify } from "html-minifier-terser"; +import httpProxy from "http-proxy"; import { extname } from "path"; -import { Plugin } from "vite"; +import { Plugin, ViteDevServer } from "vite"; import viteCompression from "vite-plugin-compression"; + /** * Read a canister ID from dfx's local state */ @@ -79,3 +81,68 @@ export const minifyHTML = (): { return minify(html, { collapseWhitespace: true }); }, }); + +/** + * Forwards requests to the local replica. + * Denies access to raw URLs. + * + * @param replicaOrigin Replica URL to forward requests to + * @param forwardRules List of rules (i.e. hostname to canisterId mappings) + * to forward requests to a specific canister + */ +export const replicaForwardPlugin = ({ + replicaOrigin, + forwardRules, +}: { + replicaOrigin: string; + forwardRules: Array<{ canisterId: string; hosts: string[] }>; +}) => ({ + name: "replica-forward", + configureServer(server: ViteDevServer) { + const proxy = httpProxy.createProxyServer({ + secure: false, + }); + + server.middlewares.use((req, res, next) => { + if ( + /* Deny requests to raw URLs, e.g. .raw.ic0.app to make sure that II always uses certified assets + * to verify the alternative origins. */ + req.headers["host"]?.includes(".raw.") + ) { + console.log( + `Denying access to raw URL ${req.method} https://${req.headers.host}${req.url}` + ); + res.statusCode = 400; + res.end("Raw IC URLs are not supported"); + return; + } + + const host = req.headers["host"]; + if (isNullish(host)) { + // default handling + return next(); + } + + const matchingRule = forwardRules.find((rule) => + rule.hosts.includes(host) + ); + if (isNullish(matchingRule)) { + // default handling + return next(); + } + + console.log( + `forwarding ${req.method} https://${req.headers.host}${req.url} to canister ${matchingRule.canisterId}` + ); + req.headers["host"] = `${matchingRule.canisterId}.localhost`; + proxy.web(req, res, { + target: `http://${replicaOrigin}`, + }); + + proxy.on("error", (err: Error) => { + res.statusCode = 500; + res.end("Replica forwarding failed: " + err.message); + }); + }); + }, +});