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 ::= """ + + +
+