diff --git a/src/web-socket.toit b/src/web-socket.toit index cf41676..1eabd3e 100644 --- a/src/web-socket.toit +++ b/src/web-socket.toit @@ -302,6 +302,13 @@ class WebSocket: */ static check-server-upgrade-request_ request/RequestIncoming response-writer/ResponseWriter -> string?: connection-header := request.headers.single "Connection" + // A connection header may have multiple tokens. Firefox, for example, sends + // "keep-alive, Upgrade". + connection-tokens := connection-header + ? (connection-header.split ",").map: it.trim + : [] + has-upgrade-connection-token := connection-tokens.any: | token/string | + token.to-ascii-lower == "upgrade" upgrade-header := request.headers.single "Upgrade" version-header := request.headers.single "Sec-WebSocket-Version" nonce := request.headers.single "Sec-WebSocket-Key" @@ -309,7 +316,7 @@ class WebSocket: if nonce == null: message = "No nonce" else if nonce.size != 24: message = "Bad nonce size" else if not connection-header or not upgrade-header: message = "No upgrade headers" - else if (Headers.ascii-normalize_ connection-header) != "Upgrade": message = "No Connection: Upgrade" + else if not has-upgrade-connection-token: message = "No Connection: Upgrade" else if (Headers.ascii-normalize_ upgrade-header) != "Websocket": message = "No Upgrade: websocket" else if version-header != "13": message = "Unrecognized Websocket version" else: diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f83e6a8..234a8c6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,7 +7,7 @@ file(GLOB HTTPBIN_TESTS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "*-test-httpbin.toi set(TOIT_EXEC "toit.run${CMAKE_EXECUTABLE_SUFFIX}" CACHE FILEPATH "The executable used to run the tests") set(TPKG_EXEC "toit.pkg${CMAKE_EXECUTABLE_SUFFIX}" CACHE FILEPATH "The executable used to install the packages") -set(TEST_TIMEOUT 40 CACHE STRING "The maximal amount of time each test is allowed to run") +set(TEST_TIMEOUT 120 CACHE STRING "The maximal amount of time each test is allowed to run") set(ENABLE_HTTPBIN_TESTS ON CACHE BOOL "Whether to run tests that depend on httpbin.org docker") set(USE_HTTPBIN_DOCKER OFF CACHE BOOL "Whether to use the httpbin.org docker container") @@ -41,16 +41,40 @@ include(fail.cmake OPTIONAL) message("Failing tests: ${FAILING_TESTS}") message("Skipped tests: ${SKIP_TESTS}") +set(BROWSERS "chrome") +if (APPLE) + # The macOS GitHub runner doesn't come with geckodriver anymore: + # https://github.com/actions/runner-images/issues/9974 + # Since that browser is already tested on the other platforms, we skip it here. + list(APPEND BROWSERS "safari") +elseif (WIN32) + list(APPEND BROWSERS "edge" "firefox") +else() + list(APPEND BROWSERS "firefox") +endif() + foreach(file ${TESTS}) set(test_name "/tests/${file}") if("${test_name}" IN_LIST SKIP_TESTS) continue() endif() - add_test( - NAME "${test_name}" - COMMAND "${TOIT_EXEC}" "-Xenable-asserts" "${file}" - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + if ("${file}" MATCHES "webdriver-") + foreach (browser ${BROWSERS}) + set(test_name "/tests/${file}-${browser}") + add_test( + NAME "${test_name}" + COMMAND "${TOIT_EXEC}" "-Xenable-asserts" "${file}" "${browser}" + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + ) + set_tests_properties("${test_name}" PROPERTIES TIMEOUT ${TEST_TIMEOUT}) + endforeach() + else() + add_test( + NAME "${test_name}" + COMMAND "${TOIT_EXEC}" "-Xenable-asserts" "${file}" + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) + endif() set_tests_properties(${test_name} PROPERTIES TIMEOUT ${TEST_TIMEOUT}) endforeach() diff --git a/tests/http-standalone-test.toit b/tests/http-standalone-test.toit index e938d1d..5e4e00e 100644 --- a/tests/http-standalone-test.toit +++ b/tests/http-standalone-test.toit @@ -345,11 +345,11 @@ listen server server-socket my-port other-port: response-writer.headers.set "Content-Type" "text/plain" while data := request.body.read: writer.write data - else if request.query.resource == "/get_with_parameters": + else if resource == "/get_with_parameters": response-writer.headers.set "Content-Type" "text/plain" writer.write "Response with parameters" POST-DATA.do: | key/string value/string | expect-equals value request.query.parameters[key] else: - print "request.query.resource = '$request.query.resource'" + print "request.query.resource = '$resource'" response-writer.write-headers http.STATUS-NOT-FOUND --message="Not Found" diff --git a/tests/package.lock b/tests/package.lock index 2193b27..9a018b3 100644 --- a/tests/package.lock +++ b/tests/package.lock @@ -1,10 +1,24 @@ -sdk: ^2.0.0-alpha.91 +sdk: ^2.0.0-alpha.144 prefixes: certificate_roots: toit-cert-roots + fs: pkg-fs + host: pkg-host http: .. packages: ..: path: .. + pkg-fs: + url: github.com/toitlang/pkg-fs + name: fs + version: 2.3.1 + hash: 60836d4500317af2093d59d50c117d612c33f1fa + prefixes: + host: pkg-host + pkg-host: + url: github.com/toitlang/pkg-host + name: host + version: 1.15.1 + hash: ff187c2c19d695e66c3dc1d9c09b4dc6bec09088 toit-cert-roots: url: github.com/toitware/toit-cert-roots name: certificate_roots diff --git a/tests/package.yaml b/tests/package.yaml index 02d3522..56db2b2 100644 --- a/tests/package.yaml +++ b/tests/package.yaml @@ -2,5 +2,11 @@ dependencies: certificate_roots: url: github.com/toitware/toit-cert-roots version: ^1.6.1 + fs: + url: github.com/toitlang/pkg-fs + version: ^2.3.1 + host: + url: github.com/toitlang/pkg-host + version: ^1.15.1 http: path: .. diff --git a/tests/third-party/ephemeral-port-reserve/.coveragerc b/tests/third-party/ephemeral-port-reserve/.coveragerc new file mode 100644 index 0000000..ffa0456 --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/.coveragerc @@ -0,0 +1,33 @@ +[run] +branch = True +source = + . +omit = + .tox/* + /usr/* + */tmp* + setup.py + # Don't complain if non-runnable code isn't run + */__main__.py + yelp_styleguide/templates/*.py + # Coverage is incorrectly reported for distutils/__init__.py only when in venv, causing < 100% + */distutils/__init__.py + +[report] +exclude_lines = + # Have to re-enable the standard pragma + \#\s*pragma: no cover + + # Don't complain if tests don't hit defensive assertion code: + ^\s*raise AssertionError\b + ^\s*raise NotImplementedError\b + ^\s*return NotImplemented\b + ^\s*raise$ + + # Don't complain if non-runnable code isn't run: + ^if __name__ == ['"]__main__['"]:$ + +[html] +directory = coverage-html + +# vim:ft=dosini diff --git a/tests/third-party/ephemeral-port-reserve/.gitignore b/tests/third-party/ephemeral-port-reserve/.gitignore new file mode 100644 index 0000000..e567663 --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/.gitignore @@ -0,0 +1,17 @@ +.cache +._* +*.egg +*.eggs +*.egg-info +*.iml +*.py[co] +.*.sw[a-z] +.coverage +.idea +.project +.pydevproject +.tox +.venv.touch +/venv* +coverage-html +dist diff --git a/tests/third-party/ephemeral-port-reserve/.pre-commit-config.yaml b/tests/third-party/ephemeral-port-reserve/.pre-commit-config.yaml new file mode 100644 index 0000000..638756f --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: debug-statements + - id: name-tests-test + - id: check-added-large-files +- repo: https://github.com/pre-commit/mirrors-autopep8 + rev: v1.4.4 + hooks: + - id: autopep8 +- repo: https://github.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 +- repo: https://github.com/asottile/reorder_python_imports + rev: v1.8.0 + hooks: + - id: reorder-python-imports + args: [ + '--add-import', 'from __future__ import absolute_import', + '--add-import', 'from __future__ import unicode_literals', + ] diff --git a/tests/third-party/ephemeral-port-reserve/.travis.yml b/tests/third-party/ephemeral-port-reserve/.travis.yml new file mode 100644 index 0000000..c6d3901 --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/.travis.yml @@ -0,0 +1,13 @@ +language: python +matrix: + include: + - env: TOXENV=py27 + python: 2.7 + - env: TOXENV=py35 + python: 3.5 + - env: TOXENV=py36 + python: 3.6 + - env: TOXENV=pypy + python: pypy +install: pip install coveralls tox +script: tox diff --git a/tests/third-party/ephemeral-port-reserve/LICENSE b/tests/third-party/ephemeral-port-reserve/LICENSE new file mode 100644 index 0000000..6c94062 --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Yelp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/third-party/ephemeral-port-reserve/README.md b/tests/third-party/ephemeral-port-reserve/README.md new file mode 100644 index 0000000..b9eaa3d --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/README.md @@ -0,0 +1,38 @@ +# `ephemeral-port-reserve` +Sometimes you need a networked program to bind to a port that can't be hard-coded. +Generally this is when you want to run several of them in parallel; if they all +bind to port 8080, only one of them can succeed. + +The usual solution is the "port 0 trick". If you bind to port 0, your kernel will +find some arbitrary high-numbered port that's unused and bind to that. Afterward +you can query the actual port that was bound to if you need to use the port number +elsewhere. However, there are cases where the port 0 trick won't work. For example, +mysqld takes port 0 to mean "the port configured in my.cnf". Docker can bind your +containers to port 0, but uses its own implementation to find a free port which +races and fails in the face of parallelism. + +`ephemeral-port-reserve` provides an implementation of the port 0 trick which +is reliable and race-free. You can use it like so: + +```!bash +PORT="$(ephemeral-port-reserve)" +docker run -p 127.0.0.1:$PORT:5000 registry:2 +``` + + +`ephemeral-port-reserve` is a utility to bind to an ephemeral port, force it into +the `TIME_WAIT` state, and unbind it. + +This means that further ephemeral port alloctions won't pick this "reserved" port, +but subprocesses can still bind to it explicitly, given that they use `SO_REUSEADDR`. +By default on linux you have a grace period of 60 seconds to reuse this port. +To check your own particular value: + +```!bash +$ cat /proc/sys/net/ipv4/tcp_fin_timeout +60 +``` + +**NOTE:** By default, the port returned is *specifically* for `localhost`, aka `127.0.0.1`. +If you bind instead to `0.0.0.0`, you may encounter a port conflict. If you need to +bind to a non-localhost IP, you can pass it as the first argument. diff --git a/tests/third-party/ephemeral-port-reserve/README.toit b/tests/third-party/ephemeral-port-reserve/README.toit new file mode 100644 index 0000000..0ca8ef1 --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/README.toit @@ -0,0 +1,16 @@ +Name: Ephemeral Port Reserve +URL: https://github.com/Yelp/ephemeral-port-reserve +Copy: https://github.com/toitware/ephemeral-port-reserve +Revision: 6cf5addc2f1d5c0a25be4ba4af62104da1d8fc51 +Date: 2024-08-22 +License: MIT + +Description: +A utility to bind to an ephemeral port, force it into the TIME_WAIT state, and unbind it. + +This means that further ephemeral port alloctions won't pick this "reserved" port, but +subprocesses can still bind to it explicitly, given that they use SO_REUSEADDR. By +default on linux you have a grace period of 60 seconds to reuse this port. + +Changes: +No changes except to remove unnecessary files. diff --git a/tests/third-party/ephemeral-port-reserve/ephemeral_port_reserve.py b/tests/third-party/ephemeral-port-reserve/ephemeral_port_reserve.py new file mode 100755 index 0000000..5124b43 --- /dev/null +++ b/tests/third-party/ephemeral-port-reserve/ephemeral_port_reserve.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +from __future__ import absolute_import +from __future__ import unicode_literals + +import contextlib +import errno +from socket import error as SocketError +from socket import SO_REUSEADDR +from socket import socket +from socket import SOL_SOCKET + +LOCALHOST = '127.0.0.1' + + +def reserve(ip=LOCALHOST, port=0): + """Bind to an ephemeral port, force it into the TIME_WAIT state, and unbind it. + + This means that further ephemeral port alloctions won't pick this "reserved" port, + but subprocesses can still bind to it explicitly, given that they use SO_REUSEADDR. + By default on linux you have a grace period of 60 seconds to reuse this port. + To check your own particular value: + $ cat /proc/sys/net/ipv4/tcp_fin_timeout + 60 + + By default, the port will be reserved for localhost (aka 127.0.0.1). + To reserve a port for a different ip, provide the ip as the first argument. + Note that IP 0.0.0.0 is interpreted as localhost. + """ + port = int(port) + with contextlib.closing(socket()) as s: + s.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) + try: + s.bind((ip, port)) + except SocketError as e: + # socket.error: EADDRINUSE Address already in use + if e.errno == errno.EADDRINUSE and port != 0: + s.bind((ip, 0)) + else: + raise + + # the connect below deadlocks on kernel >= 4.4.0 unless this arg is greater than zero + s.listen(1) + + sockname = s.getsockname() + + # these three are necessary just to get the port into a TIME_WAIT state + with contextlib.closing(socket()) as s2: + s2.connect(sockname) + sock, _ = s.accept() + with contextlib.closing(sock): + return sockname[1] + + +def main(): # pragma: no cover + from sys import argv + port = reserve(*argv[1:]) + print(port) + + +if __name__ == '__main__': + exit(main()) diff --git a/tests/webdriver-test.toit b/tests/webdriver-test.toit new file mode 100644 index 0000000..01caa5a --- /dev/null +++ b/tests/webdriver-test.toit @@ -0,0 +1,439 @@ +// Copyright (C) 2024 Toitware ApS. +// Use of this source code is governed by a Zero-Clause BSD license that can +// be found in the tests/TESTS_LICENSE file. + +import encoding.json +import encoding.url +import expect show * +import host.file +import http +import http.connection show is-close-exception_ +import io +import net + +import .webdriver + +main args/List: + if args.is-empty: return + + network := net.open + + browser := args.first + server-task := start-server network + + if browser == "--serve": + // Just run the server. + return + + if not DRIVERS_.get browser: + // This test may be called from the Toit repository with the wrong arguments. + // We don't want to fail in that case. + print "*********************************************" + print "IGNORING UNSUPPORTED BROWSER: $browser" + print "*********************************************" + return + + web-driver := WebDriver browser + web-driver.start + try: + test-status web-driver + test-json web-driver + test-json-content-length web-driver + test-204-no-content web-driver + test-500-because-nothing-written web-driver + test-500-because-throw-before-headers web-driver + test-hard-close-because-wrote-too-little web-driver + test-hard-close-because-throw-after-headers web-driver + test-post-json web-driver + test-post-form web-driver + test-get-with-parameters web-driver + test-websocket web-driver + finally: + web-driver.close + server-task.cancel + +// Is set by start-server. +URL/string? := null + +HTML ::= """ + + + + Test + + +
Pending
+ + + +""" + +build-xml-request -> string + path/string + expected-status/int + response-check/string + --expect-send-error/bool=false + --method="GET" + --payload/string="": + send-error-handler := "" + catch-handler := "" + if expect-send-error: + send-error-handler = """ + if (xhr.status !== 0) { + throw 'Error: Unexpected status ' + xhr.status; + } else { + document.getElementById('status').innerText = 'OK'; + return; + } + """ + else: + catch-handler = """ + throw e; + """ + + return """ + var xhr = new XMLHttpRequest(); + xhr.open('$method', '$path', true); + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + $send-error-handler + if (xhr.status !== $expected-status) { + throw 'Error ' + xhr.status + ' ' + xhr.statusText; + } + $response-check + document.getElementById('status').innerText = 'OK'; + } + }; + try { + xhr.send('$payload'); + } catch (e) { + $catch-handler + } + """ + +get-text driver/WebDriver --id/string -> string: + for i := 0; i < 10; i++: + driver-ids := driver.find "#$id" + if driver-ids.is-empty: + sleep --ms=(10 * i) + continue + return driver.get-text driver-ids.first + throw "Timeout waiting for element with id '$id'" + +expect-ok path/string driver/WebDriver -> none: + driver.goto "$URL/$path" + for i := 0; i < 10; i++: + text := get-text driver --id="status" + if text != "Pending": + expect-equals "OK" text + return + sleep --ms=(10 * i) + throw "Timeout waiting for 'OK'" + +test-status driver/WebDriver -> none: + expect-ok "test-status" driver + +html-status -> string: + return HTML.replace "SCRIPT" """ + document.getElementById('status').innerText = 'OK'; + """ + +test-json driver/WebDriver -> none: + expect-ok "test-json" driver + +html-json path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 200 """ + var json = JSON.parse(xhr.responseText); + if (json.foo !== 123) { + throw 'Error ' + json.foo; + } + // Check that the content-length header is *not* present. + if (xhr.getResponseHeader('Content-Length') !== null) { + throw 'Error: Content-Length header is present'; + } + """ + +test-json-content-length driver/WebDriver -> none: + expect-ok "test-json-content-length" driver + +html-json-content-length path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 200 """ + var json = JSON.parse(xhr.responseText); + if (json.foo !== 1234) { + throw 'Error ' + json.foo; + } + // Check that the content-length header is present. + if (xhr.getResponseHeader('Content-Length') === null) { + throw 'Error: Content-Length header missing'; + } + """ + +test-204-no-content driver/WebDriver -> none: + expect-ok "test-204-no-content" driver + +html-204-no-content path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 204 """ + if (xhr.getResponseHeader('X-Toit-Message') !== 'Nothing more to say') { + throw 'Error ' + xhr.getResponseHeader('X-Toit-Message'); + } + """ + +test-500-because-nothing-written driver/WebDriver -> none: + expect-ok "test-500-because-nothing-written" driver + +html-500-because-nothing-written path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 500 """ + if (xhr.status !== 500) { + throw 'Error ' + xhr.status + ' ' + xhr.statusText; + } + """ + +test-500-because-throw-before-headers driver/WebDriver -> none: + expect-ok "test-500-because-throw-before-headers" driver + +html-500-because-throw-before-headers path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 500 """ + if (xhr.status !== 500) { + throw 'Error ' + xhr.status + ' ' + xhr.statusText; + } + """ + +test-hard-close-because-wrote-too-little driver/WebDriver -> none: + expect-ok "test-hard-close-because-wrote-too-little" driver + +html-hard-close-because-wrote-too-little path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 200 "" --expect-send-error + +test-hard-close-because-throw-after-headers driver/WebDriver -> none: + expect-ok "test-hard-close-because-throw-after-headers" driver + +html-hard-close-because-throw-after-headers path/string -> string: + return HTML.replace "SCRIPT" + build-xml-request path 200 "" --expect-send-error + +test-post-json driver/WebDriver -> none: + expect-ok "test-post-json" driver + +html-post-json path/string -> string: + payload := """{"foo": "bar", "baz": [42, 103]}""" + return HTML.replace "SCRIPT" + build-xml-request path 200 + --method="POST" + --payload=payload + """ + var json = JSON.parse(xhr.responseText); + if (json.foo !== 'bar') { + throw 'Error ' + json.foo; + } + if (json.baz[0] !== 42) { + throw 'Error ' + json.baz[0]; + } + if (xhr.getResponseHeader('Content-Type') !== 'application/json') { + throw 'Error: Content-Type header is missing'; + } + """ + +POST-FORM-DATA ::= { + "foo": "bar", + "date": "2023-04-25", + "baz": "42?103", + "/&%": "slash", + "slash": "/&%" +} + +test-form driver/WebDriver path/string -> none: + driver.goto "$URL/$path" + id := driver.find "#submit" + driver.click id.first + expect-equals "OK" (get-text driver --id="status") + +test-post-form driver/WebDriver -> none: + test-form driver "test-post-form" + +html-form --method/string --path/string -> string: + return """ + + + + Test + + +
+ + + + + + +
+ + + """ + +html-form-ok -> string: + return HTML.replace "SCRIPT" """ + document.getElementById('status').innerText = 'OK'; + """ + +html-post-form path/string -> string: + return html-form --path=path --method="post" + +test-get-with-parameters driver/WebDriver -> none: + test-form driver "test-get-with-parameters" + +html-get-with-parameters path/string -> string: + return html-form --path=path --method="get" + +test-websocket driver/WebDriver -> none: + expect-ok "test-websocket" driver + +html-websocket path/string -> string: + return """ + + + + Test + + +
Pending
+ + + + """ + +start-server network -> Task: + server-socket := network.tcp-listen 0 + port := server-socket.local-address.port + URL = "http://localhost:$port" + print "Server running on $URL" + server := http.Server --max-tasks=50 + return task:: listen server server-socket port + +listen server server-socket my-port: + server.listen server-socket:: | request/http.RequestIncoming response-writer/http.ResponseWriter | + if request.method == "POST" and request.path != "/post_chunked": + expect-not-null (request.headers.single "Content-Length") + + resource := request.query.resource + + writer := response-writer.out + + fill-html := : | text/string | + response-writer.headers.set "Content-Type" "text/html" + writer.write text + + if resource == "/test-status": + fill-html.call html-status + else if resource == "/test-json": + fill-html.call (html-json "/foo.json") + else if resource == "/foo.json": + response-writer.headers.set "Content-Type" "application/json" + writer.write + json.encode {"foo": 123, "bar": 1.0/3, "fizz": [1, 42, 103]} + else if resource == "/test-json-content-length": + fill-html.call (html-json-content-length "/content-length.json") + else if resource == "/content-length.json": + data := json.encode {"foo": 1234, "bar": 1.0/3, "fizz": [1, 42, 103]} + response-writer.headers.set "Content-Type" "application/json" + response-writer.headers.set "Content-Length" "$data.size" + writer.write data + else if resource == "/test-204-no-content": + fill-html.call (html-204-no-content "/204-no-content") + else if resource == "/204-no-content": + response-writer.headers.set "X-Toit-Message" "Nothing more to say" + response-writer.write-headers http.STATUS-NO-CONTENT + else if resource == "/test-500-because-nothing-written": + fill-html.call (html-500-because-nothing-written "/500-because-nothing-written") + else if resource == "/500-because-nothing-written": + // Forget to write anything - the server should send 500 - Internal error. + else if resource == "/test-500-because-throw-before-headers": + fill-html.call (html-500-because-throw-before-headers "/500-because-throw-before-headers") + else if resource == "/500-because-throw-before-headers": + throw "** Expect a stack trace here caused by testing: throws-before-headers **" + else if resource == "/test-hard-close-because-wrote-too-little": + fill-html.call (html-hard-close-because-wrote-too-little "/hard-close-because-wrote-too-little") + else if resource == "/hard-close-because-wrote-too-little": + response-writer.headers.set "Content-Length" "2" + writer.write "x" // Only writes half the message. + else if resource == "/test-hard-close-because-throw-after-headers": + fill-html.call (html-hard-close-because-throw-after-headers "/hard-close-because-throw-after-headers") + else if resource == "/hard-close-because-throw-after-headers": + response-writer.headers.set "Content-Length" "2" + writer.write "x" // Only writes half the message. + throw "** Expect a stack trace here caused by testing: throws-after-headers **" + else if resource == "/test-post-json": + fill-html.call (html-post-json "/post-json") + else if resource == "/post-json": + response-writer.headers.set "Content-Type" "application/json" + while data := request.body.read: + writer.write data + else if resource == "/test-post-form": + fill-html.call (html-post-form "/post-form") + else if resource == "/post-form": + expect-equals "application/x-www-form-urlencoded" (request.headers.single "Content-Type") + response-writer.headers.set "Content-Type" "text/plain" + str := "" + while data := request.body.read: + str += data.to-string + map := {:} + str.split "&": | pair | + parts := pair.split "=" + key := url.decode parts[0] + value := url.decode parts[1] + map[key.to-string] = value.to-string + expect-equals POST-FORM-DATA.size map.size + POST-FORM-DATA.do: | key value | + expect-equals POST-FORM-DATA[key] map[key] + fill-html.call html-form-ok + else if resource == "/test-get-with-parameters": + fill-html.call (html-get-with-parameters "/get-with-parameters") + else if resource == "/get-with-parameters": + response-writer.headers.set "Content-Type" "text/plain" + POST-FORM-DATA.do: | key/string value/string | + expect-equals value request.query.parameters[key] + fill-html.call html-form-ok + else if resource == "/test-websocket": + fill-html.call (html-websocket "/ws") + else if resource == "/ws": + web-socket := server.web-socket request response-writer + // For this test, the server end of the web socket just echoes back + // what it gets. + while data := web-socket.receive: + web-socket.send data + web-socket.close + else: + print "request.query.resource = '$request.query.resource'" + response-writer.write-headers http.STATUS-NOT-FOUND --message="Not Found" diff --git a/tests/webdriver.toit b/tests/webdriver.toit new file mode 100644 index 0000000..72e25f2 --- /dev/null +++ b/tests/webdriver.toit @@ -0,0 +1,148 @@ +// Copyright (C) 2024 Toitware ApS. +// Use of this source code is governed by a Zero-Clause BSD license that can +// be found in the tests/TESTS_LICENSE file. + +import expect show * +import fs +import host.pipe +import http +import encoding.json +import net +import system + +DRIVERS_ ::= { + "chrome": "chromedriver", + "firefox": "geckodriver", + "safari": "safaridriver", + "edge": "msedgedriver", +} + +class WebDriver: + driver-app_/string + child-process_/any := null + session-url_/string? := null + network_/net.Interface? := null + client_/http.Client? := null + + constructor browser/string: + driver := DRIVERS_.get browser + if driver == null: + throw "Unsupported browser: $browser" + driver-app_ = driver + + + start: + program-path := system.program-path + program-dir := fs.dirname program-path + extension := system.platform == system.PLATFORM-WINDOWS ? ".exe" : "" + port/string := pipe.backticks + "python$extension" + "$program-dir/third-party/ephemeral-port-reserve/ephemeral_port_reserve.py" + port = port.trim + command := ["$driver-app_$extension", "--port=$port"] + fork-data := pipe.fork + true // use_path + pipe.PIPE-INHERITED // stdin. + pipe.PIPE-INHERITED // stdout + pipe.PIPE-INHERITED // stderr + command.first + command + child-process_ = fork-data[3] + network_ = net.open + client_ = http.Client network_ + + url := "http://localhost:$port" + + MAX-ATTEMPTS := 20 + for i := 0; i < MAX-ATTEMPTS; i++: + exception := catch --unwind=(: i == MAX-ATTEMPTS - 1): + response := client_.post-json --uri="$url/session" { + "capabilities": { + "alwaysMatch": {:}, + "firstMatch": [ + { + "browserName": "chrome", + "goog:chromeOptions": { + "args": ["--disable-gpu", "--headless"] + } + }, + { + "browserName": "safari" + }, + { + "browserName": "firefox", + "moz:firefoxOptions": { + "args": ["-headless"] + } + }, + { + "browserName": "MicrosoftEdge", + "ms:edgeOptions": { + "args": ["--headless"] + } + }, + ], + } + } + + decoded := json.decode-stream response.body + print "Decoded: $decoded" + session-id := decoded["value"]["sessionId"] + session-url_ = "$url/session/$session-id" + + if not exception: return + // Probably hasn't started yet. Just try again. + sleep --ms=(100 * i) + + close: + pid := child-process_ + if not pid: return + // Delete the session. + // This doesn't shut down the driver, but is good practice. + request := client_.new-request http.DELETE --uri=session-url_ + request.send + // Some drivers have a shutdown endpoint. It doesn't hurt to send it + // to all drivers. + client_.get --uri="$session-url_/shutdown" + client_.close + network_.close + child-process_ = null + if system.platform == system.PLATFORM-WINDOWS: + // On Windows we only have kill 9. + pipe.kill_ pid 9 + else: + pipe.kill_ pid 15 + exception := catch --unwind=(: it != DEADLINE-EXCEEDED-ERROR): + with-timeout --ms=3_000: + pipe.wait-for pid + if exception: + pipe.kill_ pid 9 + + post_ --url/string=session-url_ path/string payload/any -> any: + response := client_.post-json --uri="$url/$path" payload + result := json.decode-stream response.body + response.drain + return result + + get_ path/string -> any: + response := client_.get --uri="$session-url_/$path" + result := json.decode-stream response.body + response.drain + return result + + goto url/string: + post_ "url" { "url": url } + + find css-selector/string -> List: + response := post_ "element" { "using": "css selector", "value": css-selector } + element-json := response["value"] + if element-json.contains "error": return [] + if not element-json: return [] + return element-json.values + + get-text element-id/string -> string: + response := get_ "element/$element-id/text" + return response["value"] + + click element-id/string: + post_ "element/$element-id/click" {:}