From 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 03:47:29 +0200 Subject: Adding upstream version 115.8.0esr. Signed-off-by: Daniel Baumann --- testing/xpcshell/README | 6 + testing/xpcshell/dbg-actors.js | 48 + testing/xpcshell/dns-packet/.editorconfig | 10 + testing/xpcshell/dns-packet/.eslintrc | 9 + testing/xpcshell/dns-packet/.gitignore | 4 + testing/xpcshell/dns-packet/.travis.yml | 11 + testing/xpcshell/dns-packet/CHANGELOG.md | 30 + testing/xpcshell/dns-packet/LICENSE | 21 + testing/xpcshell/dns-packet/README.md | 365 ++ testing/xpcshell/dns-packet/classes.js | 23 + testing/xpcshell/dns-packet/examples/doh.js | 52 + testing/xpcshell/dns-packet/examples/tcp.js | 52 + testing/xpcshell/dns-packet/examples/tls.js | 61 + testing/xpcshell/dns-packet/examples/udp.js | 28 + testing/xpcshell/dns-packet/index.js | 1841 ++++++++ testing/xpcshell/dns-packet/opcodes.js | 50 + testing/xpcshell/dns-packet/optioncodes.js | 61 + testing/xpcshell/dns-packet/package.json | 48 + testing/xpcshell/dns-packet/rcodes.js | 50 + testing/xpcshell/dns-packet/test.js | 613 +++ testing/xpcshell/dns-packet/types.js | 105 + testing/xpcshell/example/moz.build | 12 + testing/xpcshell/example/unit/check_profile.js | 44 + testing/xpcshell/example/unit/file.txt | 1 + .../xpcshell/example/unit/import_module.sys.mjs | 9 + testing/xpcshell/example/unit/load_subscript.js | 6 + testing/xpcshell/example/unit/location_load.js | 8 + testing/xpcshell/example/unit/prefs_test_common.js | 47 + testing/xpcshell/example/unit/subdir/file.txt | 1 + testing/xpcshell/example/unit/test_add_setup.js | 23 + .../example/unit/test_check_nsIException.js | 10 + .../unit/test_check_nsIException_failing.js | 10 + .../xpcshell/example/unit/test_do_check_matches.js | 14 + .../example/unit/test_do_check_matches_failing.js | 12 + .../xpcshell/example/unit/test_do_check_null.js | 6 + .../example/unit/test_do_check_null_failing.js | 6 + .../xpcshell/example/unit/test_do_get_tempdir.js | 14 + testing/xpcshell/example/unit/test_execute_soon.js | 20 + testing/xpcshell/example/unit/test_fail.js | 8 + testing/xpcshell/example/unit/test_get_file.js | 31 + testing/xpcshell/example/unit/test_get_idle.js | 24 + .../xpcshell/example/unit/test_import_module.js | 19 + testing/xpcshell/example/unit/test_load.js | 23 + .../xpcshell/example/unit/test_load_httpd_js.js | 13 + testing/xpcshell/example/unit/test_location.js | 13 + .../xpcshell/example/unit/test_multiple_setups.js | 13 + .../xpcshell/example/unit/test_multiple_tasks.js | 20 + .../xpcshell/example/unit/test_prefs_defaults.js | 18 + .../example/unit/test_prefs_defaults_and_file.js | 42 + .../example/unit/test_prefs_defaults_included.js | 16 + .../example/unit/test_prefs_no_defaults.js | 15 + .../unit/test_prefs_no_defaults_with_file.js | 15 + testing/xpcshell/example/unit/test_profile.js | 11 + .../example/unit/test_profile_afterChange.js | 11 + testing/xpcshell/example/unit/test_sample.js | 21 + testing/xpcshell/example/unit/test_skip.js | 8 + testing/xpcshell/example/unit/test_tasks_skip.js | 21 + .../xpcshell/example/unit/test_tasks_skipall.js | 23 + .../example/unit/xpcshell-included-with-prefs.ini | 5 + .../xpcshell/example/unit/xpcshell-with-prefs.ini | 16 + testing/xpcshell/example/unit/xpcshell.ini | 60 + testing/xpcshell/head.js | 1890 ++++++++ testing/xpcshell/mach_commands.py | 277 ++ testing/xpcshell/mach_test_package_commands.py | 48 + testing/xpcshell/moz-http2/http2-cert.key | 28 + testing/xpcshell/moz-http2/http2-cert.key.keyspec | 1 + testing/xpcshell/moz-http2/http2-cert.pem | 19 + testing/xpcshell/moz-http2/http2-cert.pem.certspec | 4 + testing/xpcshell/moz-http2/moz-http2-child.js | 33 + testing/xpcshell/moz-http2/moz-http2.js | 1920 +++++++++ testing/xpcshell/moz-http2/proxy-cert.key | 28 + testing/xpcshell/moz-http2/proxy-cert.key.keyspec | 1 + testing/xpcshell/moz-http2/proxy-cert.pem | 19 + testing/xpcshell/moz-http2/proxy-cert.pem.certspec | 4 + testing/xpcshell/moz.build | 15 + testing/xpcshell/node-http2/.gitignore | 7 + testing/xpcshell/node-http2/.travis.yml | 5 + testing/xpcshell/node-http2/HISTORY.md | 264 ++ testing/xpcshell/node-http2/LICENSE | 22 + testing/xpcshell/node-http2/README.md | 173 + testing/xpcshell/node-http2/example/client.js | 48 + testing/xpcshell/node-http2/example/localhost.crt | 14 + testing/xpcshell/node-http2/example/localhost.key | 15 + testing/xpcshell/node-http2/example/server.js | 67 + testing/xpcshell/node-http2/lib/http.js | 1276 ++++++ testing/xpcshell/node-http2/lib/index.js | 52 + .../xpcshell/node-http2/lib/protocol/compressor.js | 1428 +++++++ .../xpcshell/node-http2/lib/protocol/connection.js | 630 +++ .../xpcshell/node-http2/lib/protocol/endpoint.js | 262 ++ testing/xpcshell/node-http2/lib/protocol/flow.js | 345 ++ testing/xpcshell/node-http2/lib/protocol/framer.js | 1166 +++++ testing/xpcshell/node-http2/lib/protocol/index.js | 91 + testing/xpcshell/node-http2/lib/protocol/stream.js | 677 +++ testing/xpcshell/node-http2/package.json | 46 + testing/xpcshell/node-http2/test/compressor.js | 575 +++ testing/xpcshell/node-http2/test/connection.js | 237 + testing/xpcshell/node-http2/test/endpoint.js | 41 + testing/xpcshell/node-http2/test/flow.js | 260 ++ testing/xpcshell/node-http2/test/framer.js | 395 ++ testing/xpcshell/node-http2/test/http.js | 793 ++++ testing/xpcshell/node-http2/test/stream.js | 413 ++ testing/xpcshell/node-http2/test/util.js | 89 + testing/xpcshell/node-ws/.eslintrc.yaml | 19 + testing/xpcshell/node-ws/.gitignore | 4 + testing/xpcshell/node-ws/.npmrc | 1 + testing/xpcshell/node-ws/.prettierrc.yaml | 5 + testing/xpcshell/node-ws/LICENSE | 19 + testing/xpcshell/node-ws/README.md | 495 +++ testing/xpcshell/node-ws/SECURITY.md | 39 + testing/xpcshell/node-ws/bench/parser.benchmark.js | 95 + testing/xpcshell/node-ws/bench/sender.benchmark.js | 48 + testing/xpcshell/node-ws/bench/speed.js | 115 + testing/xpcshell/node-ws/browser.js | 8 + testing/xpcshell/node-ws/doc/ws.md | 669 +++ .../examples/express-session-parse/index.js | 101 + .../examples/express-session-parse/package.json | 11 + .../examples/express-session-parse/public/app.js | 67 + .../express-session-parse/public/index.html | 24 + .../node-ws/examples/server-stats/index.js | 33 + .../node-ws/examples/server-stats/package.json | 9 + .../examples/server-stats/public/index.html | 63 + testing/xpcshell/node-ws/examples/ssl.js | 37 + testing/xpcshell/node-ws/index.js | 13 + testing/xpcshell/node-ws/lib/buffer-util.js | 127 + testing/xpcshell/node-ws/lib/constants.js | 12 + testing/xpcshell/node-ws/lib/event-target.js | 266 ++ testing/xpcshell/node-ws/lib/extension.js | 203 + testing/xpcshell/node-ws/lib/limiter.js | 55 + testing/xpcshell/node-ws/lib/permessage-deflate.js | 511 +++ testing/xpcshell/node-ws/lib/receiver.js | 618 +++ testing/xpcshell/node-ws/lib/sender.js | 478 +++ testing/xpcshell/node-ws/lib/stream.js | 159 + testing/xpcshell/node-ws/lib/subprotocol.js | 62 + testing/xpcshell/node-ws/lib/validation.js | 125 + testing/xpcshell/node-ws/lib/websocket-server.js | 535 +++ testing/xpcshell/node-ws/lib/websocket.js | 1305 ++++++ testing/xpcshell/node-ws/package.json | 61 + testing/xpcshell/node-ws/test/autobahn-server.js | 17 + testing/xpcshell/node-ws/test/autobahn.js | 39 + testing/xpcshell/node-ws/test/buffer-util.test.js | 15 + .../node-ws/test/create-websocket-stream.test.js | 598 +++ testing/xpcshell/node-ws/test/event-target.test.js | 253 ++ testing/xpcshell/node-ws/test/extension.test.js | 190 + .../node-ws/test/fixtures/ca-certificate.pem | 12 + testing/xpcshell/node-ws/test/fixtures/ca-key.pem | 5 + .../xpcshell/node-ws/test/fixtures/certificate.pem | 12 + .../node-ws/test/fixtures/client-certificate.pem | 12 + .../xpcshell/node-ws/test/fixtures/client-key.pem | 5 + testing/xpcshell/node-ws/test/fixtures/key.pem | 5 + testing/xpcshell/node-ws/test/limiter.test.js | 41 + .../node-ws/test/permessage-deflate.test.js | 647 +++ testing/xpcshell/node-ws/test/receiver.test.js | 1086 +++++ testing/xpcshell/node-ws/test/sender.test.js | 370 ++ testing/xpcshell/node-ws/test/subprotocol.test.js | 91 + testing/xpcshell/node-ws/test/validation.test.js | 52 + .../xpcshell/node-ws/test/websocket-server.test.js | 1284 ++++++ .../xpcshell/node-ws/test/websocket.integration.js | 55 + testing/xpcshell/node-ws/test/websocket.test.js | 4514 ++++++++++++++++++++ testing/xpcshell/node-ws/wrapper.mjs | 8 + testing/xpcshell/node_ip/.gitignore | 2 + testing/xpcshell/node_ip/.jscsrc | 46 + testing/xpcshell/node_ip/.jshintrc | 89 + testing/xpcshell/node_ip/.travis.yml | 15 + testing/xpcshell/node_ip/README.md | 90 + testing/xpcshell/node_ip/lib/ip.js | 416 ++ testing/xpcshell/node_ip/package.json | 21 + testing/xpcshell/node_ip/test/api-test.js | 407 ++ testing/xpcshell/remotexpcshelltests.py | 791 ++++ testing/xpcshell/runxpcshelltests.py | 2226 ++++++++++ testing/xpcshell/selftest.py | 1475 +++++++ testing/xpcshell/xpcshellcommandline.py | 420 ++ 171 files changed, 39556 insertions(+) create mode 100644 testing/xpcshell/README create mode 100644 testing/xpcshell/dbg-actors.js create mode 100644 testing/xpcshell/dns-packet/.editorconfig create mode 100644 testing/xpcshell/dns-packet/.eslintrc create mode 100644 testing/xpcshell/dns-packet/.gitignore create mode 100644 testing/xpcshell/dns-packet/.travis.yml create mode 100644 testing/xpcshell/dns-packet/CHANGELOG.md create mode 100644 testing/xpcshell/dns-packet/LICENSE create mode 100644 testing/xpcshell/dns-packet/README.md create mode 100644 testing/xpcshell/dns-packet/classes.js create mode 100644 testing/xpcshell/dns-packet/examples/doh.js create mode 100644 testing/xpcshell/dns-packet/examples/tcp.js create mode 100644 testing/xpcshell/dns-packet/examples/tls.js create mode 100644 testing/xpcshell/dns-packet/examples/udp.js create mode 100644 testing/xpcshell/dns-packet/index.js create mode 100644 testing/xpcshell/dns-packet/opcodes.js create mode 100644 testing/xpcshell/dns-packet/optioncodes.js create mode 100644 testing/xpcshell/dns-packet/package.json create mode 100644 testing/xpcshell/dns-packet/rcodes.js create mode 100644 testing/xpcshell/dns-packet/test.js create mode 100644 testing/xpcshell/dns-packet/types.js create mode 100644 testing/xpcshell/example/moz.build create mode 100644 testing/xpcshell/example/unit/check_profile.js create mode 100644 testing/xpcshell/example/unit/file.txt create mode 100644 testing/xpcshell/example/unit/import_module.sys.mjs create mode 100644 testing/xpcshell/example/unit/load_subscript.js create mode 100644 testing/xpcshell/example/unit/location_load.js create mode 100644 testing/xpcshell/example/unit/prefs_test_common.js create mode 100644 testing/xpcshell/example/unit/subdir/file.txt create mode 100644 testing/xpcshell/example/unit/test_add_setup.js create mode 100644 testing/xpcshell/example/unit/test_check_nsIException.js create mode 100644 testing/xpcshell/example/unit/test_check_nsIException_failing.js create mode 100644 testing/xpcshell/example/unit/test_do_check_matches.js create mode 100644 testing/xpcshell/example/unit/test_do_check_matches_failing.js create mode 100644 testing/xpcshell/example/unit/test_do_check_null.js create mode 100644 testing/xpcshell/example/unit/test_do_check_null_failing.js create mode 100644 testing/xpcshell/example/unit/test_do_get_tempdir.js create mode 100644 testing/xpcshell/example/unit/test_execute_soon.js create mode 100644 testing/xpcshell/example/unit/test_fail.js create mode 100644 testing/xpcshell/example/unit/test_get_file.js create mode 100644 testing/xpcshell/example/unit/test_get_idle.js create mode 100644 testing/xpcshell/example/unit/test_import_module.js create mode 100644 testing/xpcshell/example/unit/test_load.js create mode 100644 testing/xpcshell/example/unit/test_load_httpd_js.js create mode 100644 testing/xpcshell/example/unit/test_location.js create mode 100644 testing/xpcshell/example/unit/test_multiple_setups.js create mode 100644 testing/xpcshell/example/unit/test_multiple_tasks.js create mode 100644 testing/xpcshell/example/unit/test_prefs_defaults.js create mode 100644 testing/xpcshell/example/unit/test_prefs_defaults_and_file.js create mode 100644 testing/xpcshell/example/unit/test_prefs_defaults_included.js create mode 100644 testing/xpcshell/example/unit/test_prefs_no_defaults.js create mode 100644 testing/xpcshell/example/unit/test_prefs_no_defaults_with_file.js create mode 100644 testing/xpcshell/example/unit/test_profile.js create mode 100644 testing/xpcshell/example/unit/test_profile_afterChange.js create mode 100644 testing/xpcshell/example/unit/test_sample.js create mode 100644 testing/xpcshell/example/unit/test_skip.js create mode 100644 testing/xpcshell/example/unit/test_tasks_skip.js create mode 100644 testing/xpcshell/example/unit/test_tasks_skipall.js create mode 100644 testing/xpcshell/example/unit/xpcshell-included-with-prefs.ini create mode 100644 testing/xpcshell/example/unit/xpcshell-with-prefs.ini create mode 100644 testing/xpcshell/example/unit/xpcshell.ini create mode 100644 testing/xpcshell/head.js create mode 100644 testing/xpcshell/mach_commands.py create mode 100644 testing/xpcshell/mach_test_package_commands.py create mode 100644 testing/xpcshell/moz-http2/http2-cert.key create mode 100644 testing/xpcshell/moz-http2/http2-cert.key.keyspec create mode 100644 testing/xpcshell/moz-http2/http2-cert.pem create mode 100644 testing/xpcshell/moz-http2/http2-cert.pem.certspec create mode 100644 testing/xpcshell/moz-http2/moz-http2-child.js create mode 100644 testing/xpcshell/moz-http2/moz-http2.js create mode 100644 testing/xpcshell/moz-http2/proxy-cert.key create mode 100644 testing/xpcshell/moz-http2/proxy-cert.key.keyspec create mode 100644 testing/xpcshell/moz-http2/proxy-cert.pem create mode 100644 testing/xpcshell/moz-http2/proxy-cert.pem.certspec create mode 100644 testing/xpcshell/moz.build create mode 100644 testing/xpcshell/node-http2/.gitignore create mode 100644 testing/xpcshell/node-http2/.travis.yml create mode 100644 testing/xpcshell/node-http2/HISTORY.md create mode 100644 testing/xpcshell/node-http2/LICENSE create mode 100644 testing/xpcshell/node-http2/README.md create mode 100644 testing/xpcshell/node-http2/example/client.js create mode 100644 testing/xpcshell/node-http2/example/localhost.crt create mode 100644 testing/xpcshell/node-http2/example/localhost.key create mode 100644 testing/xpcshell/node-http2/example/server.js create mode 100644 testing/xpcshell/node-http2/lib/http.js create mode 100644 testing/xpcshell/node-http2/lib/index.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/compressor.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/connection.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/endpoint.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/flow.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/framer.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/index.js create mode 100644 testing/xpcshell/node-http2/lib/protocol/stream.js create mode 100644 testing/xpcshell/node-http2/package.json create mode 100644 testing/xpcshell/node-http2/test/compressor.js create mode 100644 testing/xpcshell/node-http2/test/connection.js create mode 100644 testing/xpcshell/node-http2/test/endpoint.js create mode 100644 testing/xpcshell/node-http2/test/flow.js create mode 100644 testing/xpcshell/node-http2/test/framer.js create mode 100644 testing/xpcshell/node-http2/test/http.js create mode 100644 testing/xpcshell/node-http2/test/stream.js create mode 100644 testing/xpcshell/node-http2/test/util.js create mode 100644 testing/xpcshell/node-ws/.eslintrc.yaml create mode 100644 testing/xpcshell/node-ws/.gitignore create mode 100644 testing/xpcshell/node-ws/.npmrc create mode 100644 testing/xpcshell/node-ws/.prettierrc.yaml create mode 100644 testing/xpcshell/node-ws/LICENSE create mode 100644 testing/xpcshell/node-ws/README.md create mode 100644 testing/xpcshell/node-ws/SECURITY.md create mode 100644 testing/xpcshell/node-ws/bench/parser.benchmark.js create mode 100644 testing/xpcshell/node-ws/bench/sender.benchmark.js create mode 100644 testing/xpcshell/node-ws/bench/speed.js create mode 100644 testing/xpcshell/node-ws/browser.js create mode 100644 testing/xpcshell/node-ws/doc/ws.md create mode 100644 testing/xpcshell/node-ws/examples/express-session-parse/index.js create mode 100644 testing/xpcshell/node-ws/examples/express-session-parse/package.json create mode 100644 testing/xpcshell/node-ws/examples/express-session-parse/public/app.js create mode 100644 testing/xpcshell/node-ws/examples/express-session-parse/public/index.html create mode 100644 testing/xpcshell/node-ws/examples/server-stats/index.js create mode 100644 testing/xpcshell/node-ws/examples/server-stats/package.json create mode 100644 testing/xpcshell/node-ws/examples/server-stats/public/index.html create mode 100644 testing/xpcshell/node-ws/examples/ssl.js create mode 100644 testing/xpcshell/node-ws/index.js create mode 100644 testing/xpcshell/node-ws/lib/buffer-util.js create mode 100644 testing/xpcshell/node-ws/lib/constants.js create mode 100644 testing/xpcshell/node-ws/lib/event-target.js create mode 100644 testing/xpcshell/node-ws/lib/extension.js create mode 100644 testing/xpcshell/node-ws/lib/limiter.js create mode 100644 testing/xpcshell/node-ws/lib/permessage-deflate.js create mode 100644 testing/xpcshell/node-ws/lib/receiver.js create mode 100644 testing/xpcshell/node-ws/lib/sender.js create mode 100644 testing/xpcshell/node-ws/lib/stream.js create mode 100644 testing/xpcshell/node-ws/lib/subprotocol.js create mode 100644 testing/xpcshell/node-ws/lib/validation.js create mode 100644 testing/xpcshell/node-ws/lib/websocket-server.js create mode 100644 testing/xpcshell/node-ws/lib/websocket.js create mode 100644 testing/xpcshell/node-ws/package.json create mode 100644 testing/xpcshell/node-ws/test/autobahn-server.js create mode 100644 testing/xpcshell/node-ws/test/autobahn.js create mode 100644 testing/xpcshell/node-ws/test/buffer-util.test.js create mode 100644 testing/xpcshell/node-ws/test/create-websocket-stream.test.js create mode 100644 testing/xpcshell/node-ws/test/event-target.test.js create mode 100644 testing/xpcshell/node-ws/test/extension.test.js create mode 100644 testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem create mode 100644 testing/xpcshell/node-ws/test/fixtures/ca-key.pem create mode 100644 testing/xpcshell/node-ws/test/fixtures/certificate.pem create mode 100644 testing/xpcshell/node-ws/test/fixtures/client-certificate.pem create mode 100644 testing/xpcshell/node-ws/test/fixtures/client-key.pem create mode 100644 testing/xpcshell/node-ws/test/fixtures/key.pem create mode 100644 testing/xpcshell/node-ws/test/limiter.test.js create mode 100644 testing/xpcshell/node-ws/test/permessage-deflate.test.js create mode 100644 testing/xpcshell/node-ws/test/receiver.test.js create mode 100644 testing/xpcshell/node-ws/test/sender.test.js create mode 100644 testing/xpcshell/node-ws/test/subprotocol.test.js create mode 100644 testing/xpcshell/node-ws/test/validation.test.js create mode 100644 testing/xpcshell/node-ws/test/websocket-server.test.js create mode 100644 testing/xpcshell/node-ws/test/websocket.integration.js create mode 100644 testing/xpcshell/node-ws/test/websocket.test.js create mode 100644 testing/xpcshell/node-ws/wrapper.mjs create mode 100644 testing/xpcshell/node_ip/.gitignore create mode 100644 testing/xpcshell/node_ip/.jscsrc create mode 100644 testing/xpcshell/node_ip/.jshintrc create mode 100644 testing/xpcshell/node_ip/.travis.yml create mode 100644 testing/xpcshell/node_ip/README.md create mode 100644 testing/xpcshell/node_ip/lib/ip.js create mode 100644 testing/xpcshell/node_ip/package.json create mode 100644 testing/xpcshell/node_ip/test/api-test.js create mode 100644 testing/xpcshell/remotexpcshelltests.py create mode 100755 testing/xpcshell/runxpcshelltests.py create mode 100755 testing/xpcshell/selftest.py create mode 100644 testing/xpcshell/xpcshellcommandline.py (limited to 'testing/xpcshell') diff --git a/testing/xpcshell/README b/testing/xpcshell/README new file mode 100644 index 0000000000..28de62e607 --- /dev/null +++ b/testing/xpcshell/README @@ -0,0 +1,6 @@ +Simple xpcshell-based test harness + +converted from netwerk/test/unit + +Some documentation at https://developer.mozilla.org/en-US/docs/Mozilla/QA/Writing_xpcshell-based_unit_tests +See also http://wiki.mozilla.org/SoftwareTesting:Tools:Simple_xpcshell_test_harness diff --git a/testing/xpcshell/dbg-actors.js b/testing/xpcshell/dbg-actors.js new file mode 100644 index 0000000000..f9a44f1295 --- /dev/null +++ b/testing/xpcshell/dbg-actors.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals require, exports, Services */ + +"use strict"; + +const { DevToolsServer } = require("devtools/server/devtools-server"); +const { RootActor } = require("devtools/server/actors/root"); +const { BrowserTabList } = require("devtools/server/actors/webbrowser"); +const { ProcessActorList } = require("devtools/server/actors/process"); +const { + ActorRegistry, +} = require("devtools/server/actors/utils/actor-registry"); + +/** + * xpcshell-test (XPCST) specific actors. + * + */ + +/** + * Construct a root actor appropriate for use in a server running xpcshell + * tests. :) + */ +function createRootActor(connection) { + let parameters = { + tabList: new XPCSTTabList(connection), + processList: new ProcessActorList(), + globalActorFactories: ActorRegistry.globalActorFactories, + onShutdown() { + // If the user never switches to the "debugger" tab we might get a + // shutdown before we've attached. + Services.obs.notifyObservers(null, "xpcshell-test-devtools-shutdown"); + }, + }; + return new RootActor(connection, parameters); +} +exports.createRootActor = createRootActor; + +/** + * A "stub" TabList implementation that provides no tabs. + */ +class XPCSTTabList extends BrowserTabList { + getList() { + return Promise.resolve([]); + } +} diff --git a/testing/xpcshell/dns-packet/.editorconfig b/testing/xpcshell/dns-packet/.editorconfig new file mode 100644 index 0000000000..aaaa7a4baa --- /dev/null +++ b/testing/xpcshell/dns-packet/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +tab_width = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/testing/xpcshell/dns-packet/.eslintrc b/testing/xpcshell/dns-packet/.eslintrc new file mode 100644 index 0000000000..d3ed05cf86 --- /dev/null +++ b/testing/xpcshell/dns-packet/.eslintrc @@ -0,0 +1,9 @@ +root: true + +parserOptions: + ecmaVersion: 2015 + +env: + node: true + +extends: standard diff --git a/testing/xpcshell/dns-packet/.gitignore b/testing/xpcshell/dns-packet/.gitignore new file mode 100644 index 0000000000..cea4849cd9 --- /dev/null +++ b/testing/xpcshell/dns-packet/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.nyc_output/ +coverage/ +package-lock.json diff --git a/testing/xpcshell/dns-packet/.travis.yml b/testing/xpcshell/dns-packet/.travis.yml new file mode 100644 index 0000000000..e0211604d3 --- /dev/null +++ b/testing/xpcshell/dns-packet/.travis.yml @@ -0,0 +1,11 @@ +language: node_js +node_js: + - node + - lts/* +install: +- npm install +- npm install coveralls +script: +- npm run coverage +after_success: +- npx nyc report --reporter=text-lcov | npx coveralls diff --git a/testing/xpcshell/dns-packet/CHANGELOG.md b/testing/xpcshell/dns-packet/CHANGELOG.md new file mode 100644 index 0000000000..6b714e04c9 --- /dev/null +++ b/testing/xpcshell/dns-packet/CHANGELOG.md @@ -0,0 +1,30 @@ +# Version 5.2.0 - 2019-02-21 + +- Feature: Added support for de/encoding certain OPT options. + +# Version 5.1.0 - 2019-01-22 + +- Feature: Added support for the RP record type. + +# Version 5.0.0 - 2018-06-01 + +- Breaking: Node.js 6.0.0 or greater is now required. +- Feature: Added support for DNSSEC record types. + +# Version 4.1.0 - 2018-02-11 + +- Feature: Added support for the MX record type. + +# Version 4.0.0 - 2018-02-04 + +- Feature: Added `streamEncode` and `streamDecode` methods for encoding TCP packets. +- Breaking: Changed the decoded value of TXT records to an array of Buffers. This is to accomodate DNS-SD records which rely on the individual strings record being separated. +- Breaking: Renamed the `flag_trunc` and `flag_auth` to `flag_tc` and `flag_aa` to match the names of these in the dns standards. + +# Version 3.0.0 - 2018-01-12 + +- Breaking: The `class` option has been changed from integer to string. + +# Version 2.0.0 - 2018-01-11 + +- Breaking: Converted module to ES2015, now requires Node.js 4.0 or greater diff --git a/testing/xpcshell/dns-packet/LICENSE b/testing/xpcshell/dns-packet/LICENSE new file mode 100644 index 0000000000..bae9da7bfa --- /dev/null +++ b/testing/xpcshell/dns-packet/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Mathias Buus + +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/testing/xpcshell/dns-packet/README.md b/testing/xpcshell/dns-packet/README.md new file mode 100644 index 0000000000..2a729b3d10 --- /dev/null +++ b/testing/xpcshell/dns-packet/README.md @@ -0,0 +1,365 @@ +# dns-packet +[![](https://img.shields.io/npm/v/dns-packet.svg?style=flat)](https://www.npmjs.org/package/dns-packet) [![](https://img.shields.io/npm/dm/dns-packet.svg)](https://www.npmjs.org/package/dns-packet) [![](https://api.travis-ci.org/mafintosh/dns-packet.svg?style=flat)](https://travis-ci.org/mafintosh/dns-packet) [![Coverage Status](https://coveralls.io/repos/github/mafintosh/dns-packet/badge.svg?branch=master)](https://coveralls.io/github/mafintosh/dns-packet?branch=master) + +An [abstract-encoding](https://github.com/mafintosh/abstract-encoding) compliant module for encoding / decoding DNS packets. Lifted out of [multicast-dns](https://github.com/mafintosh/multicast-dns) as a separate module. + +``` +npm install dns-packet +``` + +## UDP Usage + +``` js +const dnsPacket = require('dns-packet') +const dgram = require('dgram') + +const socket = dgram.createSocket('udp4') + +const buf = dnsPacket.encode({ + type: 'query', + id: 1, + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: 'A', + name: 'google.com' + }] +}) + +socket.on('message', message => { + console.log(dnsPacket.decode(message)) // prints out a response from google dns +}) + +socket.send(buf, 0, buf.length, 53, '8.8.8.8') +``` + +Also see [the UDP example](examples/udp.js). + +## TCP, TLS, HTTPS + +While DNS has traditionally been used over a datagram transport, it is increasingly being carried over TCP for larger responses commonly including DNSSEC responses and TLS or HTTPS for enhanced security. See below examples on how to use `dns-packet` to wrap DNS packets in these protocols: + +- [TCP](examples/tcp.js) +- [DNS over TLS](examples/tls.js) +- [DNS over HTTPS](examples/doh.js) + +## API + +#### `var buf = packets.encode(packet, [buf], [offset])` + +Encodes a DNS packet into a buffer containing a UDP payload. + +#### `var packet = packets.decode(buf, [offset])` + +Decode a DNS packet from a buffer containing a UDP payload. + +#### `var buf = packets.streamEncode(packet, [buf], [offset])` + +Encodes a DNS packet into a buffer containing a TCP payload. + +#### `var packet = packets.streamDecode(buf, [offset])` + +Decode a DNS packet from a buffer containing a TCP payload. + +#### `var len = packets.encodingLength(packet)` + +Returns how many bytes are needed to encode the DNS packet + +## Packets + +Packets look like this + +``` js +{ + type: 'query|response', + id: optionalIdNumber, + flags: optionalBitFlags, + questions: [...], + answers: [...], + additionals: [...], + authorities: [...] +} +``` + +The bit flags available are + +``` js +packet.RECURSION_DESIRED +packet.RECURSION_AVAILABLE +packet.TRUNCATED_RESPONSE +packet.AUTHORITATIVE_ANSWER +packet.AUTHENTIC_DATA +packet.CHECKING_DISABLED +``` + +To use more than one flag bitwise-or them together + +``` js +var flags = packet.RECURSION_DESIRED | packet.RECURSION_AVAILABLE +``` + +And to check for a flag use bitwise-and + +``` js +var isRecursive = message.flags & packet.RECURSION_DESIRED +``` + +A question looks like this + +``` js +{ + type: 'A', // or SRV, AAAA, etc + class: 'IN', // one of IN, CS, CH, HS, ANY. Default: IN + name: 'google.com' // which record are you looking for +} +``` + +And an answer, additional, or authority looks like this + +``` js +{ + type: 'A', // or SRV, AAAA, etc + class: 'IN', // one of IN, CS, CH, HS + name: 'google.com', // which name is this record for + ttl: optionalTimeToLiveInSeconds, + (record specific data, see below) +} +``` + +## Supported record types + +#### `A` + +``` js +{ + data: 'IPv4 address' // fx 127.0.0.1 +} +``` + +#### `AAAA` + +``` js +{ + data: 'IPv6 address' // fx fe80::1 +} +``` + +#### `CAA` + +``` js +{ + flags: 128, // octet + tag: 'issue|issuewild|iodef', + value: 'ca.example.net', + issuerCritical: false +} +``` + +#### `CNAME` + +``` js +{ + data: 'cname.to.another.record' +} +``` + +#### `DNAME` + +``` js +{ + data: 'dname.to.another.record' +} +``` + +#### `DNSKEY` + +``` js +{ + flags: 257, // 16 bits + algorithm: 1, // octet + key: Buffer +} +``` + +#### `DS` + +``` js +{ + keyTag: 12345, + algorithm: 8, + digestType: 1, + digest: Buffer +} +``` + +#### `HINFO` + +``` js +{ + data: { + cpu: 'cpu info', + os: 'os info' + } +} +``` + +#### `MX` + +``` js +{ + preference: 10, + exchange: 'mail.example.net' +} +``` + +#### `NS` + +``` js +{ + data: nameServer +} +``` + +#### `NSEC` + +``` js +{ + nextDomain: 'a.domain', + rrtypes: ['A', 'TXT', 'RRSIG'] +} +``` + +#### `NSEC3` + +``` js +{ + algorithm: 1, + flags: 0, + iterations: 2, + salt: Buffer, + nextDomain: Buffer, // Hashed per RFC5155 + rrtypes: ['A', 'TXT', 'RRSIG'] +} +``` + +#### `NULL` + +``` js +{ + data: Buffer('any binary data') +} +``` + +#### `OPT` + +[EDNS0](https://tools.ietf.org/html/rfc6891) options. + +``` js +{ + type: 'OPT', + name: '.', + udpPayloadSize: 4096, + flags: packet.DNSSEC_OK, + options: [{ + // pass in any code/data for generic EDNS0 options + code: 12, + data: Buffer.alloc(31) + }, { + // Several EDNS0 options have enhanced support + code: 'PADDING', + length: 31, + }, { + code: 'CLIENT_SUBNET', + family: 2, // 1 for IPv4, 2 for IPv6 + sourcePrefixLength: 64, // used to truncate IP address + scopePrefixLength: 0, + ip: 'fe80::', + }, { + code: 'TCP_KEEPALIVE', + timeout: 150 // increments of 100ms. This means 15s. + }, { + code: 'KEY_TAG', + tags: [1, 2, 3], + }] +} +``` + +The options `PADDING`, `CLIENT_SUBNET`, `TCP_KEEPALIVE` and `KEY_TAG` support enhanced de/encoding. See [optionscodes.js](https://github.com/mafintosh/dns-packet/blob/master/optioncodes.js) for all supported option codes. If the `data` property is present on a option, it takes precedence. On decoding, `data` will always be defined. + +#### `PTR` + +``` js +{ + data: 'points.to.another.record' +} +``` + +#### `RP` + +``` js +{ + mbox: 'admin.example.com', + txt: 'txt.example.com' +} +``` + +#### `RRSIG` + +``` js +{ + typeCovered: 'A', + algorithm: 8, + labels: 1, + originalTTL: 3600, + expiration: timestamp, + inception: timestamp, + keyTag: 12345, + signersName: 'a.name', + signature: Buffer +} +``` + +#### `SOA` + +``` js +{ + data: + { + mname: domainName, + rname: mailbox, + serial: zoneSerial, + refresh: refreshInterval, + retry: retryInterval, + expire: expireInterval, + minimum: minimumTTL + } +} +``` + +#### `SRV` + +``` js +{ + data: { + port: servicePort, + target: serviceHostName, + priority: optionalServicePriority, + weight: optionalServiceWeight + } +} +``` + +#### `TXT` + +``` js +{ + data: 'text' || Buffer || [ Buffer || 'text' ] +} +``` + +When encoding, scalar values are converted to an array and strings are converted to UTF-8 encoded Buffers. When decoding, the return value will always be an array of Buffer. + +If you need another record type, open an issue and we'll try to add it. + +## License + +MIT diff --git a/testing/xpcshell/dns-packet/classes.js b/testing/xpcshell/dns-packet/classes.js new file mode 100644 index 0000000000..9a3d9b1e8c --- /dev/null +++ b/testing/xpcshell/dns-packet/classes.js @@ -0,0 +1,23 @@ +'use strict' + +exports.toString = function (klass) { + switch (klass) { + case 1: return 'IN' + case 2: return 'CS' + case 3: return 'CH' + case 4: return 'HS' + case 255: return 'ANY' + } + return 'UNKNOWN_' + klass +} + +exports.toClass = function (name) { + switch (name.toUpperCase()) { + case 'IN': return 1 + case 'CS': return 2 + case 'CH': return 3 + case 'HS': return 4 + case 'ANY': return 255 + } + return 0 +} diff --git a/testing/xpcshell/dns-packet/examples/doh.js b/testing/xpcshell/dns-packet/examples/doh.js new file mode 100644 index 0000000000..37ef19fc35 --- /dev/null +++ b/testing/xpcshell/dns-packet/examples/doh.js @@ -0,0 +1,52 @@ + +'use strict' + +/* + * Sample code to make DNS over HTTPS request using POST + * AUTHOR: Tom Pusateri + * DATE: March 17, 2018 + * LICENSE: MIT + */ + +const dnsPacket = require('..') +const https = require('https') + +function getRandomInt (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const buf = dnsPacket.encode({ + type: 'query', + id: getRandomInt(1, 65534), + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: 'A', + name: 'google.com' + }] +}) + +const options = { + hostname: 'dns.google.com', + port: 443, + path: '/experimental', + method: 'POST', + headers: { + 'Content-Type': 'application/dns-udpwireformat', + 'Content-Length': Buffer.byteLength(buf) + } +} + +const request = https.request(options, (response) => { + console.log('statusCode:', response.statusCode) + console.log('headers:', response.headers) + + response.on('data', (d) => { + console.log(dnsPacket.decode(d)) + }) +}) + +request.on('error', (e) => { + console.error(e) +}) +request.write(buf) +request.end() diff --git a/testing/xpcshell/dns-packet/examples/tcp.js b/testing/xpcshell/dns-packet/examples/tcp.js new file mode 100644 index 0000000000..b25c2c41cb --- /dev/null +++ b/testing/xpcshell/dns-packet/examples/tcp.js @@ -0,0 +1,52 @@ +'use strict' + +const dnsPacket = require('..') +const net = require('net') + +var response = null +var expectedLength = 0 + +function getRandomInt (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const buf = dnsPacket.streamEncode({ + type: 'query', + id: getRandomInt(1, 65534), + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: 'A', + name: 'google.com' + }] +}) + +const client = new net.Socket() +client.connect(53, '8.8.8.8', function () { + console.log('Connected') + client.write(buf) +}) + +client.on('data', function (data) { + console.log('Received response: %d bytes', data.byteLength) + if (response == null) { + if (data.byteLength > 1) { + const plen = data.readUInt16BE(0) + expectedLength = plen + if (plen < 12) { + throw new Error('below DNS minimum packet length') + } + response = Buffer.from(data) + } + } else { + response = Buffer.concat([response, data]) + } + + if (response.byteLength >= expectedLength) { + console.log(dnsPacket.streamDecode(response)) + client.destroy() + } +}) + +client.on('close', function () { + console.log('Connection closed') +}) diff --git a/testing/xpcshell/dns-packet/examples/tls.js b/testing/xpcshell/dns-packet/examples/tls.js new file mode 100644 index 0000000000..694a4fecfa --- /dev/null +++ b/testing/xpcshell/dns-packet/examples/tls.js @@ -0,0 +1,61 @@ +'use strict' + +const tls = require('tls') +const dnsPacket = require('..') + +var response = null +var expectedLength = 0 + +function getRandomInt (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const buf = dnsPacket.streamEncode({ + type: 'query', + id: getRandomInt(1, 65534), + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: 'A', + name: 'google.com' + }] +}) + +const context = tls.createSecureContext({ + secureProtocol: 'TLSv1_2_method' +}) + +const options = { + port: 853, + host: 'getdnsapi.net', + secureContext: context +} + +const client = tls.connect(options, () => { + console.log('client connected') + client.write(buf) +}) + +client.on('data', function (data) { + console.log('Received response: %d bytes', data.byteLength) + if (response == null) { + if (data.byteLength > 1) { + const plen = data.readUInt16BE(0) + expectedLength = plen + if (plen < 12) { + throw new Error('below DNS minimum packet length') + } + response = Buffer.from(data) + } + } else { + response = Buffer.concat([response, data]) + } + + if (response.byteLength >= expectedLength) { + console.log(dnsPacket.streamDecode(response)) + client.destroy() + } +}) + +client.on('end', () => { + console.log('Connection ended') +}) diff --git a/testing/xpcshell/dns-packet/examples/udp.js b/testing/xpcshell/dns-packet/examples/udp.js new file mode 100644 index 0000000000..0f9df9d794 --- /dev/null +++ b/testing/xpcshell/dns-packet/examples/udp.js @@ -0,0 +1,28 @@ +'use strict' + +const dnsPacket = require('..') +const dgram = require('dgram') + +const socket = dgram.createSocket('udp4') + +function getRandomInt (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const buf = dnsPacket.encode({ + type: 'query', + id: getRandomInt(1, 65534), + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ + type: 'A', + name: 'google.com' + }] +}) + +socket.on('message', function (message, rinfo) { + console.log(rinfo) + console.log(dnsPacket.decode(message)) // prints out a response from google dns + socket.close() +}) + +socket.send(buf, 0, buf.length, 53, '8.8.8.8') diff --git a/testing/xpcshell/dns-packet/index.js b/testing/xpcshell/dns-packet/index.js new file mode 100644 index 0000000000..f1b7352731 --- /dev/null +++ b/testing/xpcshell/dns-packet/index.js @@ -0,0 +1,1841 @@ +'use strict' + +const types = require('./types') +const rcodes = require('./rcodes') +exports.rcodes = rcodes; +const opcodes = require('./opcodes') +const classes = require('./classes') +const optioncodes = require('./optioncodes') +const ip = require('../node_ip') + +const QUERY_FLAG = 0 +const RESPONSE_FLAG = 1 << 15 +const FLUSH_MASK = 1 << 15 +const NOT_FLUSH_MASK = ~FLUSH_MASK +const QU_MASK = 1 << 15 +const NOT_QU_MASK = ~QU_MASK + +const name = exports.txt = exports.name = {} + +name.encode = function (str, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(name.encodingLength(str)) + if (!offset) offset = 0 + const oldOffset = offset + + // strip leading and trailing . + const n = str.replace(/^\.|\.$/gm, '') + if (n.length) { + const list = n.split('.') + + for (let i = 0; i < list.length; i++) { + const len = buf.write(list[i], offset + 1) + buf[offset] = len + offset += len + 1 + } + } + + buf[offset++] = 0 + + name.encode.bytes = offset - oldOffset + return buf +} + +name.encode.bytes = 0 + +name.decode = function (buf, offset) { + if (!offset) offset = 0 + + const list = [] + const oldOffset = offset + let len = buf[offset++] + + if (len === 0) { + name.decode.bytes = 1 + return '.' + } + if (len >= 0xc0) { + const res = name.decode(buf, buf.readUInt16BE(offset - 1) - 0xc000) + name.decode.bytes = 2 + return res + } + + while (len) { + if (len >= 0xc0) { + list.push(name.decode(buf, buf.readUInt16BE(offset - 1) - 0xc000)) + offset++ + break + } + + list.push(buf.toString('utf-8', offset, offset + len)) + offset += len + len = buf[offset++] + } + + name.decode.bytes = offset - oldOffset + return list.join('.') +} + +name.decode.bytes = 0 + +name.encodingLength = function (n) { + if (n === '.') return 1 + return Buffer.byteLength(n) + 2 +} + +const string = {} + +string.encode = function (s, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(string.encodingLength(s)) + if (!offset) offset = 0 + + const len = buf.write(s, offset + 1) + buf[offset] = len + string.encode.bytes = len + 1 + return buf +} + +string.encode.bytes = 0 + +string.decode = function (buf, offset) { + if (!offset) offset = 0 + + const len = buf[offset] + const s = buf.toString('utf-8', offset + 1, offset + 1 + len) + string.decode.bytes = len + 1 + return s +} + +string.decode.bytes = 0 + +string.encodingLength = function (s) { + return Buffer.byteLength(s) + 1 +} + +const header = {} + +header.encode = function (h, buf, offset) { + if (!buf) buf = header.encodingLength(h) + if (!offset) offset = 0 + + const flags = (h.flags || 0) & 32767 + const type = h.type === 'response' ? RESPONSE_FLAG : QUERY_FLAG + + buf.writeUInt16BE(h.id || 0, offset) + buf.writeUInt16BE(flags | type, offset + 2) + buf.writeUInt16BE(h.questions.length, offset + 4) + buf.writeUInt16BE(h.answers.length, offset + 6) + buf.writeUInt16BE(h.authorities.length, offset + 8) + buf.writeUInt16BE(h.additionals.length, offset + 10) + + return buf +} + +header.encode.bytes = 12 + +header.decode = function (buf, offset) { + if (!offset) offset = 0 + if (buf.length < 12) throw new Error('Header must be 12 bytes') + const flags = buf.readUInt16BE(offset + 2) + + return { + id: buf.readUInt16BE(offset), + type: flags & RESPONSE_FLAG ? 'response' : 'query', + flags: flags & 32767, + flag_qr: ((flags >> 15) & 0x1) === 1, + opcode: opcodes.toString((flags >> 11) & 0xf), + flag_aa: ((flags >> 10) & 0x1) === 1, + flag_tc: ((flags >> 9) & 0x1) === 1, + flag_rd: ((flags >> 8) & 0x1) === 1, + flag_ra: ((flags >> 7) & 0x1) === 1, + flag_z: ((flags >> 6) & 0x1) === 1, + flag_ad: ((flags >> 5) & 0x1) === 1, + flag_cd: ((flags >> 4) & 0x1) === 1, + rcode: rcodes.toString(flags & 0xf), + questions: new Array(buf.readUInt16BE(offset + 4)), + answers: new Array(buf.readUInt16BE(offset + 6)), + authorities: new Array(buf.readUInt16BE(offset + 8)), + additionals: new Array(buf.readUInt16BE(offset + 10)) + } +} + +header.decode.bytes = 12 + +header.encodingLength = function () { + return 12 +} + +const runknown = exports.unknown = {} + +runknown.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(runknown.encodingLength(data)) + if (!offset) offset = 0 + + buf.writeUInt16BE(data.length, offset) + data.copy(buf, offset + 2) + + runknown.encode.bytes = data.length + 2 + return buf +} + +runknown.encode.bytes = 0 + +runknown.decode = function (buf, offset) { + if (!offset) offset = 0 + + const len = buf.readUInt16BE(offset) + const data = buf.slice(offset + 2, offset + 2 + len) + runknown.decode.bytes = len + 2 + return data +} + +runknown.decode.bytes = 0 + +runknown.encodingLength = function (data) { + return data.length + 2 +} + +const rns = exports.ns = {} + +rns.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rns.encodingLength(data)) + if (!offset) offset = 0 + + name.encode(data, buf, offset + 2) + buf.writeUInt16BE(name.encode.bytes, offset) + rns.encode.bytes = name.encode.bytes + 2 + return buf +} + +rns.encode.bytes = 0 + +rns.decode = function (buf, offset) { + if (!offset) offset = 0 + + const len = buf.readUInt16BE(offset) + const dd = name.decode(buf, offset + 2) + + rns.decode.bytes = len + 2 + return dd +} + +rns.decode.bytes = 0 + +rns.encodingLength = function (data) { + return name.encodingLength(data) + 2 +} + +const rsoa = exports.soa = {} + +rsoa.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rsoa.encodingLength(data)) + if (!offset) offset = 0 + + const oldOffset = offset + offset += 2 + name.encode(data.mname, buf, offset) + offset += name.encode.bytes + name.encode(data.rname, buf, offset) + offset += name.encode.bytes + buf.writeUInt32BE(data.serial || 0, offset) + offset += 4 + buf.writeUInt32BE(data.refresh || 0, offset) + offset += 4 + buf.writeUInt32BE(data.retry || 0, offset) + offset += 4 + buf.writeUInt32BE(data.expire || 0, offset) + offset += 4 + buf.writeUInt32BE(data.minimum || 0, offset) + offset += 4 + + buf.writeUInt16BE(offset - oldOffset - 2, oldOffset) + rsoa.encode.bytes = offset - oldOffset + return buf +} + +rsoa.encode.bytes = 0 + +rsoa.decode = function (buf, offset) { + if (!offset) offset = 0 + + const oldOffset = offset + + const data = {} + offset += 2 + data.mname = name.decode(buf, offset) + offset += name.decode.bytes + data.rname = name.decode(buf, offset) + offset += name.decode.bytes + data.serial = buf.readUInt32BE(offset) + offset += 4 + data.refresh = buf.readUInt32BE(offset) + offset += 4 + data.retry = buf.readUInt32BE(offset) + offset += 4 + data.expire = buf.readUInt32BE(offset) + offset += 4 + data.minimum = buf.readUInt32BE(offset) + offset += 4 + + rsoa.decode.bytes = offset - oldOffset + return data +} + +rsoa.decode.bytes = 0 + +rsoa.encodingLength = function (data) { + return 22 + name.encodingLength(data.mname) + name.encodingLength(data.rname) +} + +const rtxt = exports.txt = {} + +rtxt.encode = function (data, buf, offset) { + if (!Array.isArray(data)) data = [data] + for (let i = 0; i < data.length; i++) { + if (typeof data[i] === 'string') { + data[i] = Buffer.from(data[i]) + } + if (!Buffer.isBuffer(data[i])) { + throw new Error('Must be a Buffer') + } + } + + if (!buf) buf = Buffer.allocUnsafe(rtxt.encodingLength(data)) + if (!offset) offset = 0 + + const oldOffset = offset + offset += 2 + + data.forEach(function (d) { + buf[offset++] = d.length + d.copy(buf, offset, 0, d.length) + offset += d.length + }) + + buf.writeUInt16BE(offset - oldOffset - 2, oldOffset) + rtxt.encode.bytes = offset - oldOffset + return buf +} + +rtxt.encode.bytes = 0 + +rtxt.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + let remaining = buf.readUInt16BE(offset) + offset += 2 + + let data = [] + while (remaining > 0) { + const len = buf[offset++] + --remaining + if (remaining < len) { + throw new Error('Buffer overflow') + } + data.push(buf.slice(offset, offset + len)) + offset += len + remaining -= len + } + + rtxt.decode.bytes = offset - oldOffset + return data +} + +rtxt.decode.bytes = 0 + +rtxt.encodingLength = function (data) { + if (!Array.isArray(data)) data = [data] + let length = 2 + data.forEach(function (buf) { + if (typeof buf === 'string') { + length += Buffer.byteLength(buf) + 1 + } else { + length += buf.length + 1 + } + }) + return length +} + +const rnull = exports.null = {} + +rnull.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rnull.encodingLength(data)) + if (!offset) offset = 0 + + if (typeof data === 'string') data = Buffer.from(data) + if (!data) data = Buffer.allocUnsafe(0) + + const oldOffset = offset + offset += 2 + + const len = data.length + data.copy(buf, offset, 0, len) + offset += len + + buf.writeUInt16BE(offset - oldOffset - 2, oldOffset) + rnull.encode.bytes = offset - oldOffset + return buf +} + +rnull.encode.bytes = 0 + +rnull.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + const len = buf.readUInt16BE(offset) + + offset += 2 + + const data = buf.slice(offset, offset + len) + offset += len + + rnull.decode.bytes = offset - oldOffset + return data +} + +rnull.decode.bytes = 0 + +rnull.encodingLength = function (data) { + if (!data) return 2 + return (Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data)) + 2 +} + +const rhinfo = exports.hinfo = {} + +rhinfo.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rhinfo.encodingLength(data)) + if (!offset) offset = 0 + + const oldOffset = offset + offset += 2 + string.encode(data.cpu, buf, offset) + offset += string.encode.bytes + string.encode(data.os, buf, offset) + offset += string.encode.bytes + buf.writeUInt16BE(offset - oldOffset - 2, oldOffset) + rhinfo.encode.bytes = offset - oldOffset + return buf +} + +rhinfo.encode.bytes = 0 + +rhinfo.decode = function (buf, offset) { + if (!offset) offset = 0 + + const oldOffset = offset + + const data = {} + offset += 2 + data.cpu = string.decode(buf, offset) + offset += string.decode.bytes + data.os = string.decode(buf, offset) + offset += string.decode.bytes + rhinfo.decode.bytes = offset - oldOffset + return data +} + +rhinfo.decode.bytes = 0 + +rhinfo.encodingLength = function (data) { + return string.encodingLength(data.cpu) + string.encodingLength(data.os) + 2 +} + +const rptr = exports.ptr = {} +const rcname = exports.cname = rptr +const rdname = exports.dname = rptr + +rptr.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rptr.encodingLength(data)) + if (!offset) offset = 0 + + name.encode(data, buf, offset + 2) + buf.writeUInt16BE(name.encode.bytes, offset) + rptr.encode.bytes = name.encode.bytes + 2 + return buf +} + +rptr.encode.bytes = 0 + +rptr.decode = function (buf, offset) { + if (!offset) offset = 0 + + const data = name.decode(buf, offset + 2) + rptr.decode.bytes = name.decode.bytes + 2 + return data +} + +rptr.decode.bytes = 0 + +rptr.encodingLength = function (data) { + return name.encodingLength(data) + 2 +} + +const rsrv = exports.srv = {} + +rsrv.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rsrv.encodingLength(data)) + if (!offset) offset = 0 + + buf.writeUInt16BE(data.priority || 0, offset + 2) + buf.writeUInt16BE(data.weight || 0, offset + 4) + buf.writeUInt16BE(data.port || 0, offset + 6) + name.encode(data.target, buf, offset + 8) + + const len = name.encode.bytes + 6 + buf.writeUInt16BE(len, offset) + + rsrv.encode.bytes = len + 2 + return buf +} + +rsrv.encode.bytes = 0 + +rsrv.decode = function (buf, offset) { + if (!offset) offset = 0 + + const len = buf.readUInt16BE(offset) + + const data = {} + data.priority = buf.readUInt16BE(offset + 2) + data.weight = buf.readUInt16BE(offset + 4) + data.port = buf.readUInt16BE(offset + 6) + data.target = name.decode(buf, offset + 8) + + rsrv.decode.bytes = len + 2 + return data +} + +rsrv.decode.bytes = 0 + +rsrv.encodingLength = function (data) { + return 8 + name.encodingLength(data.target) +} + +const rcaa = exports.caa = {} + +rcaa.ISSUER_CRITICAL = 1 << 7 + +rcaa.encode = function (data, buf, offset) { + const len = rcaa.encodingLength(data) + + if (!buf) buf = Buffer.allocUnsafe(rcaa.encodingLength(data)) + if (!offset) offset = 0 + + if (data.issuerCritical) { + data.flags = rcaa.ISSUER_CRITICAL + } + + buf.writeUInt16BE(len - 2, offset) + offset += 2 + buf.writeUInt8(data.flags || 0, offset) + offset += 1 + string.encode(data.tag, buf, offset) + offset += string.encode.bytes + buf.write(data.value, offset) + offset += Buffer.byteLength(data.value) + + rcaa.encode.bytes = len + return buf +} + +rcaa.encode.bytes = 0 + +rcaa.decode = function (buf, offset) { + if (!offset) offset = 0 + + const len = buf.readUInt16BE(offset) + offset += 2 + + const oldOffset = offset + const data = {} + data.flags = buf.readUInt8(offset) + offset += 1 + data.tag = string.decode(buf, offset) + offset += string.decode.bytes + data.value = buf.toString('utf-8', offset, oldOffset + len) + + data.issuerCritical = !!(data.flags & rcaa.ISSUER_CRITICAL) + + rcaa.decode.bytes = len + 2 + + return data +} + +rcaa.decode.bytes = 0 + +rcaa.encodingLength = function (data) { + return string.encodingLength(data.tag) + string.encodingLength(data.value) + 2 +} + +const rmx = exports.mx = {} + +rmx.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rmx.encodingLength(data)) + if (!offset) offset = 0 + + const oldOffset = offset + offset += 2 + buf.writeUInt16BE(data.preference || 0, offset) + offset += 2 + name.encode(data.exchange, buf, offset) + offset += name.encode.bytes + + buf.writeUInt16BE(offset - oldOffset - 2, oldOffset) + rmx.encode.bytes = offset - oldOffset + return buf +} + +rmx.encode.bytes = 0 + +rmx.decode = function (buf, offset) { + if (!offset) offset = 0 + + const oldOffset = offset + + const data = {} + offset += 2 + data.preference = buf.readUInt16BE(offset) + offset += 2 + data.exchange = name.decode(buf, offset) + offset += name.decode.bytes + + rmx.decode.bytes = offset - oldOffset + return data +} + +rmx.encodingLength = function (data) { + return 4 + name.encodingLength(data.exchange) +} + +const ra = exports.a = {} + +ra.encode = function (host, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(ra.encodingLength(host)) + if (!offset) offset = 0 + + buf.writeUInt16BE(4, offset) + offset += 2 + ip.toBuffer(host, buf, offset) + ra.encode.bytes = 6 + return buf +} + +ra.encode.bytes = 0 + +ra.decode = function (buf, offset) { + if (!offset) offset = 0 + + offset += 2 + const host = ip.toString(buf, offset, 4) + ra.decode.bytes = 6 + return host +} +ra.decode.bytes = 0 + +ra.encodingLength = function () { + return 6 +} + +const raaaa = exports.aaaa = {} + +raaaa.encode = function (host, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(raaaa.encodingLength(host)) + if (!offset) offset = 0 + + buf.writeUInt16BE(16, offset) + offset += 2 + ip.toBuffer(host, buf, offset) + raaaa.encode.bytes = 18 + return buf +} + +raaaa.encode.bytes = 0 + +raaaa.decode = function (buf, offset) { + if (!offset) offset = 0 + + offset += 2 + const host = ip.toString(buf, offset, 16) + raaaa.decode.bytes = 18 + return host +} + +raaaa.decode.bytes = 0 + +raaaa.encodingLength = function () { + return 18 +} + +const roption = exports.option = {} + +roption.encode = function (option, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(roption.encodingLength(option)) + if (!offset) offset = 0 + const oldOffset = offset + + const code = optioncodes.toCode(option.code) + buf.writeUInt16BE(code, offset) + offset += 2 + if (option.data) { + buf.writeUInt16BE(option.data.length, offset) + offset += 2 + option.data.copy(buf, offset) + offset += option.data.length + } else { + switch (code) { + // case 3: NSID. No encode makes sense. + // case 5,6,7: Not implementable + case 8: // ECS + // note: do IP math before calling + const spl = option.sourcePrefixLength || 0 + const fam = option.family || (ip.isV4Format(option.ip) ? 1 : 2) + const ipBuf = ip.toBuffer(option.ip) + const ipLen = Math.ceil(spl / 8) + buf.writeUInt16BE(ipLen + 4, offset) + offset += 2 + buf.writeUInt16BE(fam, offset) + offset += 2 + buf.writeUInt8(spl, offset++) + buf.writeUInt8(option.scopePrefixLength || 0, offset++) + + ipBuf.copy(buf, offset, 0, ipLen) + offset += ipLen + break + // case 9: EXPIRE (experimental) + // case 10: COOKIE. No encode makes sense. + case 11: // KEEP-ALIVE + if (option.timeout) { + buf.writeUInt16BE(2, offset) + offset += 2 + buf.writeUInt16BE(option.timeout, offset) + offset += 2 + } else { + buf.writeUInt16BE(0, offset) + offset += 2 + } + break + case 12: // PADDING + const len = option.length || 0 + buf.writeUInt16BE(len, offset) + offset += 2 + buf.fill(0, offset, offset + len) + offset += len + break + // case 13: CHAIN. Experimental. + case 14: // KEY-TAG + const tagsLen = option.tags.length * 2 + buf.writeUInt16BE(tagsLen, offset) + offset += 2 + for (const tag of option.tags) { + buf.writeUInt16BE(tag, offset) + offset += 2 + } + break + case 15: // EDNS_ERROR + const text = option.text || ""; + buf.writeUInt16BE(text.length + 2, offset) + offset += 2; + buf.writeUInt16BE(option.extended_error, offset) + offset += 2; + buf.write(text, offset); + offset += option.text.length; + break; + default: + throw new Error(`Unknown roption code: ${option.code}`) + } + } + + roption.encode.bytes = offset - oldOffset + return buf +} + +roption.encode.bytes = 0 + +roption.decode = function (buf, offset) { + if (!offset) offset = 0 + const option = {} + option.code = buf.readUInt16BE(offset) + option.type = optioncodes.toString(option.code) + offset += 2 + const len = buf.readUInt16BE(offset) + offset += 2 + option.data = buf.slice(offset, offset + len) + switch (option.code) { + // case 3: NSID. No decode makes sense. + case 8: // ECS + option.family = buf.readUInt16BE(offset) + offset += 2 + option.sourcePrefixLength = buf.readUInt8(offset++) + option.scopePrefixLength = buf.readUInt8(offset++) + const padded = Buffer.alloc((option.family === 1) ? 4 : 16) + buf.copy(padded, 0, offset, offset + len - 4) + option.ip = ip.toString(padded) + break + // case 12: Padding. No decode makes sense. + case 11: // KEEP-ALIVE + if (len > 0) { + option.timeout = buf.readUInt16BE(offset) + offset += 2 + } + break + case 14: + option.tags = [] + for (let i = 0; i < len; i += 2) { + option.tags.push(buf.readUInt16BE(offset)) + offset += 2 + } + // don't worry about default. caller will use data if desired + } + + roption.decode.bytes = len + 4 + return option +} + +roption.decode.bytes = 0 + +roption.encodingLength = function (option) { + if (option.data) { + return option.data.length + 4 + } + const code = optioncodes.toCode(option.code) + switch (code) { + case 8: // ECS + const spl = option.sourcePrefixLength || 0 + return Math.ceil(spl / 8) + 8 + case 11: // KEEP-ALIVE + return (typeof option.timeout === 'number') ? 6 : 4 + case 12: // PADDING + return option.length + 4 + case 14: // KEY-TAG + return 4 + (option.tags.length * 2) + case 15: // EDNS_ERROR + return 4 + 2 + option.text.length + } + throw new Error(`Unknown roption code: ${option.code}`) +} + +const ropt = exports.opt = {} + +ropt.encode = function (options, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(ropt.encodingLength(options)) + if (!offset) offset = 0 + const oldOffset = offset + + const rdlen = encodingLengthList(options, roption) + buf.writeUInt16BE(rdlen, offset) + offset = encodeList(options, roption, buf, offset + 2) + + ropt.encode.bytes = offset - oldOffset + return buf +} + +ropt.encode.bytes = 0 + +ropt.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + const options = [] + let rdlen = buf.readUInt16BE(offset) + offset += 2 + let o = 0 + while (rdlen > 0) { + options[o++] = roption.decode(buf, offset) + offset += roption.decode.bytes + rdlen -= roption.decode.bytes + } + ropt.decode.bytes = offset - oldOffset + return options +} + +ropt.decode.bytes = 0 + +ropt.encodingLength = function (options) { + return 2 + encodingLengthList(options || [], roption) +} + +const rdnskey = exports.dnskey = {} + +rdnskey.PROTOCOL_DNSSEC = 3 +rdnskey.ZONE_KEY = 0x80 +rdnskey.SECURE_ENTRYPOINT = 0x8000 + +rdnskey.encode = function (key, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rdnskey.encodingLength(key)) + if (!offset) offset = 0 + const oldOffset = offset + + const keydata = key.key + if (!Buffer.isBuffer(keydata)) { + throw new Error('Key must be a Buffer') + } + + offset += 2 // Leave space for length + buf.writeUInt16BE(key.flags, offset) + offset += 2 + buf.writeUInt8(rdnskey.PROTOCOL_DNSSEC, offset) + offset += 1 + buf.writeUInt8(key.algorithm, offset) + offset += 1 + keydata.copy(buf, offset, 0, keydata.length) + offset += keydata.length + + rdnskey.encode.bytes = offset - oldOffset + buf.writeUInt16BE(rdnskey.encode.bytes - 2, oldOffset) + return buf +} + +rdnskey.encode.bytes = 0 + +rdnskey.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + var key = {} + var length = buf.readUInt16BE(offset) + offset += 2 + key.flags = buf.readUInt16BE(offset) + offset += 2 + if (buf.readUInt8(offset) !== rdnskey.PROTOCOL_DNSSEC) { + throw new Error('Protocol must be 3') + } + offset += 1 + key.algorithm = buf.readUInt8(offset) + offset += 1 + key.key = buf.slice(offset, oldOffset + length + 2) + offset += key.key.length + rdnskey.decode.bytes = offset - oldOffset + return key +} + +rdnskey.decode.bytes = 0 + +rdnskey.encodingLength = function (key) { + return 6 + Buffer.byteLength(key.key) +} + +const rrrsig = exports.rrsig = {} + +rrrsig.encode = function (sig, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rrrsig.encodingLength(sig)) + if (!offset) offset = 0 + const oldOffset = offset + + const signature = sig.signature + if (!Buffer.isBuffer(signature)) { + throw new Error('Signature must be a Buffer') + } + + offset += 2 // Leave space for length + buf.writeUInt16BE(types.toType(sig.typeCovered), offset) + offset += 2 + buf.writeUInt8(sig.algorithm, offset) + offset += 1 + buf.writeUInt8(sig.labels, offset) + offset += 1 + buf.writeUInt32BE(sig.originalTTL, offset) + offset += 4 + buf.writeUInt32BE(sig.expiration, offset) + offset += 4 + buf.writeUInt32BE(sig.inception, offset) + offset += 4 + buf.writeUInt16BE(sig.keyTag, offset) + offset += 2 + name.encode(sig.signersName, buf, offset) + offset += name.encode.bytes + signature.copy(buf, offset, 0, signature.length) + offset += signature.length + + rrrsig.encode.bytes = offset - oldOffset + buf.writeUInt16BE(rrrsig.encode.bytes - 2, oldOffset) + return buf +} + +rrrsig.encode.bytes = 0 + +rrrsig.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + var sig = {} + var length = buf.readUInt16BE(offset) + offset += 2 + sig.typeCovered = types.toString(buf.readUInt16BE(offset)) + offset += 2 + sig.algorithm = buf.readUInt8(offset) + offset += 1 + sig.labels = buf.readUInt8(offset) + offset += 1 + sig.originalTTL = buf.readUInt32BE(offset) + offset += 4 + sig.expiration = buf.readUInt32BE(offset) + offset += 4 + sig.inception = buf.readUInt32BE(offset) + offset += 4 + sig.keyTag = buf.readUInt16BE(offset) + offset += 2 + sig.signersName = name.decode(buf, offset) + offset += name.decode.bytes + sig.signature = buf.slice(offset, oldOffset + length + 2) + offset += sig.signature.length + rrrsig.decode.bytes = offset - oldOffset + return sig +} + +rrrsig.decode.bytes = 0 + +rrrsig.encodingLength = function (sig) { + return 20 + + name.encodingLength(sig.signersName) + + Buffer.byteLength(sig.signature) +} + +const rrp = exports.rp = {} + +rrp.encode = function (data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rrp.encodingLength(data)) + if (!offset) offset = 0 + const oldOffset = offset + + offset += 2 // Leave space for length + name.encode(data.mbox || '.', buf, offset) + offset += name.encode.bytes + name.encode(data.txt || '.', buf, offset) + offset += name.encode.bytes + rrp.encode.bytes = offset - oldOffset + buf.writeUInt16BE(rrp.encode.bytes - 2, oldOffset) + return buf +} + +rrp.encode.bytes = 0 + +rrp.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + const data = {} + offset += 2 + data.mbox = name.decode(buf, offset) || '.' + offset += name.decode.bytes + data.txt = name.decode(buf, offset) || '.' + offset += name.decode.bytes + rrp.decode.bytes = offset - oldOffset + return data +} + +rrp.decode.bytes = 0 + +rrp.encodingLength = function (data) { + return 2 + name.encodingLength(data.mbox || '.') + name.encodingLength(data.txt || '.') +} + +const typebitmap = {} + +typebitmap.encode = function (typelist, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(typebitmap.encodingLength(typelist)) + if (!offset) offset = 0 + const oldOffset = offset + + var typesByWindow = [] + for (var i = 0; i < typelist.length; i++) { + var typeid = types.toType(typelist[i]) + if (typesByWindow[typeid >> 8] === undefined) { + typesByWindow[typeid >> 8] = [] + } + typesByWindow[typeid >> 8][(typeid >> 3) & 0x1F] |= 1 << (7 - (typeid & 0x7)) + } + + for (i = 0; i < typesByWindow.length; i++) { + if (typesByWindow[i] !== undefined) { + var windowBuf = Buffer.from(typesByWindow[i]) + buf.writeUInt8(i, offset) + offset += 1 + buf.writeUInt8(windowBuf.length, offset) + offset += 1 + windowBuf.copy(buf, offset) + offset += windowBuf.length + } + } + + typebitmap.encode.bytes = offset - oldOffset + return buf +} + +typebitmap.encode.bytes = 0 + +typebitmap.decode = function (buf, offset, length) { + if (!offset) offset = 0 + const oldOffset = offset + + var typelist = [] + while (offset - oldOffset < length) { + var window = buf.readUInt8(offset) + offset += 1 + var windowLength = buf.readUInt8(offset) + offset += 1 + for (var i = 0; i < windowLength; i++) { + var b = buf.readUInt8(offset + i) + for (var j = 0; j < 8; j++) { + if (b & (1 << (7 - j))) { + var typeid = types.toString((window << 8) | (i << 3) | j) + typelist.push(typeid) + } + } + } + offset += windowLength + } + + typebitmap.decode.bytes = offset - oldOffset + return typelist +} + +typebitmap.decode.bytes = 0 + +typebitmap.encodingLength = function (typelist) { + var extents = [] + for (var i = 0; i < typelist.length; i++) { + var typeid = types.toType(typelist[i]) + extents[typeid >> 8] = Math.max(extents[typeid >> 8] || 0, typeid & 0xFF) + } + + var len = 0 + for (i = 0; i < extents.length; i++) { + if (extents[i] !== undefined) { + len += 2 + Math.ceil((extents[i] + 1) / 8) + } + } + + return len +} + +const rnsec = exports.nsec = {} + +rnsec.encode = function (record, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rnsec.encodingLength(record)) + if (!offset) offset = 0 + const oldOffset = offset + + offset += 2 // Leave space for length + name.encode(record.nextDomain, buf, offset) + offset += name.encode.bytes + typebitmap.encode(record.rrtypes, buf, offset) + offset += typebitmap.encode.bytes + + rnsec.encode.bytes = offset - oldOffset + buf.writeUInt16BE(rnsec.encode.bytes - 2, oldOffset) + return buf +} + +rnsec.encode.bytes = 0 + +rnsec.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + var record = {} + var length = buf.readUInt16BE(offset) + offset += 2 + record.nextDomain = name.decode(buf, offset) + offset += name.decode.bytes + record.rrtypes = typebitmap.decode(buf, offset, length - (offset - oldOffset)) + offset += typebitmap.decode.bytes + + rnsec.decode.bytes = offset - oldOffset + return record +} + +rnsec.decode.bytes = 0 + +rnsec.encodingLength = function (record) { + return 2 + + name.encodingLength(record.nextDomain) + + typebitmap.encodingLength(record.rrtypes) +} + +const rnsec3 = exports.nsec3 = {} + +rnsec3.encode = function (record, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rnsec3.encodingLength(record)) + if (!offset) offset = 0 + const oldOffset = offset + + const salt = record.salt + if (!Buffer.isBuffer(salt)) { + throw new Error('salt must be a Buffer') + } + + const nextDomain = record.nextDomain + if (!Buffer.isBuffer(nextDomain)) { + throw new Error('nextDomain must be a Buffer') + } + + offset += 2 // Leave space for length + buf.writeUInt8(record.algorithm, offset) + offset += 1 + buf.writeUInt8(record.flags, offset) + offset += 1 + buf.writeUInt16BE(record.iterations, offset) + offset += 2 + buf.writeUInt8(salt.length, offset) + offset += 1 + salt.copy(buf, offset, 0, salt.length) + offset += salt.length + buf.writeUInt8(nextDomain.length, offset) + offset += 1 + nextDomain.copy(buf, offset, 0, nextDomain.length) + offset += nextDomain.length + typebitmap.encode(record.rrtypes, buf, offset) + offset += typebitmap.encode.bytes + + rnsec3.encode.bytes = offset - oldOffset + buf.writeUInt16BE(rnsec3.encode.bytes - 2, oldOffset) + return buf +} + +rnsec3.encode.bytes = 0 + +rnsec3.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + var record = {} + var length = buf.readUInt16BE(offset) + offset += 2 + record.algorithm = buf.readUInt8(offset) + offset += 1 + record.flags = buf.readUInt8(offset) + offset += 1 + record.iterations = buf.readUInt16BE(offset) + offset += 2 + const saltLength = buf.readUInt8(offset) + offset += 1 + record.salt = buf.slice(offset, offset + saltLength) + offset += saltLength + const hashLength = buf.readUInt8(offset) + offset += 1 + record.nextDomain = buf.slice(offset, offset + hashLength) + offset += hashLength + record.rrtypes = typebitmap.decode(buf, offset, length - (offset - oldOffset)) + offset += typebitmap.decode.bytes + + rnsec3.decode.bytes = offset - oldOffset + return record +} + +rnsec3.decode.bytes = 0 + +rnsec3.encodingLength = function (record) { + return 8 + + record.salt.length + + record.nextDomain.length + + typebitmap.encodingLength(record.rrtypes) +} + +const rds = exports.ds = {} + +rds.encode = function (digest, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rds.encodingLength(digest)) + if (!offset) offset = 0 + const oldOffset = offset + + const digestdata = digest.digest + if (!Buffer.isBuffer(digestdata)) { + throw new Error('Digest must be a Buffer') + } + + offset += 2 // Leave space for length + buf.writeUInt16BE(digest.keyTag, offset) + offset += 2 + buf.writeUInt8(digest.algorithm, offset) + offset += 1 + buf.writeUInt8(digest.digestType, offset) + offset += 1 + digestdata.copy(buf, offset, 0, digestdata.length) + offset += digestdata.length + + rds.encode.bytes = offset - oldOffset + buf.writeUInt16BE(rds.encode.bytes - 2, oldOffset) + return buf +} + +rds.encode.bytes = 0 + +rds.decode = function (buf, offset) { + if (!offset) offset = 0 + const oldOffset = offset + + var digest = {} + var length = buf.readUInt16BE(offset) + offset += 2 + digest.keyTag = buf.readUInt16BE(offset) + offset += 2 + digest.algorithm = buf.readUInt8(offset) + offset += 1 + digest.digestType = buf.readUInt8(offset) + offset += 1 + digest.digest = buf.slice(offset, oldOffset + length + 2) + offset += digest.digest.length + rds.decode.bytes = offset - oldOffset + return digest +} + +rds.decode.bytes = 0 + +rds.encodingLength = function (digest) { + return 6 + Buffer.byteLength(digest.digest) +} + +const svcparam = exports.svcparam = {} + +svcparam.keyToNumber = function(keyName) { + switch (keyName.toLowerCase()) { + case 'mandatory': return 0 + case 'alpn' : return 1 + case 'no-default-alpn' : return 2 + case 'port' : return 3 + case 'ipv4hint' : return 4 + case 'echconfig' : return 5 + case 'ipv6hint' : return 6 + case 'odoh' : return 32769 + case 'key65535' : return 65535 + } + if (!keyName.startsWith('key')) { + throw new Error(`Name must start with key: ${keyName}`); + } + + return Number.parseInt(keyName.substring(3)); +} + +svcparam.numberToKeyName = function(number) { + switch (number) { + case 0 : return 'mandatory' + case 1 : return 'alpn' + case 2 : return 'no-default-alpn' + case 3 : return 'port' + case 4 : return 'ipv4hint' + case 5 : return 'echconfig' + case 6 : return 'ipv6hint' + case 32769 : return 'odoh' + } + + return `key${number}`; +} + +svcparam.encode = function(param, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(svcparam.encodingLength(param)) + if (!offset) offset = 0 + + let key = param.key; + if (typeof param.key !== 'number') { + key = svcparam.keyToNumber(param.key); + } + + buf.writeUInt16BE(key || 0, offset) + offset += 2; + svcparam.encode.bytes = 2; + + if (key == 0) { // mandatory + let values = param.value; + if (!Array.isArray(values)) values = [values]; + buf.writeUInt16BE(values.length*2, offset); + offset += 2; + svcparam.encode.bytes += 2; + + for (let val of values) { + if (typeof val !== 'number') { + val = svcparam.keyToNumber(val); + } + buf.writeUInt16BE(val, offset); + offset += 2; + svcparam.encode.bytes += 2; + } + } else if (key == 1) { // alpn + let val = param.value; + if (!Array.isArray(val)) val = [val]; + // The alpn param is prefixed by its length as a single byte, so the + // initialValue to reduce function is the length of the array. + let total = val.reduce(function(result, id) { + return result += id.length; + }, val.length); + + buf.writeUInt16BE(total, offset); + offset += 2; + svcparam.encode.bytes += 2; + + for (let id of val) { + buf.writeUInt8(id.length, offset); + offset += 1; + svcparam.encode.bytes += 1; + + buf.write(id, offset); + offset += id.length; + svcparam.encode.bytes += id.length; + } + } else if (key == 2) { // no-default-alpn + buf.writeUInt16BE(0, offset); + offset += 2; + svcparam.encode.bytes += 2; + } else if (key == 3) { // port + buf.writeUInt16BE(2, offset); + offset += 2; + svcparam.encode.bytes += 2; + buf.writeUInt16BE(param.value || 0, offset); + offset += 2; + svcparam.encode.bytes += 2; + } else if (key == 4) { //ipv4hint + let val = param.value; + if (!Array.isArray(val)) val = [val]; + buf.writeUInt16BE(val.length*4, offset); + offset += 2; + svcparam.encode.bytes += 2; + + for (let host of val) { + ip.toBuffer(host, buf, offset) + offset += 4; + svcparam.encode.bytes += 4; + } + } else if (key == 5) { //echconfig + if (svcparam.ech) { + buf.writeUInt16BE(svcparam.ech.length, offset); + offset += 2; + svcparam.encode.bytes += 2; + for (let i = 0; i < svcparam.ech.length; i++) { + buf.writeUInt8(svcparam.ech[i], offset); + offset++; + } + svcparam.encode.bytes += svcparam.ech.length; + } else { + buf.writeUInt16BE(param.value.length, offset); + offset += 2; + svcparam.encode.bytes += 2; + buf.write(param.value, offset); + offset += param.value.length; + svcparam.encode.bytes += param.value.length; + } + } else if (key == 6) { //ipv6hint + let val = param.value; + if (!Array.isArray(val)) val = [val]; + buf.writeUInt16BE(val.length*16, offset); + offset += 2; + svcparam.encode.bytes += 2; + + for (let host of val) { + ip.toBuffer(host, buf, offset) + offset += 16; + svcparam.encode.bytes += 16; + } + } else if (key == 32769) { //odoh + if (svcparam.odoh) { + buf.writeUInt16BE(svcparam.odoh.length, offset); + offset += 2; + svcparam.encode.bytes += 2; + for (let i = 0; i < svcparam.odoh.length; i++) { + buf.writeUInt8(svcparam.odoh[i], offset); + offset++; + } + svcparam.encode.bytes += svcparam.odoh.length; + svcparam.odoh = null; + } else { + buf.writeUInt16BE(param.value.length, offset); + offset += 2; + svcparam.encode.bytes += 2; + buf.write(param.value, offset); + offset += param.value.length; + svcparam.encode.bytes += param.value.length; + } + } else { + // Unknown option + buf.writeUInt16BE(0, offset); // 0 length since we don't know how to encode + offset += 2; + svcparam.encode.bytes += 2; + } + +} + +svcparam.encode.bytes = 0; + +svcparam.decode = function (buf, offset) { + let param = {}; + let id = buf.readUInt16BE(offset); + param.key = svcparam.numberToKeyName(id); + offset += 2; + svcparam.decode.bytes = 2; + + let len = buf.readUInt16BE(offset); + offset += 2; + svcparam.decode.bytes += 2; + + param.value = buf.toString('utf-8', offset, offset + len); + offset += len; + svcparam.decode.bytes += len; + + return param; +} + +svcparam.decode.bytes = 0; + +svcparam.encodingLength = function (param) { + // 2 bytes for type, 2 bytes for length, what's left for the value + + switch (param.key) { + case 'mandatory' : return 4 + 2*(Array.isArray(param.value) ? param.value.length : 1) + case 'alpn' : { + let val = param.value; + if (!Array.isArray(val)) val = [val]; + let total = val.reduce(function(result, id) { + return result += id.length; + }, val.length); + return 4 + total; + } + case 'no-default-alpn' : return 4 + case 'port' : return 4 + 2 + case 'ipv4hint' : return 4 + 4 * (Array.isArray(param.value) ? param.value.length : 1) + case 'echconfig' : { + if (param.needBase64Decode) { + svcparam.ech = Buffer.from(param.value, "base64"); + return 4 + svcparam.ech.length; + } + return 4 + param.value.length + } + case 'ipv6hint' : return 4 + 16 * (Array.isArray(param.value) ? param.value.length : 1) + case 'odoh' : { + if (param.needBase64Decode) { + svcparam.odoh = Buffer.from(param.value, "base64"); + return 4 + svcparam.odoh.length; + } + return 4 + param.value.length + } + case 'key65535' : return 4 + default: return 4 // unknown option + } +} + +const rhttpssvc = exports.httpssvc = {} + +rhttpssvc.encode = function(data, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(rhttpssvc.encodingLength(data)) + if (!offset) offset = 0 + + buf.writeUInt16BE(rhttpssvc.encodingLength(data) - 2 , offset); + offset += 2; + + buf.writeUInt16BE(data.priority || 0, offset); + rhttpssvc.encode.bytes = 4; + offset += 2; + name.encode(data.name, buf, offset); + rhttpssvc.encode.bytes += name.encode.bytes; + offset += name.encode.bytes; + + if (data.priority == 0) { + return; + } + + for (let val of data.values) { + svcparam.encode(val, buf, offset); + offset += svcparam.encode.bytes; + rhttpssvc.encode.bytes += svcparam.encode.bytes; + } + + return buf; +} + +rhttpssvc.encode.bytes = 0; + +rhttpssvc.decode = function (buf, offset) { + let rdlen = buf.readUInt16BE(offset); + let oldOffset = offset; + offset += 2; + let record = {} + record.priority = buf.readUInt16BE(offset); + offset += 2; + rhttpssvc.decode.bytes = 4; + record.name = name.decode(buf, offset); + offset += name.decode.bytes; + rhttpssvc.decode.bytes += name.decode.bytes; + + while (rdlen > rhttpssvc.decode.bytes - 2) { + let rec1 = svcparam.decode(buf, offset); + offset += svcparam.decode.bytes; + rhttpssvc.decode.bytes += svcparam.decode.bytes; + record.values.push(rec1); + } + + return record; +} + +rhttpssvc.decode.bytes = 0; + +rhttpssvc.encodingLength = function (data) { + let len = + 2 + // rdlen + 2 + // priority + name.encodingLength(data.name); + len += data.values.map(svcparam.encodingLength).reduce((acc, len) => acc + len, 0); + return len; +} + +const renc = exports.record = function (type) { + switch (type.toUpperCase()) { + case 'A': return ra + case 'PTR': return rptr + case 'CNAME': return rcname + case 'DNAME': return rdname + case 'TXT': return rtxt + case 'NULL': return rnull + case 'AAAA': return raaaa + case 'SRV': return rsrv + case 'HINFO': return rhinfo + case 'CAA': return rcaa + case 'NS': return rns + case 'SOA': return rsoa + case 'MX': return rmx + case 'OPT': return ropt + case 'DNSKEY': return rdnskey + case 'RRSIG': return rrrsig + case 'RP': return rrp + case 'NSEC': return rnsec + case 'NSEC3': return rnsec3 + case 'DS': return rds + case 'HTTPS': return rhttpssvc + } + return runknown +} + +const answer = exports.answer = {} + +answer.encode = function (a, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(answer.encodingLength(a)) + if (!offset) offset = 0 + + const oldOffset = offset + + name.encode(a.name, buf, offset) + offset += name.encode.bytes + + buf.writeUInt16BE(types.toType(a.type), offset) + + if (a.type.toUpperCase() === 'OPT') { + if (a.name !== '.') { + throw new Error('OPT name must be root.') + } + buf.writeUInt16BE(a.udpPayloadSize || 4096, offset + 2) + buf.writeUInt8(a.extendedRcode || 0, offset + 4) + buf.writeUInt8(a.ednsVersion || 0, offset + 5) + buf.writeUInt16BE(a.flags || 0, offset + 6) + + offset += 8 + ropt.encode(a.options || [], buf, offset) + offset += ropt.encode.bytes + } else { + let klass = classes.toClass(a.class === undefined ? 'IN' : a.class) + if (a.flush) klass |= FLUSH_MASK // the 1st bit of the class is the flush bit + buf.writeUInt16BE(klass, offset + 2) + buf.writeUInt32BE(a.ttl || 0, offset + 4) + + offset += 8 + const enc = renc(a.type) + enc.encode(a.data, buf, offset) + offset += enc.encode.bytes + } + + answer.encode.bytes = offset - oldOffset + return buf +} + +answer.encode.bytes = 0 + +answer.decode = function (buf, offset) { + if (!offset) offset = 0 + + const a = {} + const oldOffset = offset + + a.name = name.decode(buf, offset) + offset += name.decode.bytes + a.type = types.toString(buf.readUInt16BE(offset)) + if (a.type === 'OPT') { + a.udpPayloadSize = buf.readUInt16BE(offset + 2) + a.extendedRcode = buf.readUInt8(offset + 4) + a.ednsVersion = buf.readUInt8(offset + 5) + a.flags = buf.readUInt16BE(offset + 6) + a.flag_do = ((a.flags >> 15) & 0x1) === 1 + a.options = ropt.decode(buf, offset + 8) + offset += 8 + ropt.decode.bytes + } else { + const klass = buf.readUInt16BE(offset + 2) + a.ttl = buf.readUInt32BE(offset + 4) + a.class = classes.toString(klass & NOT_FLUSH_MASK) + a.flush = !!(klass & FLUSH_MASK) + + const enc = renc(a.type) + a.data = enc.decode(buf, offset + 8) + offset += 8 + enc.decode.bytes + } + + answer.decode.bytes = offset - oldOffset + return a +} + +answer.decode.bytes = 0 + +answer.encodingLength = function (a) { + const data = (a.data !== null && a.data !== undefined) ? a.data : a.options + return name.encodingLength(a.name) + 8 + renc(a.type).encodingLength(data) +} + +const question = exports.question = {} + +question.encode = function (q, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(question.encodingLength(q)) + if (!offset) offset = 0 + + const oldOffset = offset + + name.encode(q.name, buf, offset) + offset += name.encode.bytes + + buf.writeUInt16BE(types.toType(q.type), offset) + offset += 2 + + buf.writeUInt16BE(classes.toClass(q.class === undefined ? 'IN' : q.class), offset) + offset += 2 + + question.encode.bytes = offset - oldOffset + return q +} + +question.encode.bytes = 0 + +question.decode = function (buf, offset) { + if (!offset) offset = 0 + + const oldOffset = offset + const q = {} + + q.name = name.decode(buf, offset) + offset += name.decode.bytes + + q.type = types.toString(buf.readUInt16BE(offset)) + offset += 2 + + q.class = classes.toString(buf.readUInt16BE(offset)) + offset += 2 + + const qu = !!(q.class & QU_MASK) + if (qu) q.class &= NOT_QU_MASK + + question.decode.bytes = offset - oldOffset + return q +} + +question.decode.bytes = 0 + +question.encodingLength = function (q) { + return name.encodingLength(q.name) + 4 +} + +exports.AUTHORITATIVE_ANSWER = 1 << 10 +exports.TRUNCATED_RESPONSE = 1 << 9 +exports.RECURSION_DESIRED = 1 << 8 +exports.RECURSION_AVAILABLE = 1 << 7 +exports.AUTHENTIC_DATA = 1 << 5 +exports.CHECKING_DISABLED = 1 << 4 +exports.DNSSEC_OK = 1 << 15 + +exports.encode = function (result, buf, offset) { + if (!buf) buf = Buffer.allocUnsafe(exports.encodingLength(result)) + if (!offset) offset = 0 + + const oldOffset = offset + + if (!result.questions) result.questions = [] + if (!result.answers) result.answers = [] + if (!result.authorities) result.authorities = [] + if (!result.additionals) result.additionals = [] + + header.encode(result, buf, offset) + offset += header.encode.bytes + + offset = encodeList(result.questions, question, buf, offset) + offset = encodeList(result.answers, answer, buf, offset) + offset = encodeList(result.authorities, answer, buf, offset) + offset = encodeList(result.additionals, answer, buf, offset) + + exports.encode.bytes = offset - oldOffset + + return buf +} + +exports.encode.bytes = 0 + +exports.decode = function (buf, offset) { + if (!offset) offset = 0 + + const oldOffset = offset + const result = header.decode(buf, offset) + offset += header.decode.bytes + + offset = decodeList(result.questions, question, buf, offset) + offset = decodeList(result.answers, answer, buf, offset) + offset = decodeList(result.authorities, answer, buf, offset) + offset = decodeList(result.additionals, answer, buf, offset) + + exports.decode.bytes = offset - oldOffset + + return result +} + +exports.decode.bytes = 0 + +exports.encodingLength = function (result) { + return header.encodingLength(result) + + encodingLengthList(result.questions || [], question) + + encodingLengthList(result.answers || [], answer) + + encodingLengthList(result.authorities || [], answer) + + encodingLengthList(result.additionals || [], answer) +} + +exports.streamEncode = function (result) { + const buf = exports.encode(result) + const sbuf = Buffer.allocUnsafe(2) + sbuf.writeUInt16BE(buf.byteLength) + const combine = Buffer.concat([sbuf, buf]) + exports.streamEncode.bytes = combine.byteLength + return combine +} + +exports.streamEncode.bytes = 0 + +exports.streamDecode = function (sbuf) { + const len = sbuf.readUInt16BE(0) + if (sbuf.byteLength < len + 2) { + // not enough data + return null + } + const result = exports.decode(sbuf.slice(2)) + exports.streamDecode.bytes = exports.decode.bytes + return result +} + +exports.streamDecode.bytes = 0 + +function encodingLengthList (list, enc) { + let len = 0 + for (let i = 0; i < list.length; i++) len += enc.encodingLength(list[i]) + return len +} + +function encodeList (list, enc, buf, offset) { + for (let i = 0; i < list.length; i++) { + enc.encode(list[i], buf, offset) + offset += enc.encode.bytes + } + return offset +} + +function decodeList (list, enc, buf, offset) { + for (let i = 0; i < list.length; i++) { + list[i] = enc.decode(buf, offset) + offset += enc.decode.bytes + } + return offset +} diff --git a/testing/xpcshell/dns-packet/opcodes.js b/testing/xpcshell/dns-packet/opcodes.js new file mode 100644 index 0000000000..32b0a1b4de --- /dev/null +++ b/testing/xpcshell/dns-packet/opcodes.js @@ -0,0 +1,50 @@ +'use strict' + +/* + * Traditional DNS header OPCODEs (4-bits) defined by IANA in + * https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-5 + */ + +exports.toString = function (opcode) { + switch (opcode) { + case 0: return 'QUERY' + case 1: return 'IQUERY' + case 2: return 'STATUS' + case 3: return 'OPCODE_3' + case 4: return 'NOTIFY' + case 5: return 'UPDATE' + case 6: return 'OPCODE_6' + case 7: return 'OPCODE_7' + case 8: return 'OPCODE_8' + case 9: return 'OPCODE_9' + case 10: return 'OPCODE_10' + case 11: return 'OPCODE_11' + case 12: return 'OPCODE_12' + case 13: return 'OPCODE_13' + case 14: return 'OPCODE_14' + case 15: return 'OPCODE_15' + } + return 'OPCODE_' + opcode +} + +exports.toOpcode = function (code) { + switch (code.toUpperCase()) { + case 'QUERY': return 0 + case 'IQUERY': return 1 + case 'STATUS': return 2 + case 'OPCODE_3': return 3 + case 'NOTIFY': return 4 + case 'UPDATE': return 5 + case 'OPCODE_6': return 6 + case 'OPCODE_7': return 7 + case 'OPCODE_8': return 8 + case 'OPCODE_9': return 9 + case 'OPCODE_10': return 10 + case 'OPCODE_11': return 11 + case 'OPCODE_12': return 12 + case 'OPCODE_13': return 13 + case 'OPCODE_14': return 14 + case 'OPCODE_15': return 15 + } + return 0 +} diff --git a/testing/xpcshell/dns-packet/optioncodes.js b/testing/xpcshell/dns-packet/optioncodes.js new file mode 100644 index 0000000000..a683ce81e6 --- /dev/null +++ b/testing/xpcshell/dns-packet/optioncodes.js @@ -0,0 +1,61 @@ +'use strict' + +exports.toString = function (type) { + switch (type) { + // list at + // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-11 + case 1: return 'LLQ' + case 2: return 'UL' + case 3: return 'NSID' + case 5: return 'DAU' + case 6: return 'DHU' + case 7: return 'N3U' + case 8: return 'CLIENT_SUBNET' + case 9: return 'EXPIRE' + case 10: return 'COOKIE' + case 11: return 'TCP_KEEPALIVE' + case 12: return 'PADDING' + case 13: return 'CHAIN' + case 14: return 'KEY_TAG' + case 15: return 'EDNS_ERROR' + case 26946: return 'DEVICEID' + } + if (type < 0) { + return null + } + return `OPTION_${type}` +} + +exports.toCode = function (name) { + if (typeof name === 'number') { + return name + } + if (!name) { + return -1 + } + switch (name.toUpperCase()) { + case 'OPTION_0': return 0 + case 'LLQ': return 1 + case 'UL': return 2 + case 'NSID': return 3 + case 'OPTION_4': return 4 + case 'DAU': return 5 + case 'DHU': return 6 + case 'N3U': return 7 + case 'CLIENT_SUBNET': return 8 + case 'EXPIRE': return 9 + case 'COOKIE': return 10 + case 'TCP_KEEPALIVE': return 11 + case 'PADDING': return 12 + case 'CHAIN': return 13 + case 'KEY_TAG': return 14 + case 'EDNS_ERROR': return 15 + case 'DEVICEID': return 26946 + case 'OPTION_65535': return 65535 + } + const m = name.match(/_(\d+)$/) + if (m) { + return parseInt(m[1], 10) + } + return -1 +} diff --git a/testing/xpcshell/dns-packet/package.json b/testing/xpcshell/dns-packet/package.json new file mode 100644 index 0000000000..31a859fc2b --- /dev/null +++ b/testing/xpcshell/dns-packet/package.json @@ -0,0 +1,48 @@ +{ + "name": "dns-packet", + "version": "5.2.1", + "description": "An abstract-encoding compliant module for encoding / decoding DNS packets", + "author": "Mathias Buus", + "license": "MIT", + "repository": "mafintosh/dns-packet", + "homepage": "https://github.com/mafintosh/dns-packet", + "engines": { + "node": ">=6" + }, + "scripts": { + "clean": "rm -rf coverage .nyc_output/", + "lint": "eslint --color *.js examples/*.js", + "pretest": "npm run lint", + "test": "tape test.js", + "coverage": "nyc -r html npm test" + }, + "dependencies": { + "ip": "^1.1.5" + }, + "devDependencies": { + "eslint": "^5.14.1", + "eslint-config-standard": "^12.0.0", + "eslint-plugin-import": "^2.16.0", + "eslint-plugin-node": "^8.0.1", + "eslint-plugin-promise": "^4.0.1", + "eslint-plugin-standard": "^4.0.0", + "nyc": "^13.3.0", + "tape": "^4.10.1" + }, + "keywords": [ + "dns", + "packet", + "encodings", + "encoding", + "encoder", + "abstract-encoding" + ], + "files": [ + "index.js", + "types.js", + "rcodes.js", + "opcodes.js", + "classes.js", + "optioncodes.js" + ] +} diff --git a/testing/xpcshell/dns-packet/rcodes.js b/testing/xpcshell/dns-packet/rcodes.js new file mode 100644 index 0000000000..0500887c2a --- /dev/null +++ b/testing/xpcshell/dns-packet/rcodes.js @@ -0,0 +1,50 @@ +'use strict' + +/* + * Traditional DNS header RCODEs (4-bits) defined by IANA in + * https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml + */ + +exports.toString = function (rcode) { + switch (rcode) { + case 0: return 'NOERROR' + case 1: return 'FORMERR' + case 2: return 'SERVFAIL' + case 3: return 'NXDOMAIN' + case 4: return 'NOTIMP' + case 5: return 'REFUSED' + case 6: return 'YXDOMAIN' + case 7: return 'YXRRSET' + case 8: return 'NXRRSET' + case 9: return 'NOTAUTH' + case 10: return 'NOTZONE' + case 11: return 'RCODE_11' + case 12: return 'RCODE_12' + case 13: return 'RCODE_13' + case 14: return 'RCODE_14' + case 15: return 'RCODE_15' + } + return 'RCODE_' + rcode +} + +exports.toRcode = function (code) { + switch (code.toUpperCase()) { + case 'NOERROR': return 0 + case 'FORMERR': return 1 + case 'SERVFAIL': return 2 + case 'NXDOMAIN': return 3 + case 'NOTIMP': return 4 + case 'REFUSED': return 5 + case 'YXDOMAIN': return 6 + case 'YXRRSET': return 7 + case 'NXRRSET': return 8 + case 'NOTAUTH': return 9 + case 'NOTZONE': return 10 + case 'RCODE_11': return 11 + case 'RCODE_12': return 12 + case 'RCODE_13': return 13 + case 'RCODE_14': return 14 + case 'RCODE_15': return 15 + } + return 0 +} diff --git a/testing/xpcshell/dns-packet/test.js b/testing/xpcshell/dns-packet/test.js new file mode 100644 index 0000000000..adf4757dae --- /dev/null +++ b/testing/xpcshell/dns-packet/test.js @@ -0,0 +1,613 @@ +'use strict' + +const tape = require('tape') +const packet = require('./') +const rcodes = require('./rcodes') +const opcodes = require('./opcodes') +const optioncodes = require('./optioncodes') + +tape('unknown', function (t) { + testEncoder(t, packet.unknown, Buffer.from('hello world')) + t.end() +}) + +tape('txt', function (t) { + testEncoder(t, packet.txt, []) + testEncoder(t, packet.txt, ['hello world']) + testEncoder(t, packet.txt, ['hello', 'world']) + testEncoder(t, packet.txt, [Buffer.from([0, 1, 2, 3, 4, 5])]) + testEncoder(t, packet.txt, ['a', 'b', Buffer.from([0, 1, 2, 3, 4, 5])]) + testEncoder(t, packet.txt, ['', Buffer.allocUnsafe(0)]) + t.end() +}) + +tape('txt-scalar-string', function (t) { + const buf = packet.txt.encode('hi') + const val = packet.txt.decode(buf) + t.ok(val.length === 1, 'array length') + t.ok(val[0].toString() === 'hi', 'data') + t.end() +}) + +tape('txt-scalar-buffer', function (t) { + const data = Buffer.from([0, 1, 2, 3, 4, 5]) + const buf = packet.txt.encode(data) + const val = packet.txt.decode(buf) + t.ok(val.length === 1, 'array length') + t.ok(val[0].equals(data), 'data') + t.end() +}) + +tape('txt-invalid-data', function (t) { + t.throws(function () { packet.txt.encode(null) }, 'null') + t.throws(function () { packet.txt.encode(undefined) }, 'undefined') + t.throws(function () { packet.txt.encode(10) }, 'number') + t.end() +}) + +tape('null', function (t) { + testEncoder(t, packet.null, Buffer.from([0, 1, 2, 3, 4, 5])) + t.end() +}) + +tape('hinfo', function (t) { + testEncoder(t, packet.hinfo, { cpu: 'intel', os: 'best one' }) + t.end() +}) + +tape('ptr', function (t) { + testEncoder(t, packet.ptr, 'hello.world.com') + t.end() +}) + +tape('cname', function (t) { + testEncoder(t, packet.cname, 'hello.cname.world.com') + t.end() +}) + +tape('dname', function (t) { + testEncoder(t, packet.dname, 'hello.dname.world.com') + t.end() +}) + +tape('srv', function (t) { + testEncoder(t, packet.srv, { port: 9999, target: 'hello.world.com' }) + testEncoder(t, packet.srv, { port: 9999, target: 'hello.world.com', priority: 42, weight: 10 }) + t.end() +}) + +tape('caa', function (t) { + testEncoder(t, packet.caa, { flags: 128, tag: 'issue', value: 'letsencrypt.org', issuerCritical: true }) + testEncoder(t, packet.caa, { tag: 'issue', value: 'letsencrypt.org', issuerCritical: true }) + testEncoder(t, packet.caa, { tag: 'issue', value: 'letsencrypt.org' }) + t.end() +}) + +tape('mx', function (t) { + testEncoder(t, packet.mx, { preference: 10, exchange: 'mx.hello.world.com' }) + testEncoder(t, packet.mx, { exchange: 'mx.hello.world.com' }) + t.end() +}) + +tape('ns', function (t) { + testEncoder(t, packet.ns, 'ns.world.com') + t.end() +}) + +tape('soa', function (t) { + testEncoder(t, packet.soa, { + mname: 'hello.world.com', + rname: 'root.hello.world.com', + serial: 2018010400, + refresh: 14400, + retry: 3600, + expire: 604800, + minimum: 3600 + }) + t.end() +}) + +tape('a', function (t) { + testEncoder(t, packet.a, '127.0.0.1') + t.end() +}) + +tape('aaaa', function (t) { + testEncoder(t, packet.aaaa, 'fe80::1') + t.end() +}) + +tape('query', function (t) { + testEncoder(t, packet, { + type: 'query', + questions: [{ + type: 'A', + name: 'hello.a.com' + }, { + type: 'SRV', + name: 'hello.srv.com' + }] + }) + + testEncoder(t, packet, { + type: 'query', + id: 42, + questions: [{ + type: 'A', + class: 'IN', + name: 'hello.a.com' + }, { + type: 'SRV', + name: 'hello.srv.com' + }] + }) + + testEncoder(t, packet, { + type: 'query', + id: 42, + questions: [{ + type: 'A', + class: 'CH', + name: 'hello.a.com' + }, { + type: 'SRV', + name: 'hello.srv.com' + }] + }) + + t.end() +}) + +tape('response', function (t) { + testEncoder(t, packet, { + type: 'response', + answers: [{ + type: 'A', + class: 'IN', + flush: true, + name: 'hello.a.com', + data: '127.0.0.1' + }] + }) + + testEncoder(t, packet, { + type: 'response', + flags: packet.TRUNCATED_RESPONSE, + answers: [{ + type: 'A', + class: 'IN', + name: 'hello.a.com', + data: '127.0.0.1' + }, { + type: 'SRV', + class: 'IN', + name: 'hello.srv.com', + data: { + port: 9090, + target: 'hello.target.com' + } + }, { + type: 'CNAME', + class: 'IN', + name: 'hello.cname.com', + data: 'hello.other.domain.com' + }] + }) + + testEncoder(t, packet, { + type: 'response', + id: 100, + flags: 0, + additionals: [{ + type: 'AAAA', + name: 'hello.a.com', + data: 'fe80::1' + }, { + type: 'PTR', + name: 'hello.ptr.com', + data: 'hello.other.ptr.com' + }, { + type: 'SRV', + name: 'hello.srv.com', + ttl: 42, + data: { + port: 9090, + target: 'hello.target.com' + } + }], + answers: [{ + type: 'NULL', + name: 'hello.null.com', + data: Buffer.from([1, 2, 3, 4, 5]) + }] + }) + + testEncoder(t, packet, { + type: 'response', + answers: [{ + type: 'TXT', + name: 'emptytxt.com', + data: '' + }] + }) + + t.end() +}) + +tape('rcode', function (t) { + const errors = ['NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED', 'YXDOMAIN', 'YXRRSET', 'NXRRSET', 'NOTAUTH', 'NOTZONE', 'RCODE_11', 'RCODE_12', 'RCODE_13', 'RCODE_14', 'RCODE_15'] + for (const i in errors) { + const code = rcodes.toRcode(errors[i]) + t.ok(errors[i] === rcodes.toString(code), 'rcode conversion from/to string matches: ' + rcodes.toString(code)) + } + + const ops = ['QUERY', 'IQUERY', 'STATUS', 'OPCODE_3', 'NOTIFY', 'UPDATE', 'OPCODE_6', 'OPCODE_7', 'OPCODE_8', 'OPCODE_9', 'OPCODE_10', 'OPCODE_11', 'OPCODE_12', 'OPCODE_13', 'OPCODE_14', 'OPCODE_15'] + for (const j in ops) { + const ocode = opcodes.toOpcode(ops[j]) + t.ok(ops[j] === opcodes.toString(ocode), 'opcode conversion from/to string matches: ' + opcodes.toString(ocode)) + } + + const buf = packet.encode({ + type: 'response', + id: 45632, + flags: 0x8480, + answers: [{ + type: 'A', + name: 'hello.example.net', + data: '127.0.0.1' + }] + }) + const val = packet.decode(buf) + t.ok(val.type === 'response', 'decode type') + t.ok(val.opcode === 'QUERY', 'decode opcode') + t.ok(val.flag_qr === true, 'decode flag_qr') + t.ok(val.flag_aa === true, 'decode flag_aa') + t.ok(val.flag_tc === false, 'decode flag_tc') + t.ok(val.flag_rd === false, 'decode flag_rd') + t.ok(val.flag_ra === true, 'decode flag_ra') + t.ok(val.flag_z === false, 'decode flag_z') + t.ok(val.flag_ad === false, 'decode flag_ad') + t.ok(val.flag_cd === false, 'decode flag_cd') + t.ok(val.rcode === 'NOERROR', 'decode rcode') + t.end() +}) + +tape('name_encoding', function (t) { + let data = 'foo.example.com' + const buf = Buffer.allocUnsafe(255) + let offset = 0 + packet.name.encode(data, buf, offset) + t.ok(packet.name.encode.bytes === 17, 'name encoding length matches') + let dd = packet.name.decode(buf, offset) + t.ok(data === dd, 'encode/decode matches') + offset += packet.name.encode.bytes + + data = 'com' + packet.name.encode(data, buf, offset) + t.ok(packet.name.encode.bytes === 5, 'name encoding length matches') + dd = packet.name.decode(buf, offset) + t.ok(data === dd, 'encode/decode matches') + offset += packet.name.encode.bytes + + data = 'example.com.' + packet.name.encode(data, buf, offset) + t.ok(packet.name.encode.bytes === 13, 'name encoding length matches') + dd = packet.name.decode(buf, offset) + t.ok(data.slice(0, -1) === dd, 'encode/decode matches') + offset += packet.name.encode.bytes + + data = '.' + packet.name.encode(data, buf, offset) + t.ok(packet.name.encode.bytes === 1, 'name encoding length matches') + dd = packet.name.decode(buf, offset) + t.ok(data === dd, 'encode/decode matches') + t.end() +}) + +tape('stream', function (t) { + const val = { + type: 'query', + id: 45632, + flags: 0x8480, + answers: [{ + type: 'A', + name: 'test2.example.net', + data: '198.51.100.1' + }] + } + const buf = packet.streamEncode(val) + const val2 = packet.streamDecode(buf) + + t.same(buf.length, packet.streamEncode.bytes, 'streamEncode.bytes was set correctly') + t.ok(compare(t, val2.type, val.type), 'streamDecoded type match') + t.ok(compare(t, val2.id, val.id), 'streamDecoded id match') + t.ok(parseInt(val2.flags) === parseInt(val.flags & 0x7FFF), 'streamDecoded flags match') + const answer = val.answers[0] + const answer2 = val2.answers[0] + t.ok(compare(t, answer.type, answer2.type), 'streamDecoded RR type match') + t.ok(compare(t, answer.name, answer2.name), 'streamDecoded RR name match') + t.ok(compare(t, answer.data, answer2.data), 'streamDecoded RR rdata match') + t.end() +}) + +tape('opt', function (t) { + const val = { + type: 'query', + questions: [{ + type: 'A', + name: 'hello.a.com' + }], + additionals: [{ + type: 'OPT', + name: '.', + udpPayloadSize: 1024 + }] + } + testEncoder(t, packet, val) + let buf = packet.encode(val) + let val2 = packet.decode(buf) + const additional1 = val.additionals[0] + let additional2 = val2.additionals[0] + t.ok(compare(t, additional1.name, additional2.name), 'name matches') + t.ok(compare(t, additional1.udpPayloadSize, additional2.udpPayloadSize), 'udp payload size matches') + t.ok(compare(t, 0, additional2.flags), 'flags match') + additional1.flags = packet.DNSSEC_OK + additional1.extendedRcode = 0x80 + additional1.options = [ { + code: 'CLIENT_SUBNET', // edns-client-subnet, see RFC 7871 + ip: 'fe80::', + sourcePrefixLength: 64 + }, { + code: 8, // still ECS + ip: '5.6.0.0', + sourcePrefixLength: 16, + scopePrefixLength: 16 + }, { + code: 'padding', + length: 31 + }, { + code: 'TCP_KEEPALIVE' + }, { + code: 'tcp_keepalive', + timeout: 150 + }, { + code: 'KEY_TAG', + tags: [1, 82, 987] + }] + buf = packet.encode(val) + val2 = packet.decode(buf) + additional2 = val2.additionals[0] + t.ok(compare(t, 1 << 15, additional2.flags), 'DO bit set in flags') + t.ok(compare(t, true, additional2.flag_do), 'DO bit set') + t.ok(compare(t, additional1.extendedRcode, additional2.extendedRcode), 'extended rcode matches') + t.ok(compare(t, 8, additional2.options[0].code)) + t.ok(compare(t, 'fe80::', additional2.options[0].ip)) + t.ok(compare(t, 64, additional2.options[0].sourcePrefixLength)) + t.ok(compare(t, '5.6.0.0', additional2.options[1].ip)) + t.ok(compare(t, 16, additional2.options[1].sourcePrefixLength)) + t.ok(compare(t, 16, additional2.options[1].scopePrefixLength)) + t.ok(compare(t, additional1.options[2].length, additional2.options[2].data.length)) + t.ok(compare(t, additional1.options[3].timeout, undefined)) + t.ok(compare(t, additional1.options[4].timeout, additional2.options[4].timeout)) + t.ok(compare(t, additional1.options[5].tags, additional2.options[5].tags)) + t.end() +}) + +tape('dnskey', function (t) { + testEncoder(t, packet.dnskey, { + flags: packet.dnskey.SECURE_ENTRYPOINT | packet.dnskey.ZONE_KEY, + algorithm: 1, + key: Buffer.from([0, 1, 2, 3, 4, 5]) + }) + t.end() +}) + +tape('rrsig', function (t) { + const testRRSIG = { + typeCovered: 'A', + algorithm: 1, + labels: 2, + originalTTL: 3600, + expiration: 1234, + inception: 1233, + keyTag: 2345, + signersName: 'foo.com', + signature: Buffer.from([0, 1, 2, 3, 4, 5]) + } + testEncoder(t, packet.rrsig, testRRSIG) + + // Check the signature length is correct with extra junk at the end + const buf = Buffer.allocUnsafe(packet.rrsig.encodingLength(testRRSIG) + 4) + packet.rrsig.encode(testRRSIG, buf) + const val2 = packet.rrsig.decode(buf) + t.ok(compare(t, testRRSIG, val2)) + + t.end() +}) + +tape('rrp', function (t) { + testEncoder(t, packet.rp, { + mbox: 'foo.bar.com', + txt: 'baz.bar.com' + }) + testEncoder(t, packet.rp, { + mbox: 'foo.bar.com' + }) + testEncoder(t, packet.rp, { + txt: 'baz.bar.com' + }) + testEncoder(t, packet.rp, {}) + t.end() +}) + +tape('nsec', function (t) { + testEncoder(t, packet.nsec, { + nextDomain: 'foo.com', + rrtypes: ['A', 'DNSKEY', 'CAA', 'DLV'] + }) + testEncoder(t, packet.nsec, { + nextDomain: 'foo.com', + rrtypes: ['TXT'] // 16 + }) + testEncoder(t, packet.nsec, { + nextDomain: 'foo.com', + rrtypes: ['TKEY'] // 249 + }) + testEncoder(t, packet.nsec, { + nextDomain: 'foo.com', + rrtypes: ['RRSIG', 'NSEC'] + }) + testEncoder(t, packet.nsec, { + nextDomain: 'foo.com', + rrtypes: ['TXT', 'RRSIG'] + }) + testEncoder(t, packet.nsec, { + nextDomain: 'foo.com', + rrtypes: ['TXT', 'NSEC'] + }) + + // Test with the sample NSEC from https://tools.ietf.org/html/rfc4034#section-4.3 + var sampleNSEC = Buffer.from('003704686f7374076578616d706c6503636f6d00' + + '0006400100000003041b000000000000000000000000000000000000000000000' + + '000000020', 'hex') + var decoded = packet.nsec.decode(sampleNSEC) + t.ok(compare(t, decoded, { + nextDomain: 'host.example.com', + rrtypes: ['A', 'MX', 'RRSIG', 'NSEC', 'UNKNOWN_1234'] + })) + var reencoded = packet.nsec.encode(decoded) + t.same(sampleNSEC.length, reencoded.length) + t.same(sampleNSEC, reencoded) + t.end() +}) + +tape('nsec3', function (t) { + testEncoder(t, packet.nsec3, { + algorithm: 1, + flags: 0, + iterations: 257, + salt: Buffer.from([42, 42, 42]), + nextDomain: Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]), + rrtypes: ['A', 'DNSKEY', 'CAA', 'DLV'] + }) + t.end() +}) + +tape('ds', function (t) { + testEncoder(t, packet.ds, { + keyTag: 1234, + algorithm: 1, + digestType: 1, + digest: Buffer.from([0, 1, 2, 3, 4, 5]) + }) + t.end() +}) + +tape('unpack', function (t) { + const buf = Buffer.from([ + 0x00, 0x79, + 0xde, 0xad, 0x85, 0x00, 0x00, 0x01, 0x00, 0x01, + 0x00, 0x02, 0x00, 0x02, 0x02, 0x6f, 0x6a, 0x05, + 0x62, 0x61, 0x6e, 0x67, 0x6a, 0x03, 0x63, 0x6f, + 0x6d, 0x00, 0x00, 0x01, 0x00, 0x01, 0xc0, 0x0c, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x0e, 0x10, + 0x00, 0x04, 0x81, 0xfa, 0x0b, 0xaa, 0xc0, 0x0f, + 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x0e, 0x10, + 0x00, 0x05, 0x02, 0x63, 0x6a, 0xc0, 0x0f, 0xc0, + 0x0f, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x0e, + 0x10, 0x00, 0x02, 0xc0, 0x0c, 0xc0, 0x3a, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, 0x0e, 0x10, 0x00, + 0x04, 0x45, 0x4d, 0x9b, 0x9c, 0xc0, 0x0c, 0x00, + 0x1c, 0x00, 0x01, 0x00, 0x00, 0x0e, 0x10, 0x00, + 0x10, 0x20, 0x01, 0x04, 0x18, 0x00, 0x00, 0x50, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf9 + ]) + const val = packet.streamDecode(buf) + const answer = val.answers[0] + const authority = val.authorities[1] + t.ok(val.rcode === 'NOERROR', 'decode rcode') + t.ok(compare(t, answer.type, 'A'), 'streamDecoded RR type match') + t.ok(compare(t, answer.name, 'oj.bangj.com'), 'streamDecoded RR name match') + t.ok(compare(t, answer.data, '129.250.11.170'), 'streamDecoded RR rdata match') + t.ok(compare(t, authority.type, 'NS'), 'streamDecoded RR type match') + t.ok(compare(t, authority.name, 'bangj.com'), 'streamDecoded RR name match') + t.ok(compare(t, authority.data, 'oj.bangj.com'), 'streamDecoded RR rdata match') + t.end() +}) + +tape('optioncodes', function (t) { + const opts = [ + [0, 'OPTION_0'], + [1, 'LLQ'], + [2, 'UL'], + [3, 'NSID'], + [4, 'OPTION_4'], + [5, 'DAU'], + [6, 'DHU'], + [7, 'N3U'], + [8, 'CLIENT_SUBNET'], + [9, 'EXPIRE'], + [10, 'COOKIE'], + [11, 'TCP_KEEPALIVE'], + [12, 'PADDING'], + [13, 'CHAIN'], + [14, 'KEY_TAG'], + [26946, 'DEVICEID'], + [65535, 'OPTION_65535'], + [64000, 'OPTION_64000'], + [65002, 'OPTION_65002'], + [-1, null] + ] + for (const [code, str] of opts) { + const s = optioncodes.toString(code) + t.ok(compare(t, s, str), `${code} => ${str}`) + t.ok(compare(t, optioncodes.toCode(s), code), `${str} => ${code}`) + } + t.ok(compare(t, optioncodes.toCode('INVALIDINVALID'), -1)) + t.end() +}) + +function testEncoder (t, rpacket, val) { + const buf = rpacket.encode(val) + const val2 = rpacket.decode(buf) + + t.same(buf.length, rpacket.encode.bytes, 'encode.bytes was set correctly') + t.same(buf.length, rpacket.encodingLength(val), 'encoding length matches') + t.ok(compare(t, val, val2), 'decoded object match') + + const buf2 = rpacket.encode(val2) + const val3 = rpacket.decode(buf2) + + t.same(buf2.length, rpacket.encode.bytes, 'encode.bytes was set correctly on re-encode') + t.same(buf2.length, rpacket.encodingLength(val), 'encoding length matches on re-encode') + + t.ok(compare(t, val, val3), 'decoded object match on re-encode') + t.ok(compare(t, val2, val3), 're-encoded decoded object match on re-encode') + + const bigger = Buffer.allocUnsafe(buf2.length + 10) + + const buf3 = rpacket.encode(val, bigger, 10) + const val4 = rpacket.decode(buf3, 10) + + t.ok(buf3 === bigger, 'echoes buffer on external buffer') + t.same(rpacket.encode.bytes, buf.length, 'encode.bytes is the same on external buffer') + t.ok(compare(t, val, val4), 'decoded object match on external buffer') +} + +function compare (t, a, b) { + if (Buffer.isBuffer(a)) return a.toString('hex') === b.toString('hex') + if (typeof a === 'object' && a && b) { + const keys = Object.keys(a) + for (let i = 0; i < keys.length; i++) { + if (!compare(t, a[keys[i]], b[keys[i]])) { + return false + } + } + } else if (Array.isArray(b) && !Array.isArray(a)) { + // TXT always decode as array + return a.toString() === b[0].toString() + } else { + return a === b + } + return true +} diff --git a/testing/xpcshell/dns-packet/types.js b/testing/xpcshell/dns-packet/types.js new file mode 100644 index 0000000000..110705b160 --- /dev/null +++ b/testing/xpcshell/dns-packet/types.js @@ -0,0 +1,105 @@ +'use strict' + +exports.toString = function (type) { + switch (type) { + case 1: return 'A' + case 10: return 'NULL' + case 28: return 'AAAA' + case 18: return 'AFSDB' + case 42: return 'APL' + case 257: return 'CAA' + case 60: return 'CDNSKEY' + case 59: return 'CDS' + case 37: return 'CERT' + case 5: return 'CNAME' + case 49: return 'DHCID' + case 32769: return 'DLV' + case 39: return 'DNAME' + case 48: return 'DNSKEY' + case 43: return 'DS' + case 55: return 'HIP' + case 13: return 'HINFO' + case 45: return 'IPSECKEY' + case 25: return 'KEY' + case 36: return 'KX' + case 29: return 'LOC' + case 15: return 'MX' + case 35: return 'NAPTR' + case 2: return 'NS' + case 47: return 'NSEC' + case 50: return 'NSEC3' + case 51: return 'NSEC3PARAM' + case 12: return 'PTR' + case 46: return 'RRSIG' + case 17: return 'RP' + case 24: return 'SIG' + case 6: return 'SOA' + case 99: return 'SPF' + case 33: return 'SRV' + case 44: return 'SSHFP' + case 32768: return 'TA' + case 249: return 'TKEY' + case 52: return 'TLSA' + case 250: return 'TSIG' + case 16: return 'TXT' + case 252: return 'AXFR' + case 251: return 'IXFR' + case 41: return 'OPT' + case 255: return 'ANY' + case 65: return 'HTTPS' + } + return 'UNKNOWN_' + type +} + +exports.toType = function (name) { + switch (name.toUpperCase()) { + case 'A': return 1 + case 'NULL': return 10 + case 'AAAA': return 28 + case 'AFSDB': return 18 + case 'APL': return 42 + case 'CAA': return 257 + case 'CDNSKEY': return 60 + case 'CDS': return 59 + case 'CERT': return 37 + case 'CNAME': return 5 + case 'DHCID': return 49 + case 'DLV': return 32769 + case 'DNAME': return 39 + case 'DNSKEY': return 48 + case 'DS': return 43 + case 'HIP': return 55 + case 'HINFO': return 13 + case 'IPSECKEY': return 45 + case 'KEY': return 25 + case 'KX': return 36 + case 'LOC': return 29 + case 'MX': return 15 + case 'NAPTR': return 35 + case 'NS': return 2 + case 'NSEC': return 47 + case 'NSEC3': return 50 + case 'NSEC3PARAM': return 51 + case 'PTR': return 12 + case 'RRSIG': return 46 + case 'RP': return 17 + case 'SIG': return 24 + case 'SOA': return 6 + case 'SPF': return 99 + case 'SRV': return 33 + case 'SSHFP': return 44 + case 'TA': return 32768 + case 'TKEY': return 249 + case 'TLSA': return 52 + case 'TSIG': return 250 + case 'TXT': return 16 + case 'AXFR': return 252 + case 'IXFR': return 251 + case 'OPT': return 41 + case 'ANY': return 255 + case 'HTTPS': return 65 + case '*': return 255 + } + if (name.toUpperCase().startsWith('UNKNOWN_')) return parseInt(name.slice(8)) + return 0 +} diff --git a/testing/xpcshell/example/moz.build b/testing/xpcshell/example/moz.build new file mode 100644 index 0000000000..33b544a134 --- /dev/null +++ b/testing/xpcshell/example/moz.build @@ -0,0 +1,12 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# This is a list of directories containing tests to run, separated by spaces. +# Most likely, tho, you won't use more than one directory here. +XPCSHELL_TESTS_MANIFESTS += [ + "unit/xpcshell-with-prefs.ini", + "unit/xpcshell.ini", +] diff --git a/testing/xpcshell/example/unit/check_profile.js b/testing/xpcshell/example/unit/check_profile.js new file mode 100644 index 0000000000..57ecf5fd55 --- /dev/null +++ b/testing/xpcshell/example/unit/check_profile.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function check_profile_dir(profd) { + Assert.ok(profd.exists()); + Assert.ok(profd.isDirectory()); + let profd2 = Services.dirsvc.get("ProfD", Ci.nsIFile); + Assert.ok(profd2.exists()); + Assert.ok(profd2.isDirectory()); + // make sure we got the same thing back... + Assert.ok(profd.equals(profd2)); +} + +function check_do_get_profile(fireProfileAfterChange) { + const observedTopics = new Map([ + ["profile-do-change", 0], + ["profile-after-change", 0], + ]); + const expectedTopics = new Map(observedTopics); + + for (let [topic] of observedTopics) { + Services.obs.addObserver(() => { + let val = observedTopics.get(topic) + 1; + observedTopics.set(topic, val); + }, topic); + } + + // Trigger profile creation. + let profd = do_get_profile(); + check_profile_dir(profd); + + // Check the observed topics + expectedTopics.set("profile-do-change", 1); + if (fireProfileAfterChange) { + expectedTopics.set("profile-after-change", 1); + } + Assert.deepEqual(observedTopics, expectedTopics); + + // A second do_get_profile() should not trigger more notifications. + profd = do_get_profile(); + check_profile_dir(profd); + Assert.deepEqual(observedTopics, expectedTopics); +} diff --git a/testing/xpcshell/example/unit/file.txt b/testing/xpcshell/example/unit/file.txt new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/testing/xpcshell/example/unit/file.txt @@ -0,0 +1 @@ +hello diff --git a/testing/xpcshell/example/unit/import_module.sys.mjs b/testing/xpcshell/example/unit/import_module.sys.mjs new file mode 100644 index 0000000000..aba93afc86 --- /dev/null +++ b/testing/xpcshell/example/unit/import_module.sys.mjs @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Module used by test_import_module.js + +export const MODULE_IMPORTED = true; + +export const MODULE_URI = import.meta.url; diff --git a/testing/xpcshell/example/unit/load_subscript.js b/testing/xpcshell/example/unit/load_subscript.js new file mode 100644 index 0000000000..bb0c4400b3 --- /dev/null +++ b/testing/xpcshell/example/unit/load_subscript.js @@ -0,0 +1,6 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals subscriptLoaded:true */ +subscriptLoaded = true; diff --git a/testing/xpcshell/example/unit/location_load.js b/testing/xpcshell/example/unit/location_load.js new file mode 100644 index 0000000000..c198b2e7de --- /dev/null +++ b/testing/xpcshell/example/unit/location_load.js @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals __LOCATION__ */ + +// Gets loaded via test_location.js +Assert.equal(__LOCATION__.leafName, "location_load.js"); diff --git a/testing/xpcshell/example/unit/prefs_test_common.js b/testing/xpcshell/example/unit/prefs_test_common.js new file mode 100644 index 0000000000..e12d3d5298 --- /dev/null +++ b/testing/xpcshell/example/unit/prefs_test_common.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function isValidPref(prefName) { + return Services.prefs.getPrefType(prefName) !== Services.prefs.PREF_INVALID; +} + +// Check a pref that appears in testing/profiles/xpcshell/user.js +// but NOT in StaticPrefList.yaml, modules/libpref/init/all.js +function has_pref_from_xpcshell_user_js() { + return isValidPref("extensions.webextensions.warnings-as-errors"); +} + +// Test pref from xpcshell-with-prefs.ini +function has_pref_from_manifest_defaults() { + return isValidPref("dummy.pref.from.test.manifest"); +} + +// Test pref set in xpcshell.ini and xpcshell-with-prefs.ini +function has_pref_from_manifest_file_section() { + return isValidPref("dummy.pref.from.test.file"); +} + +function check_common_xpcshell_with_prefs() { + Assert.ok( + has_pref_from_xpcshell_user_js(), + "Should have pref from xpcshell's user.js" + ); + + Assert.ok( + has_pref_from_manifest_defaults(), + "Should have pref from DEFAULTS in xpcshell-with-prefs.ini" + ); +} + +function check_common_xpcshell_without_prefs() { + Assert.ok( + has_pref_from_xpcshell_user_js(), + "Should have pref from xpcshell's user.js" + ); + + Assert.ok( + !has_pref_from_manifest_defaults(), + "xpcshell.ini did not set any prefs in DEFAULTS" + ); +} diff --git a/testing/xpcshell/example/unit/subdir/file.txt b/testing/xpcshell/example/unit/subdir/file.txt new file mode 100644 index 0000000000..c4f6b5f708 --- /dev/null +++ b/testing/xpcshell/example/unit/subdir/file.txt @@ -0,0 +1 @@ +subdir hello diff --git a/testing/xpcshell/example/unit/test_add_setup.js b/testing/xpcshell/example/unit/test_add_setup.js new file mode 100644 index 0000000000..a647f108ee --- /dev/null +++ b/testing/xpcshell/example/unit/test_add_setup.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let someVar = 1; + +add_task(() => { + Assert.ok(false, "I should not be called!"); +}); + +/* eslint-disable mozilla/reject-addtask-only */ +add_task(() => { + Assert.equal( + someVar, + 2, + "Setup should have run, even though this is the only test." + ); +}).only(); + +add_setup(() => { + someVar = 2; +}); diff --git a/testing/xpcshell/example/unit/test_check_nsIException.js b/testing/xpcshell/example/unit/test_check_nsIException.js new file mode 100644 index 0000000000..b496551cee --- /dev/null +++ b/testing/xpcshell/example/unit/test_check_nsIException.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../head.js */ + +function run_test() { + do_check_throws_nsIException(function () { + Services.env.QueryInterface(Ci.nsIFile); + }, "NS_NOINTERFACE"); +} diff --git a/testing/xpcshell/example/unit/test_check_nsIException_failing.js b/testing/xpcshell/example/unit/test_check_nsIException_failing.js new file mode 100644 index 0000000000..5cee188e9a --- /dev/null +++ b/testing/xpcshell/example/unit/test_check_nsIException_failing.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../head.js */ + +function run_test() { + do_check_throws_nsIException(function () { + throw Error("I find your relaxed dishabille unpalatable"); + }, "NS_NOINTERFACE"); +} diff --git a/testing/xpcshell/example/unit/test_do_check_matches.js b/testing/xpcshell/example/unit/test_do_check_matches.js new file mode 100644 index 0000000000..44ef0096fc --- /dev/null +++ b/testing/xpcshell/example/unit/test_do_check_matches.js @@ -0,0 +1,14 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + Assert.deepEqual({ x: 1 }, { x: 1 }); + + // Property order is irrelevant. + Assert.deepEqual({ x: "foo", y: "bar" }, { y: "bar", x: "foo" }); // pass + + // Patterns nest. + Assert.deepEqual({ a: 1, b: { c: 2, d: 3 } }, { a: 1, b: { c: 2, d: 3 } }); + + Assert.deepEqual([3, 4, 5], [3, 4, 5]); +} diff --git a/testing/xpcshell/example/unit/test_do_check_matches_failing.js b/testing/xpcshell/example/unit/test_do_check_matches_failing.js new file mode 100644 index 0000000000..c45ec3469b --- /dev/null +++ b/testing/xpcshell/example/unit/test_do_check_matches_failing.js @@ -0,0 +1,12 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + Assert.deepEqual({ x: 1 }, {}); // fail: all pattern props required + Assert.deepEqual({ x: 1 }, { x: 2 }); // fail: values must match + Assert.deepEqual({ x: undefined }, {}); + + // 'length' property counts, even if non-enumerable. + Assert.deepEqual([3, 4, 5], [3, 5, 5]); // fail; value doesn't match + Assert.deepEqual([3, 4, 5], [3, 4, 5, 6]); // fail; length doesn't match +} diff --git a/testing/xpcshell/example/unit/test_do_check_null.js b/testing/xpcshell/example/unit/test_do_check_null.js new file mode 100644 index 0000000000..97ad824353 --- /dev/null +++ b/testing/xpcshell/example/unit/test_do_check_null.js @@ -0,0 +1,6 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + Assert.equal(null, null); +} diff --git a/testing/xpcshell/example/unit/test_do_check_null_failing.js b/testing/xpcshell/example/unit/test_do_check_null_failing.js new file mode 100644 index 0000000000..981f5c838c --- /dev/null +++ b/testing/xpcshell/example/unit/test_do_check_null_failing.js @@ -0,0 +1,6 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + Assert.equal(null, 0); +} diff --git a/testing/xpcshell/example/unit/test_do_get_tempdir.js b/testing/xpcshell/example/unit/test_do_get_tempdir.js new file mode 100644 index 0000000000..31c061f741 --- /dev/null +++ b/testing/xpcshell/example/unit/test_do_get_tempdir.js @@ -0,0 +1,14 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This tests that do_get_tempdir returns a directory that we can write to. */ + +function run_test() { + let tmpd = do_get_tempdir(); + Assert.ok(tmpd.exists()); + tmpd.append("testfile"); + tmpd.create(Ci.nsIFile.NORMAL_FILE_TYPE, 600); + Assert.ok(tmpd.exists()); +} diff --git a/testing/xpcshell/example/unit/test_execute_soon.js b/testing/xpcshell/example/unit/test_execute_soon.js new file mode 100644 index 0000000000..e5492e9f9d --- /dev/null +++ b/testing/xpcshell/example/unit/test_execute_soon.js @@ -0,0 +1,20 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* ***** BEGIN LICENSE BLOCK ***** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/licenses/publicdomain/ + * ***** END LICENSE BLOCK ***** */ + +var complete = false; + +function run_test() { + dump("Starting test\n"); + registerCleanupFunction(function () { + dump("Checking test completed\n"); + Assert.ok(complete); + }); + + executeSoon(function execute_soon_callback() { + dump("do_execute_soon callback\n"); + complete = true; + }); +} diff --git a/testing/xpcshell/example/unit/test_fail.js b/testing/xpcshell/example/unit/test_fail.js new file mode 100644 index 0000000000..0c203cd82e --- /dev/null +++ b/testing/xpcshell/example/unit/test_fail.js @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + // This test expects to fail. + Assert.ok(false); +} diff --git a/testing/xpcshell/example/unit/test_get_file.js b/testing/xpcshell/example/unit/test_get_file.js new file mode 100644 index 0000000000..213c9e7233 --- /dev/null +++ b/testing/xpcshell/example/unit/test_get_file.js @@ -0,0 +1,31 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + var lf = do_get_file("file.txt"); + Assert.ok(lf.exists()); + Assert.ok(lf.isFile()); + // check that allowNonexistent works + lf = do_get_file("file.txt.notfound", true); + Assert.ok(!lf.exists()); + // check that we can get a file from a subdirectory + lf = do_get_file("subdir/file.txt"); + Assert.ok(lf.exists()); + Assert.ok(lf.isFile()); + // and that we can get a handle to a directory itself + lf = do_get_file("subdir/"); + Assert.ok(lf.exists()); + Assert.ok(lf.isDirectory()); + // check that we can go up a level + lf = do_get_file(".."); + Assert.ok(lf.exists()); + lf.append("unit"); + lf.append("file.txt"); + Assert.ok(lf.exists()); + // check that do_get_cwd works + lf = do_get_cwd(); + Assert.ok(lf.exists()); + Assert.ok(lf.isDirectory()); +} diff --git a/testing/xpcshell/example/unit/test_get_idle.js b/testing/xpcshell/example/unit/test_get_idle.js new file mode 100644 index 0000000000..ea01ce0247 --- /dev/null +++ b/testing/xpcshell/example/unit/test_get_idle.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function run_test() { + print("Init the fake idle service and check its identity."); + let fakeIdleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + try { + fakeIdleService.QueryInterface(Ci.nsIFactory); + } catch (ex) { + do_throw("The fake idle service implements nsIFactory."); + } + // We need at least one PASS, thus sanity check the idle time. + Assert.equal(fakeIdleService.idleTime, 0); + + print("Init the real idle service and check its identity."); + let realIdleService = do_get_idle(); + try { + realIdleService.QueryInterface(Ci.nsIFactory); + do_throw("The real idle service does not implement nsIFactory."); + } catch (ex) {} +} diff --git a/testing/xpcshell/example/unit/test_import_module.js b/testing/xpcshell/example/unit/test_import_module.js new file mode 100644 index 0000000000..089ec34f8d --- /dev/null +++ b/testing/xpcshell/example/unit/test_import_module.js @@ -0,0 +1,19 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Ensures that tests can import a module in the same folder through: + * ChromeUtils.importESModule("resource://test/module.jsm"); + */ + +function run_test() { + Assert.ok(typeof this.MODULE_IMPORTED == "undefined"); + Assert.ok(typeof this.MODULE_URI == "undefined"); + let uri = "resource://test/import_module.sys.mjs"; + let exports = ChromeUtils.importESModule(uri); + Assert.ok(exports.MODULE_URI == uri); + Assert.ok(exports.MODULE_IMPORTED); +} diff --git a/testing/xpcshell/example/unit/test_load.js b/testing/xpcshell/example/unit/test_load.js new file mode 100644 index 0000000000..ecac04e3ac --- /dev/null +++ b/testing/xpcshell/example/unit/test_load.js @@ -0,0 +1,23 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var subscriptLoaded = false; + +function run_test() { + load("load_subscript.js"); + Assert.ok(subscriptLoaded); + subscriptLoaded = false; + try { + load("file_that_does_not_exist.js"); + subscriptLoaded = true; + } catch (ex) { + Assert.ok( + ex.message.startsWith("can't open "), + `Unexpected message: ${ex.message}` + ); + } + Assert.ok(!subscriptLoaded, "load() should throw an error"); +} diff --git a/testing/xpcshell/example/unit/test_load_httpd_js.js b/testing/xpcshell/example/unit/test_load_httpd_js.js new file mode 100644 index 0000000000..03a993730d --- /dev/null +++ b/testing/xpcshell/example/unit/test_load_httpd_js.js @@ -0,0 +1,13 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +function run_test() { + var httpserver = new HttpServer(); + Assert.notEqual(httpserver, null); + Assert.notEqual(httpserver.QueryInterface(Ci.nsIHttpServer), null); +} diff --git a/testing/xpcshell/example/unit/test_location.js b/testing/xpcshell/example/unit/test_location.js new file mode 100644 index 0000000000..3abd2f7910 --- /dev/null +++ b/testing/xpcshell/example/unit/test_location.js @@ -0,0 +1,13 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals __LOCATION__ */ + +function run_test() { + Assert.equal(__LOCATION__.leafName, "test_location.js"); + // also check that __LOCATION__ works via load() + load("location_load.js"); +} diff --git a/testing/xpcshell/example/unit/test_multiple_setups.js b/testing/xpcshell/example/unit/test_multiple_setups.js new file mode 100644 index 0000000000..63d731c8a8 --- /dev/null +++ b/testing/xpcshell/example/unit/test_multiple_setups.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let someVar = 0; + +add_setup(() => (someVar = 1)); +add_setup(() => (someVar = 2)); + +add_task(async function test_setup_ordering() { + Assert.equal(someVar, 2, "Setups should have run in order."); +}); diff --git a/testing/xpcshell/example/unit/test_multiple_tasks.js b/testing/xpcshell/example/unit/test_multiple_tasks.js new file mode 100644 index 0000000000..46d1b21225 --- /dev/null +++ b/testing/xpcshell/example/unit/test_multiple_tasks.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let someVar = 0; + +add_task(async function test_first() { + Assert.equal(someVar, 1, "I should run as the first test task."); + someVar++; +}); + +add_setup(function setup() { + Assert.equal(someVar, 0, "Should run setup first."); + someVar++; +}); + +add_task(async function test_second() { + Assert.equal(someVar, 2, "I should run as the second test task."); +}); diff --git a/testing/xpcshell/example/unit/test_prefs_defaults.js b/testing/xpcshell/example/unit/test_prefs_defaults.js new file mode 100644 index 0000000000..c6a93802e9 --- /dev/null +++ b/testing/xpcshell/example/unit/test_prefs_defaults.js @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Bug 1781025 - ESLint's use of multi-ini doesn't fully cope with +// mozilla-central's use of .ini files +// eslint-disable-next-line no-unused-vars +function run_test() { + /* import-globals-from prefs_test_common.js */ + load("prefs_test_common.js"); + + check_common_xpcshell_with_prefs(); + + Assert.ok( + !has_pref_from_manifest_file_section(), + "Should not have pref that was only assigned to a different test" + ); +} diff --git a/testing/xpcshell/example/unit/test_prefs_defaults_and_file.js b/testing/xpcshell/example/unit/test_prefs_defaults_and_file.js new file mode 100644 index 0000000000..8a56d49c34 --- /dev/null +++ b/testing/xpcshell/example/unit/test_prefs_defaults_and_file.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Bug 1781025 - ESLint's use of multi-ini doesn't fully cope with +// mozilla-central's use of .ini files +// eslint-disable-next-line no-unused-vars +function run_test() { + /* import-globals-from prefs_test_common.js */ + load("prefs_test_common.js"); + + check_common_xpcshell_with_prefs(); + + Assert.ok( + has_pref_from_manifest_file_section(), + "Should have pref set for file in xpcshell-with-prefs.ini" + ); + + Assert.equal( + Services.prefs.getIntPref("dummy.pref.from.test.file"), + 2, + "Value of pref that was set once at the file in xpcshell-with-prefs.ini" + ); + + Assert.equal( + Services.prefs.getStringPref("dummy.pref.from.test.duplicate"), + "final", + "The last pref takes precedence when duplicated" + ); + + Assert.equal( + Services.prefs.getIntPref("dummy.pref.from.test.manifest"), + 1337, + "File-specific pref takes precedence over manifest defaults" + ); + + Assert.equal( + Services.prefs.getStringPref("dummy.pref.from.test.ancestor"), + "Ancestor", + "Pref in manifest defaults without file-specific override should be set" + ); +} diff --git a/testing/xpcshell/example/unit/test_prefs_defaults_included.js b/testing/xpcshell/example/unit/test_prefs_defaults_included.js new file mode 100644 index 0000000000..c092faf5c3 --- /dev/null +++ b/testing/xpcshell/example/unit/test_prefs_defaults_included.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + /* import-globals-from prefs_test_common.js */ + load("prefs_test_common.js"); + + check_common_xpcshell_with_prefs(); + + Assert.equal( + Services.prefs.getStringPref("dummy.pref.from.test.ancestor"), + "ReplacedParent", + "Pref set in included test manifest takes precedence over ancestor" + ); +} diff --git a/testing/xpcshell/example/unit/test_prefs_no_defaults.js b/testing/xpcshell/example/unit/test_prefs_no_defaults.js new file mode 100644 index 0000000000..f4516158b3 --- /dev/null +++ b/testing/xpcshell/example/unit/test_prefs_no_defaults.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + /* import-globals-from prefs_test_common.js */ + load("prefs_test_common.js"); + + check_common_xpcshell_without_prefs(); + + Assert.ok( + !has_pref_from_manifest_file_section(), + "Should not have pref that was only assigned to a different test" + ); +} diff --git a/testing/xpcshell/example/unit/test_prefs_no_defaults_with_file.js b/testing/xpcshell/example/unit/test_prefs_no_defaults_with_file.js new file mode 100644 index 0000000000..e098b5d05e --- /dev/null +++ b/testing/xpcshell/example/unit/test_prefs_no_defaults_with_file.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + /* import-globals-from prefs_test_common.js */ + load("prefs_test_common.js"); + + check_common_xpcshell_without_prefs(); + + Assert.ok( + has_pref_from_manifest_file_section(), + "Should have pref set for file in xpcshell.ini" + ); +} diff --git a/testing/xpcshell/example/unit/test_profile.js b/testing/xpcshell/example/unit/test_profile.js new file mode 100644 index 0000000000..f235eb72fa --- /dev/null +++ b/testing/xpcshell/example/unit/test_profile.js @@ -0,0 +1,11 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + /* import-globals-from check_profile.js */ + load("check_profile.js"); + check_do_get_profile(false); +} diff --git a/testing/xpcshell/example/unit/test_profile_afterChange.js b/testing/xpcshell/example/unit/test_profile_afterChange.js new file mode 100644 index 0000000000..292cb00eba --- /dev/null +++ b/testing/xpcshell/example/unit/test_profile_afterChange.js @@ -0,0 +1,11 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + /* import-globals-from check_profile.js */ + load("check_profile.js"); + check_do_get_profile(true); +} diff --git a/testing/xpcshell/example/unit/test_sample.js b/testing/xpcshell/example/unit/test_sample.js new file mode 100644 index 0000000000..f0aa3df7c6 --- /dev/null +++ b/testing/xpcshell/example/unit/test_sample.js @@ -0,0 +1,21 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* This is the most basic testcase. It makes some trivial assertions, + * then sets a timeout, and exits the test harness when that timeout + * fires. This is meant to demonstrate that there is a complete event + * system available to test scripts. + * Available functions are described at: + * http://developer.mozilla.org/en/docs/Writing_xpcshell-based_unit_tests + */ +function run_test() { + Assert.equal(57, 57); + Assert.notEqual(1, 2); + Assert.ok(true); + + do_test_pending(); + do_timeout(100, do_test_finished); +} diff --git a/testing/xpcshell/example/unit/test_skip.js b/testing/xpcshell/example/unit/test_skip.js new file mode 100644 index 0000000000..0c203cd82e --- /dev/null +++ b/testing/xpcshell/example/unit/test_skip.js @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function run_test() { + // This test expects to fail. + Assert.ok(false); +} diff --git a/testing/xpcshell/example/unit/test_tasks_skip.js b/testing/xpcshell/example/unit/test_tasks_skip.js new file mode 100644 index 0000000000..99f3e8d2c2 --- /dev/null +++ b/testing/xpcshell/example/unit/test_tasks_skip.js @@ -0,0 +1,21 @@ +"use strict"; + +add_task(async function skipMeNot1() { + Assert.ok(true, "Well well well."); +}); + +add_task(async function skipMe1() { + Assert.ok(false, "Not skipped after all."); +}).skip(); + +add_task(async function skipMeNot2() { + Assert.ok(true, "Well well well."); +}); + +add_task(async function skipMeNot3() { + Assert.ok(true, "Well well well."); +}); + +add_task(async function skipMe2() { + Assert.ok(false, "Not skipped after all."); +}).skip(); diff --git a/testing/xpcshell/example/unit/test_tasks_skipall.js b/testing/xpcshell/example/unit/test_tasks_skipall.js new file mode 100644 index 0000000000..13290e58ba --- /dev/null +++ b/testing/xpcshell/example/unit/test_tasks_skipall.js @@ -0,0 +1,23 @@ +"use strict"; + +/* eslint-disable mozilla/reject-addtask-only */ + +add_task(async function skipMe1() { + Assert.ok(false, "Not skipped after all."); +}); + +add_task(async function skipMe2() { + Assert.ok(false, "Not skipped after all."); +}).skip(); + +add_task(async function skipMe3() { + Assert.ok(false, "Not skipped after all."); +}).only(); + +add_task(async function skipMeNot() { + Assert.ok(true, "Well well well."); +}).only(); + +add_task(async function skipMe4() { + Assert.ok(false, "Not skipped after all."); +}); diff --git a/testing/xpcshell/example/unit/xpcshell-included-with-prefs.ini b/testing/xpcshell/example/unit/xpcshell-included-with-prefs.ini new file mode 100644 index 0000000000..dc18700c82 --- /dev/null +++ b/testing/xpcshell/example/unit/xpcshell-included-with-prefs.ini @@ -0,0 +1,5 @@ +# This file is included by xpcshell-with-prefs.ini +[DEFAULT] +prefs = dummy.pref.from.test.ancestor=ReplacedParent + +[test_prefs_defaults_included.js] diff --git a/testing/xpcshell/example/unit/xpcshell-with-prefs.ini b/testing/xpcshell/example/unit/xpcshell-with-prefs.ini new file mode 100644 index 0000000000..4d5944b272 --- /dev/null +++ b/testing/xpcshell/example/unit/xpcshell-with-prefs.ini @@ -0,0 +1,16 @@ +[DEFAULT] +head = +support-files = prefs_test_common.js +prefs = + dummy.pref.from.test.ancestor=Ancestor + dummy.pref.from.test.manifest=1 + +[test_prefs_defaults.js] +[test_prefs_defaults_and_file.js] +prefs = # Multiple prefs, for additional test coverage over xpcshell.ini + dummy.pref.from.test.file=2 + dummy.pref.from.test.duplicate=first + dummy.pref.from.test.duplicate=final + dummy.pref.from.test.manifest=1337 # overrides manifest + +[include:xpcshell-included-with-prefs.ini] diff --git a/testing/xpcshell/example/unit/xpcshell.ini b/testing/xpcshell/example/unit/xpcshell.ini new file mode 100644 index 0000000000..38e3c98363 --- /dev/null +++ b/testing/xpcshell/example/unit/xpcshell.ini @@ -0,0 +1,60 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[DEFAULT] +head = +support-files = + subdir/file.txt + file.txt + import_module.sys.mjs + load_subscript.js + location_load.js + check_profile.js + prefs_test_common.js +# NOTE: Do NOT set prefs here. If you do, move test_prefs_no_defaults.js and +# test_prefs_no_defaults_with_file.js to a new file without a pref definitions. + +[test_add_setup.js] +[test_check_nsIException.js] +skip-if = os == 'win' && debug +[test_check_nsIException_failing.js] +fail-if = true +skip-if = os == 'win' && debug + +[test_do_get_tempdir.js] +[test_execute_soon.js] +[test_get_file.js] +[test_get_idle.js] +[test_import_module.js] +[test_load.js] +[test_load_httpd_js.js] +[test_location.js] +[test_multiple_setups.js] +[test_multiple_tasks.js] +[test_prefs_no_defaults.js] +[test_prefs_no_defaults_with_file.js] +prefs = dummy.pref.from.test.file=1 +[test_profile.js] +[test_profile_afterChange.js] +[test_sample.js] + +[test_fail.js] +fail-if = true + +[test_skip.js] +skip-if = true + +[test_do_check_null.js] +skip-if = os == 'win' && debug + +[test_do_check_null_failing.js] +fail-if = true +skip-if = os == 'win' && debug + +[test_do_check_matches.js] +[test_do_check_matches_failing.js] +fail-if = true + +[test_tasks_skip.js] +[test_tasks_skipall.js] diff --git a/testing/xpcshell/head.js b/testing/xpcshell/head.js new file mode 100644 index 0000000000..b69b5b380b --- /dev/null +++ b/testing/xpcshell/head.js @@ -0,0 +1,1890 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This file contains common code that is loaded before each test file(s). + * See https://developer.mozilla.org/en-US/docs/Mozilla/QA/Writing_xpcshell-based_unit_tests + * for more information. + */ + +/* defined by the harness */ +/* globals _HEAD_FILES, _HEAD_JS_PATH, _JSDEBUGGER_PORT, _JSCOV_DIR, + _MOZINFO_JS_PATH, _TEST_FILE, _TEST_NAME, _TEST_CWD, _TESTING_MODULES_DIR:true, + _PREFS_FILE */ + +/* defined by XPCShellImpl.cpp */ +/* globals load, sendCommand, changeTestShellDir */ + +/* must be defined by tests using do_await_remote_message/do_send_remote_message */ +/* globals Cc, Ci */ + +/* defined by this file but is defined as read-only for tests */ +// eslint-disable-next-line no-redeclare +/* globals runningInParent: true */ + +/* may be defined in test files */ +/* globals run_test */ + +var _quit = false; +var _passed = true; +var _tests_pending = 0; +var _cleanupFunctions = []; +var _pendingTimers = []; +var _profileInitialized = false; + +// Assigned in do_load_child_test_harness. +var _XPCSHELL_PROCESS; + +// Register the testing-common resource protocol early, to have access to its +// modules. +let _Services = Services; +_register_modules_protocol_handler(); + +let { AppConstants: _AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +let { PromiseTestUtils: _PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); + +let { NetUtil: _NetUtil } = ChromeUtils.import( + "resource://gre/modules/NetUtil.jsm" +); + +let { XPCOMUtils: _XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// Support a common assertion library, Assert.sys.mjs. +var { Assert: AssertCls } = ChromeUtils.importESModule( + "resource://testing-common/Assert.sys.mjs" +); + +// Pass a custom report function for xpcshell-test style reporting. +var Assert = new AssertCls(function (err, message, stack) { + if (err) { + do_report_result(false, err.message, err.stack); + } else { + do_report_result(true, message, stack); + } +}, true); + +// Bug 1506134 for followup. Some xpcshell tests use ContentTask.sys.mjs, which +// expects browser-test.js to have set a testScope that includes record. +function record(condition, name, diag, stack) { + do_report_result(condition, name, stack); +} + +var _add_params = function (params) { + if (typeof _XPCSHELL_PROCESS != "undefined") { + params.xpcshell_process = _XPCSHELL_PROCESS; + } +}; + +var _dumpLog = function (raw_msg) { + dump("\n" + JSON.stringify(raw_msg) + "\n"); +}; + +var { StructuredLogger: _LoggerClass } = ChromeUtils.importESModule( + "resource://testing-common/StructuredLog.sys.mjs" +); +var _testLogger = new _LoggerClass("xpcshell/head.js", _dumpLog, [_add_params]); + +// Disable automatic network detection, so tests work correctly when +// not connected to a network. +_Services.io.manageOfflineStatus = false; +_Services.io.offline = false; + +// Determine if we're running on parent or child +var runningInParent = true; +try { + // Don't use Services.appinfo here as it disables replacing appinfo with stubs + // for test usage. + runningInParent = + // eslint-disable-next-line mozilla/use-services + Cc["@mozilla.org/xre/runtime;1"].getService(Ci.nsIXULRuntime).processType == + Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT; +} catch (e) {} + +// Only if building of places is enabled. +if (runningInParent && "mozIAsyncHistory" in Ci) { + // Ensure places history is enabled for xpcshell-tests as some non-FF + // apps disable it. + _Services.prefs.setBoolPref("places.history.enabled", true); +} + +// Configure crash reporting, if possible +// We rely on the Python harness to set MOZ_CRASHREPORTER, +// MOZ_CRASHREPORTER_NO_REPORT, and handle checking for minidumps. +// Note that if we're in a child process, we don't want to init the +// crashreporter component. +try { + if (runningInParent && "@mozilla.org/toolkit/crash-reporter;1" in Cc) { + // Intentially access the crash reporter service directly for this. + // eslint-disable-next-line mozilla/use-services + let crashReporter = Cc["@mozilla.org/toolkit/crash-reporter;1"].getService( + Ci.nsICrashReporter + ); + crashReporter.UpdateCrashEventsDir(); + crashReporter.minidumpPath = do_get_minidumpdir(); + } +} catch (e) {} + +if (runningInParent) { + _Services.prefs.setBoolPref("dom.push.connection.enabled", false); +} + +// Configure a console listener so messages sent to it are logged as part +// of the test. +try { + let levelNames = {}; + for (let level of ["debug", "info", "warn", "error"]) { + levelNames[Ci.nsIConsoleMessage[level]] = level; + } + + let listener = { + QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]), + observe(msg) { + if (typeof info === "function") { + info( + "CONSOLE_MESSAGE: (" + + levelNames[msg.logLevel] + + ") " + + msg.toString() + ); + } + }, + }; + // Don't use _Services.console here as it causes one of the devtools tests + // to fail, probably due to initializing Services.console too early. + // eslint-disable-next-line mozilla/use-services + Cc["@mozilla.org/consoleservice;1"] + .getService(Ci.nsIConsoleService) + .registerListener(listener); +} catch (e) {} +/** + * Date.now() is not necessarily monotonically increasing (insert sob story + * about times not being the right tool to use for measuring intervals of time, + * robarnold can tell all), so be wary of error by erring by at least + * _timerFuzz ms. + */ +const _timerFuzz = 15; + +function _Timer(func, delay) { + delay = Number(delay); + if (delay < 0) { + do_throw("do_timeout() delay must be nonnegative"); + } + + if (typeof func !== "function") { + do_throw("string callbacks no longer accepted; use a function!"); + } + + this._func = func; + this._start = Date.now(); + this._delay = delay; + + var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, delay + _timerFuzz, timer.TYPE_ONE_SHOT); + + // Keep timer alive until it fires + _pendingTimers.push(timer); +} +_Timer.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsITimerCallback"]), + + notify(timer) { + _pendingTimers.splice(_pendingTimers.indexOf(timer), 1); + + // The current nsITimer implementation can undershoot, but even if it + // couldn't, paranoia is probably a virtue here given the potential for + // random orange on tinderboxen. + var end = Date.now(); + var elapsed = end - this._start; + if (elapsed >= this._delay) { + try { + this._func.call(null); + } catch (e) { + do_throw("exception thrown from do_timeout callback: " + e); + } + return; + } + + // Timer undershot, retry with a little overshoot to try to avoid more + // undershoots. + var newDelay = this._delay - elapsed; + do_timeout(newDelay, this._func); + }, +}; + +function _isGenerator(val) { + return typeof val === "object" && val && typeof val.next === "function"; +} + +function _do_main() { + if (_quit) { + return; + } + + _testLogger.info("running event loop"); + + var tm = Cc["@mozilla.org/thread-manager;1"].getService(); + + tm.spinEventLoopUntil("Test(xpcshell/head.js:_do_main)", () => _quit); + + tm.spinEventLoopUntilEmpty(); +} + +function _do_quit() { + _testLogger.info("exiting test"); + _quit = true; +} + +// This is useless, except to the extent that it has the side-effect of +// initializing the widget module, which some tests unfortunately +// accidentally rely on. +void Cc["@mozilla.org/widget/transferable;1"].createInstance(); + +/** + * Overrides idleService with a mock. Idle is commonly used for maintenance + * tasks, thus if a test uses a service that requires the idle service, it will + * start handling them. + * This behaviour would cause random failures and slowdown tests execution, + * for example by running database vacuum or cleanups for each test. + * + * @note Idle service is overridden by default. If a test requires it, it will + * have to call do_get_idle() function at least once before use. + */ +var _fakeIdleService = { + get registrar() { + delete this.registrar; + return (this.registrar = Components.manager.QueryInterface( + Ci.nsIComponentRegistrar + )); + }, + contractID: "@mozilla.org/widget/useridleservice;1", + CID: Components.ID("{9163a4ae-70c2-446c-9ac1-bbe4ab93004e}"), + + activate: function FIS_activate() { + if (!this.originalCID) { + this.originalCID = this.registrar.contractIDToCID(this.contractID); + // Replace with the mock. + this.registrar.registerFactory( + this.CID, + "Fake Idle Service", + this.contractID, + this.factory + ); + } + }, + + deactivate: function FIS_deactivate() { + if (this.originalCID) { + // Unregister the mock. + this.registrar.unregisterFactory(this.CID, this.factory); + // Restore original factory. + this.registrar.registerFactory( + this.originalCID, + "Idle Service", + this.contractID, + null + ); + delete this.originalCID; + } + }, + + factory: { + // nsIFactory + createInstance(aIID) { + return _fakeIdleService.QueryInterface(aIID); + }, + QueryInterface: ChromeUtils.generateQI(["nsIFactory"]), + }, + + // nsIUserIdleService + get idleTime() { + return 0; + }, + addIdleObserver() {}, + removeIdleObserver() {}, + + // eslint-disable-next-line mozilla/use-chromeutils-generateqi + QueryInterface(aIID) { + // Useful for testing purposes, see test_get_idle.js. + if (aIID.equals(Ci.nsIFactory)) { + return this.factory; + } + if (aIID.equals(Ci.nsIUserIdleService) || aIID.equals(Ci.nsISupports)) { + return this; + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, +}; + +/** + * Restores the idle service factory if needed and returns the service's handle. + * @return A handle to the idle service. + */ +function do_get_idle() { + _fakeIdleService.deactivate(); + return Cc[_fakeIdleService.contractID].getService(Ci.nsIUserIdleService); +} + +// Map resource://test/ to current working directory and +// resource://testing-common/ to the shared test modules directory. +function _register_protocol_handlers() { + let protocolHandler = _Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + let curDirURI = _Services.io.newFileURI(do_get_cwd()); + protocolHandler.setSubstitution("test", curDirURI); + + _register_modules_protocol_handler(); +} + +function _register_modules_protocol_handler() { + if (!_TESTING_MODULES_DIR) { + throw new Error( + "Please define a path where the testing modules can be " + + "found in a variable called '_TESTING_MODULES_DIR' before " + + "head.js is included." + ); + } + + let protocolHandler = _Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + let modulesFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + modulesFile.initWithPath(_TESTING_MODULES_DIR); + + if (!modulesFile.exists()) { + throw new Error( + "Specified modules directory does not exist: " + _TESTING_MODULES_DIR + ); + } + + if (!modulesFile.isDirectory()) { + throw new Error( + "Specified modules directory is not a directory: " + _TESTING_MODULES_DIR + ); + } + + let modulesURI = _Services.io.newFileURI(modulesFile); + + protocolHandler.setSubstitution("testing-common", modulesURI); +} + +/* Debugging support */ +// Used locally and by our self-tests. +function _setupDevToolsServer(breakpointFiles, callback) { + // Always allow remote debugging. + _Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true); + + // for debugging-the-debugging, let an env var cause log spew. + if (_Services.env.get("DEVTOOLS_DEBUGGER_LOG")) { + _Services.prefs.setBoolPref("devtools.debugger.log", true); + } + if (_Services.env.get("DEVTOOLS_DEBUGGER_LOG_VERBOSE")) { + _Services.prefs.setBoolPref("devtools.debugger.log.verbose", true); + } + + let require; + try { + ({ require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + )); + } catch (e) { + throw new Error( + "resource://devtools appears to be inaccessible from the " + + "xpcshell environment.\n" + + "This can usually be resolved by adding:\n" + + " firefox-appdir = browser\n" + + "to the xpcshell.ini manifest.\n" + + "It is possible for this to alter test behevior by " + + "triggering additional browser code to run, so check " + + "test behavior after making this change.\n" + + "See also https://bugzil.la/1215378." + ); + } + let { DevToolsServer } = require("devtools/server/devtools-server"); + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + let { createRootActor } = require("resource://testing-common/dbg-actors.js"); + DevToolsServer.setRootActor(createRootActor); + DevToolsServer.allowChromeProcess = true; + + const TOPICS = [ + // An observer notification that tells us when the thread actor is ready + // and can accept breakpoints. + "devtools-thread-ready", + // Or when devtools are destroyed and we should stop observing. + "xpcshell-test-devtools-shutdown", + ]; + let observe = function (subject, topic, data) { + if (topic === "devtools-thread-ready") { + const threadActor = subject.wrappedJSObject; + threadActor.setBreakpointOnLoad(breakpointFiles); + } + + for (let topicToRemove of TOPICS) { + _Services.obs.removeObserver(observe, topicToRemove); + } + callback(); + }; + + for (let topic of TOPICS) { + _Services.obs.addObserver(observe, topic); + } + + const { SocketListener } = require("devtools/shared/security/socket"); + + return { DevToolsServer, SocketListener }; +} + +function _initDebugging(port) { + let initialized = false; + const { DevToolsServer, SocketListener } = _setupDevToolsServer( + _TEST_FILE, + () => { + initialized = true; + } + ); + + info(""); + info("*******************************************************************"); + info("Waiting for the debugger to connect on port " + port); + info(""); + info("To connect the debugger, open a Firefox instance, select 'Connect'"); + info("from the Developer menu and specify the port as " + port); + info("*******************************************************************"); + info(""); + + const AuthenticatorType = DevToolsServer.Authenticators.get("PROMPT"); + const authenticator = new AuthenticatorType.Server(); + authenticator.allowConnection = () => { + return DevToolsServer.AuthenticationResult.ALLOW; + }; + const socketOptions = { + authenticator, + portOrPath: port, + }; + + const listener = new SocketListener(DevToolsServer, socketOptions); + listener.open(); + + // spin an event loop until the debugger connects. + const tm = Cc["@mozilla.org/thread-manager;1"].getService(); + tm.spinEventLoopUntil("Test(xpcshell/head.js:_initDebugging)", () => { + if (initialized) { + return true; + } + info("Still waiting for debugger to connect..."); + return false; + }); + // NOTE: if you want to debug the harness itself, you can now add a 'debugger' + // statement anywhere and it will stop - but we've already added a breakpoint + // for the first line of the test scripts, so we just continue... + info("Debugger connected, starting test execution"); +} + +function _execute_test() { + if (typeof _TEST_CWD != "undefined") { + try { + changeTestShellDir(_TEST_CWD); + } catch (e) { + _testLogger.error(_exception_message(e)); + } + } + if (runningInParent && _AppConstants.platform == "android") { + try { + // GeckoView initialization needs the profile + do_get_profile(true); + // Wake up GeckoViewStartup + let geckoViewStartup = Cc["@mozilla.org/geckoview/startup;1"].getService( + Ci.nsIObserver + ); + geckoViewStartup.observe(null, "profile-after-change", null); + geckoViewStartup.observe(null, "app-startup", null); + + // Glean needs to be initialized for metric recording & tests to work. + // Usually this happens through Glean Kotlin, + // but for xpcshell tests we initialize it from here. + _Services.fog.initializeFOG(); + } catch (ex) { + do_throw(`Failed to initialize GeckoView: ${ex}`, ex.stack); + } + } + + // _JSDEBUGGER_PORT is dynamically defined by . + if (_JSDEBUGGER_PORT) { + try { + _initDebugging(_JSDEBUGGER_PORT); + } catch (ex) { + // Fail the test run immediately if debugging is requested but fails, so + // that the failure state is more obvious. + do_throw(`Failed to initialize debugging: ${ex}`, ex.stack); + } + } + + _register_protocol_handlers(); + + // Override idle service by default. + // Call do_get_idle() to restore the factory and get the service. + _fakeIdleService.activate(); + + _PromiseTestUtils.init(); + + let coverageCollector = null; + if (typeof _JSCOV_DIR === "string") { + let _CoverageCollector = ChromeUtils.importESModule( + "resource://testing-common/CoverageUtils.sys.mjs" + ).CoverageCollector; + coverageCollector = new _CoverageCollector(_JSCOV_DIR); + } + + let startTime = Cu.now(); + + // _HEAD_FILES is dynamically defined by . + _load_files(_HEAD_FILES); + // _TEST_FILE is dynamically defined by . + _load_files(_TEST_FILE); + + // Tack Assert.sys.mjs methods to the current scope. + this.Assert = Assert; + for (let func in Assert) { + this[func] = Assert[func].bind(Assert); + } + + const { PerTestCoverageUtils } = ChromeUtils.importESModule( + "resource://testing-common/PerTestCoverageUtils.sys.mjs" + ); + + if (runningInParent) { + PerTestCoverageUtils.beforeTestSync(); + } + + try { + do_test_pending("MAIN run_test"); + // Check if run_test() is defined. If defined, run it. + // Else, call run_next_test() directly to invoke tests + // added by add_test() and add_task(). + if (typeof run_test === "function") { + run_test(); + } else { + run_next_test(); + } + + do_test_finished("MAIN run_test"); + _do_main(); + _PromiseTestUtils.assertNoUncaughtRejections(); + + if (coverageCollector != null) { + coverageCollector.recordTestCoverage(_TEST_FILE[0]); + } + + if (runningInParent) { + PerTestCoverageUtils.afterTestSync(); + } + } catch (e) { + _passed = false; + // do_check failures are already logged and set _quit to true and throw + // NS_ERROR_ABORT. If both of those are true it is likely this exception + // has already been logged so there is no need to log it again. It's + // possible that this will mask an NS_ERROR_ABORT that happens after a + // do_check failure though. + + if (!_quit || e.result != Cr.NS_ERROR_ABORT) { + let extra = {}; + if (e.fileName) { + extra.source_file = e.fileName; + if (e.lineNumber) { + extra.line_number = e.lineNumber; + } + } else { + extra.source_file = "xpcshell/head.js"; + } + let message = _exception_message(e); + if (e.stack) { + extra.stack = _format_stack(e.stack); + } + _testLogger.error(message, extra); + } + } finally { + if (coverageCollector != null) { + coverageCollector.finalize(); + } + } + + // Execute all of our cleanup functions. + let reportCleanupError = function (ex) { + let stack, filename; + if (ex && typeof ex == "object" && "stack" in ex) { + stack = ex.stack; + } else { + stack = Components.stack.caller; + } + if (stack instanceof Ci.nsIStackFrame) { + filename = stack.filename; + } else if (ex.fileName) { + filename = ex.fileName; + } + _testLogger.error(_exception_message(ex), { + stack: _format_stack(stack), + source_file: filename, + }); + }; + + let complete = !_cleanupFunctions.length; + let cleanupStartTime = complete ? 0 : Cu.now(); + (async () => { + for (let func of _cleanupFunctions.reverse()) { + try { + let result = await func(); + if (_isGenerator(result)) { + Assert.ok(false, "Cleanup function returned a generator"); + } + } catch (ex) { + reportCleanupError(ex); + } + } + _cleanupFunctions = []; + })() + .catch(reportCleanupError) + .then(() => (complete = true)); + _Services.tm.spinEventLoopUntil( + "Test(xpcshell/head.js:_execute_test)", + () => complete + ); + if (cleanupStartTime) { + ChromeUtils.addProfilerMarker( + "xpcshell-test", + { category: "Test", startTime: cleanupStartTime }, + "Cleanup functions" + ); + } + + ChromeUtils.addProfilerMarker( + "xpcshell-test", + { category: "Test", startTime }, + _TEST_NAME + ); + _Services.obs.notifyObservers(null, "test-complete"); + + // Restore idle service to avoid leaks. + _fakeIdleService.deactivate(); + + if ( + globalThis.hasOwnProperty("storage") && + StorageManager.isInstance(globalThis.storage) + ) { + globalThis.storage.shutdown(); + } + + if (_profileInitialized) { + // Since we have a profile, we will notify profile shutdown topics at + // the end of the current test, to ensure correct cleanup on shutdown. + _Services.startup.advanceShutdownPhase( + _Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNNETTEARDOWN + ); + _Services.startup.advanceShutdownPhase( + _Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNTEARDOWN + ); + _Services.startup.advanceShutdownPhase( + _Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN + ); + _Services.startup.advanceShutdownPhase( + _Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNQM + ); + + _profileInitialized = false; + } + + try { + _PromiseTestUtils.ensureDOMPromiseRejectionsProcessed(); + _PromiseTestUtils.assertNoUncaughtRejections(); + _PromiseTestUtils.assertNoMoreExpectedRejections(); + } finally { + // It's important to terminate the module to avoid crashes on shutdown. + _PromiseTestUtils.uninit(); + } + + // Skip the normal shutdown path for optimized builds that don't do leak checking. + if ( + runningInParent && + !_AppConstants.RELEASE_OR_BETA && + !_AppConstants.DEBUG && + !_AppConstants.MOZ_CODE_COVERAGE && + !_AppConstants.ASAN && + !_AppConstants.TSAN + ) { + Cu.exitIfInAutomation(); + } +} + +/** + * Loads files. + * + * @param aFiles Array of files to load. + */ +function _load_files(aFiles) { + function load_file(element, index, array) { + try { + let startTime = Cu.now(); + load(element); + ChromeUtils.addProfilerMarker( + "load_file", + { category: "Test", startTime }, + element.replace(/.*\/_?tests\/xpcshell\//, "") + ); + } catch (e) { + let extra = { + source_file: element, + }; + if (e.stack) { + extra.stack = _format_stack(e.stack); + } + _testLogger.error(_exception_message(e), extra); + } + } + + aFiles.forEach(load_file); +} + +function _wrap_with_quotes_if_necessary(val) { + return typeof val == "string" ? '"' + val + '"' : val; +} + +/* ************* Functions to be used from the tests ************* */ + +/** + * Prints a message to the output log. + */ +function info(msg, data) { + ChromeUtils.addProfilerMarker("INFO", { category: "Test" }, msg); + msg = _wrap_with_quotes_if_necessary(msg); + data = data ? data : null; + _testLogger.info(msg, data); +} + +/** + * Calls the given function at least the specified number of milliseconds later. + * The callback will not undershoot the given time, but it might overshoot -- + * don't expect precision! + * + * @param delay : uint + * the number of milliseconds to delay + * @param callback : function() : void + * the function to call + */ +function do_timeout(delay, func) { + new _Timer(func, Number(delay)); +} + +function executeSoon(callback, aName) { + let funcName = aName ? aName : callback.name; + do_test_pending(funcName); + + _Services.tm.dispatchToMainThread({ + run() { + try { + callback(); + } catch (e) { + // do_check failures are already logged and set _quit to true and throw + // NS_ERROR_ABORT. If both of those are true it is likely this exception + // has already been logged so there is no need to log it again. It's + // possible that this will mask an NS_ERROR_ABORT that happens after a + // do_check failure though. + if (!_quit || e.result != Cr.NS_ERROR_ABORT) { + let stack = e.stack ? _format_stack(e.stack) : null; + _testLogger.testStatus( + _TEST_NAME, + funcName, + "FAIL", + "PASS", + _exception_message(e), + stack + ); + _do_quit(); + } + } finally { + do_test_finished(funcName); + } + }, + }); +} + +/** + * Shows an error message and the current stack and aborts the test. + * + * @param error A message string or an Error object. + * @param stack null or nsIStackFrame object or a string containing + * \n separated stack lines (as in Error().stack). + */ +function do_throw(error, stack) { + let filename = ""; + // If we didn't get passed a stack, maybe the error has one + // otherwise get it from our call context + stack = stack || error.stack || Components.stack.caller; + + if (stack instanceof Ci.nsIStackFrame) { + filename = stack.filename; + } else if (error.fileName) { + filename = error.fileName; + } + + _testLogger.error(_exception_message(error), { + source_file: filename, + stack: _format_stack(stack), + }); + ChromeUtils.addProfilerMarker( + "ERROR", + { category: "Test", captureStack: true }, + _exception_message(error) + ); + _abort_failed_test(); +} + +function _abort_failed_test() { + // Called to abort the test run after all failures are logged. + _passed = false; + _do_quit(); + throw Components.Exception("", Cr.NS_ERROR_ABORT); +} + +function _format_stack(stack) { + let normalized; + if (stack instanceof Ci.nsIStackFrame) { + let frames = []; + for (let frame = stack; frame; frame = frame.caller) { + frames.push(frame.filename + ":" + frame.name + ":" + frame.lineNumber); + } + normalized = frames.join("\n"); + } else { + normalized = "" + stack; + } + return normalized; +} + +// Make a nice display string from an object that behaves +// like Error +function _exception_message(ex) { + let message = ""; + if (ex.name) { + message = ex.name + ": "; + } + if (ex.message) { + message += ex.message; + } + if (ex.fileName) { + message += " at " + ex.fileName; + if (ex.lineNumber) { + message += ":" + ex.lineNumber; + } + } + if (message !== "") { + return message; + } + // Force ex to be stringified + return "" + ex; +} + +function do_report_unexpected_exception(ex, text) { + let filename = Components.stack.caller.filename; + text = text ? text + " - " : ""; + + _passed = false; + _testLogger.error(text + "Unexpected exception " + _exception_message(ex), { + source_file: filename, + stack: _format_stack(ex.stack), + }); + _do_quit(); + throw Components.Exception("", Cr.NS_ERROR_ABORT); +} + +function do_note_exception(ex, text) { + let filename = Components.stack.caller.filename; + _testLogger.info(text + "Swallowed exception " + _exception_message(ex), { + source_file: filename, + stack: _format_stack(ex.stack), + }); +} + +function do_report_result(passed, text, stack, todo) { + // Match names like head.js, head_foo.js, and foo_head.js, but not + // test_headache.js + while (/(\/head(_.+)?|head)\.js$/.test(stack.filename) && stack.caller) { + stack = stack.caller; + } + + let name = _gRunningTest ? _gRunningTest.name : stack.name; + let message; + if (name) { + message = "[" + name + " : " + stack.lineNumber + "] " + text; + } else { + message = text; + } + + if (passed) { + if (todo) { + _testLogger.testStatus( + _TEST_NAME, + name, + "PASS", + "FAIL", + message, + _format_stack(stack) + ); + ChromeUtils.addProfilerMarker( + "UNEXPECTED-PASS", + { category: "Test" }, + message + ); + _abort_failed_test(); + } else { + _testLogger.testStatus(_TEST_NAME, name, "PASS", "PASS", message); + ChromeUtils.addProfilerMarker("PASS", { category: "Test" }, message); + } + } else if (todo) { + _testLogger.testStatus(_TEST_NAME, name, "FAIL", "FAIL", message); + ChromeUtils.addProfilerMarker("TODO", { category: "Test" }, message); + } else { + _testLogger.testStatus( + _TEST_NAME, + name, + "FAIL", + "PASS", + message, + _format_stack(stack) + ); + ChromeUtils.addProfilerMarker("FAIL", { category: "Test" }, message); + _abort_failed_test(); + } +} + +function _do_check_eq(left, right, stack, todo) { + if (!stack) { + stack = Components.stack.caller; + } + + var text = + _wrap_with_quotes_if_necessary(left) + + " == " + + _wrap_with_quotes_if_necessary(right); + do_report_result(left == right, text, stack, todo); +} + +function todo_check_eq(left, right, stack) { + if (!stack) { + stack = Components.stack.caller; + } + + _do_check_eq(left, right, stack, true); +} + +function todo_check_true(condition, stack) { + if (!stack) { + stack = Components.stack.caller; + } + + todo_check_eq(condition, true, stack); +} + +function todo_check_false(condition, stack) { + if (!stack) { + stack = Components.stack.caller; + } + + todo_check_eq(condition, false, stack); +} + +function todo_check_null(condition, stack = Components.stack.caller) { + todo_check_eq(condition, null, stack); +} + +// Check that |func| throws an nsIException that has +// |Components.results[resultName]| as the value of its 'result' property. +function do_check_throws_nsIException( + func, + resultName, + stack = Components.stack.caller, + todo = false +) { + let expected = Cr[resultName]; + if (typeof expected !== "number") { + do_throw( + "do_check_throws_nsIException requires a Components.results" + + " property name, not " + + uneval(resultName), + stack + ); + } + + let msg = + "do_check_throws_nsIException: func should throw" + + " an nsIException whose 'result' is Components.results." + + resultName; + + try { + func(); + } catch (ex) { + if (!(ex instanceof Ci.nsIException) || ex.result !== expected) { + do_report_result( + false, + msg + ", threw " + legible_exception(ex) + " instead", + stack, + todo + ); + } + + do_report_result(true, msg, stack, todo); + return; + } + + // Call this here, not in the 'try' clause, so do_report_result's own + // throw doesn't get caught by our 'catch' clause. + do_report_result(false, msg + ", but returned normally", stack, todo); +} + +// Produce a human-readable form of |exception|. This looks up +// Components.results values, tries toString methods, and so on. +function legible_exception(exception) { + switch (typeof exception) { + case "object": + if (exception instanceof Ci.nsIException) { + return "nsIException instance: " + uneval(exception.toString()); + } + return exception.toString(); + + case "number": + for (let name in Cr) { + if (exception === Cr[name]) { + return "Components.results." + name; + } + } + + // Fall through. + default: + return uneval(exception); + } +} + +function do_check_instanceof( + value, + constructor, + stack = Components.stack.caller, + todo = false +) { + do_report_result( + value instanceof constructor, + "value should be an instance of " + constructor.name, + stack, + todo + ); +} + +function todo_check_instanceof( + value, + constructor, + stack = Components.stack.caller +) { + do_check_instanceof(value, constructor, stack, true); +} + +function do_test_pending(aName) { + ++_tests_pending; + + _testLogger.info( + "(xpcshell/head.js) | test" + + (aName ? " " + aName : "") + + " pending (" + + _tests_pending + + ")" + ); +} + +function do_test_finished(aName) { + _testLogger.info( + "(xpcshell/head.js) | test" + + (aName ? " " + aName : "") + + " finished (" + + _tests_pending + + ")" + ); + if (--_tests_pending == 0) { + _do_quit(); + } +} + +function do_get_file(path, allowNonexistent) { + try { + let lf = _Services.dirsvc.get("CurWorkD", Ci.nsIFile); + + let bits = path.split("/"); + for (let i = 0; i < bits.length; i++) { + if (bits[i]) { + if (bits[i] == "..") { + lf = lf.parent; + } else { + lf.append(bits[i]); + } + } + } + + if (!allowNonexistent && !lf.exists()) { + // Not using do_throw(): caller will continue. + _passed = false; + var stack = Components.stack.caller; + _testLogger.error( + "[" + + stack.name + + " : " + + stack.lineNumber + + "] " + + lf.path + + " does not exist" + ); + } + + return lf; + } catch (ex) { + do_throw(ex.toString(), Components.stack.caller); + } + + return null; +} + +// do_get_cwd() isn't exactly self-explanatory, so provide a helper +function do_get_cwd() { + return do_get_file(""); +} + +function do_load_manifest(path) { + var lf = do_get_file(path); + const nsIComponentRegistrar = Ci.nsIComponentRegistrar; + Assert.ok(Components.manager instanceof nsIComponentRegistrar); + // Previous do_check_true() is not a test check. + Components.manager.autoRegister(lf); +} + +/** + * Parse a DOM document. + * + * @param aPath File path to the document. + * @param aType Content type to use in DOMParser. + * + * @return Document from the file. + */ +function do_parse_document(aPath, aType) { + switch (aType) { + case "application/xhtml+xml": + case "application/xml": + case "text/xml": + break; + + default: + do_throw( + "type: expected application/xhtml+xml, application/xml or text/xml," + + " got '" + + aType + + "'", + Components.stack.caller + ); + } + + let file = do_get_file(aPath); + let url = _Services.io.newFileURI(file).spec; + file = null; + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.responseType = "document"; + xhr.onerror = reject; + xhr.onload = () => { + resolve(xhr.response); + }; + xhr.send(); + }); +} + +/** + * Registers a function that will run when the test harness is done running all + * tests. + * + * @param aFunction + * The function to be called when the test harness has finished running. + */ +function registerCleanupFunction(aFunction) { + _cleanupFunctions.push(aFunction); +} + +/** + * Returns the directory for a temp dir, which is created by the + * test harness. Every test gets its own temp dir. + * + * @return nsIFile of the temporary directory + */ +function do_get_tempdir() { + // the python harness sets this in the environment for us + let path = _Services.env.get("XPCSHELL_TEST_TEMP_DIR"); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + return file; +} + +/** + * Returns the directory for crashreporter minidumps. + * + * @return nsIFile of the minidump directory + */ +function do_get_minidumpdir() { + // the python harness may set this in the environment for us + let path = _Services.env.get("XPCSHELL_MINIDUMP_DIR"); + if (path) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + return file; + } + return do_get_tempdir(); +} + +/** + * Registers a directory with the profile service, + * and return the directory as an nsIFile. + * + * @param notifyProfileAfterChange Whether to notify for "profile-after-change". + * @return nsIFile of the profile directory. + */ +function do_get_profile(notifyProfileAfterChange = false) { + if (!runningInParent) { + _testLogger.info("Ignoring profile creation from child process."); + return null; + } + + // the python harness sets this in the environment for us + let profd = Services.env.get("XPCSHELL_TEST_PROFILE_DIR"); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(profd); + + let provider = { + getFile(prop, persistent) { + persistent.value = true; + if ( + prop == "ProfD" || + prop == "ProfLD" || + prop == "ProfDS" || + prop == "ProfLDS" || + prop == "TmpD" + ) { + return file.clone(); + } + return null; + }, + QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]), + }; + _Services.dirsvc + .QueryInterface(Ci.nsIDirectoryService) + .registerProvider(provider); + + try { + _Services.dirsvc.undefine("TmpD"); + } catch (e) { + // This throws if the key is not already registered, but that + // doesn't matter. + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + + // We need to update the crash events directory when the profile changes. + if (runningInParent && "@mozilla.org/toolkit/crash-reporter;1" in Cc) { + // Intentially access the crash reporter service directly for this. + // eslint-disable-next-line mozilla/use-services + let crashReporter = Cc["@mozilla.org/toolkit/crash-reporter;1"].getService( + Ci.nsICrashReporter + ); + crashReporter.UpdateCrashEventsDir(); + } + + if (!_profileInitialized) { + _Services.obs.notifyObservers( + null, + "profile-do-change", + "xpcshell-do-get-profile" + ); + _profileInitialized = true; + if (notifyProfileAfterChange) { + _Services.obs.notifyObservers( + null, + "profile-after-change", + "xpcshell-do-get-profile" + ); + } + } + + // The methods of 'provider' will retain this scope so null out everything + // to avoid spurious leak reports. + profd = null; + provider = null; + return file.clone(); +} + +/** + * This function loads head.js (this file) in the child process, so that all + * functions defined in this file (do_throw, etc) are available to subsequent + * sendCommand calls. It also sets various constants used by these functions. + * + * (Note that you may use sendCommand without calling this function first; you + * simply won't have any of the functions in this file available.) + */ +function do_load_child_test_harness() { + // Make sure this isn't called from child process + if (!runningInParent) { + do_throw("run_test_in_child cannot be called from child!"); + } + + // Allow to be called multiple times, but only run once + if (typeof do_load_child_test_harness.alreadyRun != "undefined") { + return; + } + do_load_child_test_harness.alreadyRun = 1; + + _XPCSHELL_PROCESS = "parent"; + + let command = + "const _HEAD_JS_PATH=" + + uneval(_HEAD_JS_PATH) + + "; " + + "const _HEAD_FILES=" + + uneval(_HEAD_FILES) + + "; " + + "const _MOZINFO_JS_PATH=" + + uneval(_MOZINFO_JS_PATH) + + "; " + + "const _TEST_NAME=" + + uneval(_TEST_NAME) + + "; " + + // We'll need more magic to get the debugger working in the child + "const _JSDEBUGGER_PORT=0; " + + "_XPCSHELL_PROCESS='child';"; + + if (typeof _JSCOV_DIR === "string") { + command += " const _JSCOV_DIR=" + uneval(_JSCOV_DIR) + ";"; + } + + if (typeof _TEST_CWD != "undefined") { + command += " const _TEST_CWD=" + uneval(_TEST_CWD) + ";"; + } + + if (_TESTING_MODULES_DIR) { + command += + " const _TESTING_MODULES_DIR=" + uneval(_TESTING_MODULES_DIR) + ";"; + } + + command += " load(_HEAD_JS_PATH);"; + sendCommand(command); +} + +/** + * Runs an entire xpcshell unit test in a child process (rather than in chrome, + * which is the default). + * + * This function returns immediately, before the test has completed. + * + * @param testFile + * The name of the script to run. Path format same as load(). + * @param optionalCallback. + * Optional function to be called (in parent) when test on child is + * complete. If provided, the function must call do_test_finished(); + * @return Promise Resolved when the test in the child is complete. + */ +function run_test_in_child(testFile, optionalCallback) { + return new Promise(resolve => { + var callback = () => { + resolve(); + if (typeof optionalCallback == "undefined") { + do_test_finished(); + } else { + optionalCallback(); + } + }; + + do_load_child_test_harness(); + + var testPath = do_get_file(testFile).path.replace(/\\/g, "/"); + do_test_pending("run in child"); + sendCommand( + "_testLogger.info('CHILD-TEST-STARTED'); " + + "const _TEST_FILE=['" + + testPath + + "']; " + + "_execute_test(); " + + "_testLogger.info('CHILD-TEST-COMPLETED');", + callback + ); + }); +} + +/** + * Execute a given function as soon as a particular cross-process message is received. + * Must be paired with do_send_remote_message or equivalent ProcessMessageManager calls. + * + * @param optionalCallback + * Optional callback that is invoked when the message is received. If provided, + * the function must call do_test_finished(). + * @return Promise Promise that is resolved when the message is received. + */ +function do_await_remote_message(name, optionalCallback) { + return new Promise(resolve => { + var listener = { + receiveMessage(message) { + if (message.name == name) { + mm.removeMessageListener(name, listener); + resolve(message.data); + if (optionalCallback) { + optionalCallback(message.data); + } else { + do_test_finished(); + } + } + }, + }; + + var mm; + if (runningInParent) { + mm = Cc["@mozilla.org/parentprocessmessagemanager;1"].getService(); + } else { + mm = Cc["@mozilla.org/childprocessmessagemanager;1"].getService(); + } + do_test_pending(); + mm.addMessageListener(name, listener); + }); +} + +/** + * Asynchronously send a message to all remote processes. Pairs with do_await_remote_message + * or equivalent ProcessMessageManager listeners. + */ +function do_send_remote_message(name, data) { + var mm; + var sender; + if (runningInParent) { + mm = Cc["@mozilla.org/parentprocessmessagemanager;1"].getService(); + sender = "broadcastAsyncMessage"; + } else { + mm = Cc["@mozilla.org/childprocessmessagemanager;1"].getService(); + sender = "sendAsyncMessage"; + } + mm[sender](name, data); +} + +/** + * Schedules and awaits a precise GC, and forces CC, `maxCount` number of times. + * @param maxCount + * How many times GC and CC should be scheduled. + */ +async function schedulePreciseGCAndForceCC(maxCount) { + for (let count = 0; count < maxCount; count++) { + await new Promise(resolve => Cu.schedulePreciseGC(resolve)); + Cu.forceCC(); + } +} + +/** + * Add a test function to the list of tests that are to be run asynchronously. + * + * @param funcOrProperties + * A function to be run or an object represents test properties. + * Supported properties: + * skip_if : An arrow function which has an expression to be + * evaluated whether the test is skipped or not. + * pref_set: An array of preferences to set for the test, reset at end of test. + * @param func + * A function to be run only if the funcOrProperies is not a function. + * @param isTask + * Optional flag that indicates whether `func` is a task. Defaults to `false`. + * @param isSetup + * Optional flag that indicates whether `func` is a setup task. Defaults to `false`. + * Implies isTask. + * + * Each test function must call run_next_test() when it's done. Test files + * should call run_next_test() in their run_test function to execute all + * async tests. + * + * @return the test function that was passed in. + */ +var _gSupportedProperties = ["skip_if", "pref_set"]; +var _gTests = []; +var _gRunOnlyThisTest = null; +function add_test( + properties, + func = properties, + isTask = false, + isSetup = false +) { + if (isSetup) { + isTask = true; + } + if (typeof properties == "function") { + properties = { isTask, isSetup }; + _gTests.push([properties, func]); + } else if (typeof properties == "object") { + // Ensure only documented properties are in the object. + for (let prop of Object.keys(properties)) { + if (!_gSupportedProperties.includes(prop)) { + do_throw(`Task property is not supported: ${prop}`); + } + } + properties.isTask = isTask; + properties.isSetup = isSetup; + _gTests.push([properties, func]); + } else { + do_throw("add_test() should take a function or an object and a function"); + } + func.skip = () => (properties.skip_if = () => true); + func.only = () => (_gRunOnlyThisTest = func); + return func; +} + +/** + * Add a test function which is a Task function. + * + * @param funcOrProperties + * An async function to be run or an object represents test properties. + * Supported properties: + * skip_if : An arrow function which has an expression to be + * evaluated whether the test is skipped or not. + * pref_set: An array of preferences to set for the test, reset at end of test. + * @param func + * An async function to be run only if the funcOrProperies is not a function. + * + * Task functions are functions fed into Task.jsm's Task.spawn(). They are async + * functions that emit promises. + * + * If an exception is thrown, a do_check_* comparison fails, or if a rejected + * promise is yielded, the test function aborts immediately and the test is + * reported as a failure. + * + * Unlike add_test(), there is no need to call run_next_test(). The next test + * will run automatically as soon the task function is exhausted. To trigger + * premature (but successful) termination of the function or simply return. + * + * Example usage: + * + * add_task(async function test() { + * let result = await Promise.resolve(true); + * + * do_check_true(result); + * + * let secondary = await someFunctionThatReturnsAPromise(result); + * do_check_eq(secondary, "expected value"); + * }); + * + * add_task(async function test_early_return() { + * let result = await somethingThatReturnsAPromise(); + * + * if (!result) { + * // Test is ended immediately, with success. + * return; + * } + * + * do_check_eq(result, "foo"); + * }); + * + * add_task({ + * skip_if: () => !("@mozilla.org/telephony/volume-service;1" in Components.classes), + * pref_set: [["some.pref", "value"], ["another.pref", true]], + * }, async function test_volume_service() { + * let volumeService = Cc["@mozilla.org/telephony/volume-service;1"] + * .getService(Ci.nsIVolumeService); + * ... + * }); + */ +function add_task(properties, func = properties) { + return add_test(properties, func, true); +} + +/** + * add_setup is like add_task, but creates setup tasks. + */ +function add_setup(properties, func = properties) { + return add_test(properties, func, true, true); +} + +const _setTaskPrefs = prefs => { + for (let [pref, value] of prefs) { + if (value === undefined) { + // Clear any pref that didn't have a user value. + info(`Clearing pref "${pref}"`); + _Services.prefs.clearUserPref(pref); + continue; + } + + info(`Setting pref "${pref}": ${value}`); + switch (typeof value) { + case "boolean": + _Services.prefs.setBoolPref(pref, value); + break; + case "number": + _Services.prefs.setIntPref(pref, value); + break; + case "string": + _Services.prefs.setStringPref(pref, value); + break; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + } +}; + +const _getTaskPrefs = prefs => { + return prefs.map(([pref, value]) => { + info(`Getting initial pref value for "${pref}"`); + if (!_Services.prefs.prefHasUserValue(pref)) { + // Check if the pref doesn't have a user value. + return [pref, undefined]; + } + switch (typeof value) { + case "boolean": + return [pref, _Services.prefs.getBoolPref(pref)]; + case "number": + return [pref, _Services.prefs.getIntPref(pref)]; + case "string": + return [pref, _Services.prefs.getStringPref(pref)]; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + }); +}; + +/** + * Runs the next test function from the list of async tests. + */ +var _gRunningTest = null; +var _gTestIndex = 0; // The index of the currently running test. +var _gTaskRunning = false; +var _gSetupRunning = false; +function run_next_test() { + if (_gTaskRunning) { + throw new Error( + "run_next_test() called from an add_task() test function. " + + "run_next_test() should not be called from inside add_setup() or add_task() " + + "under any circumstances!" + ); + } + + if (_gSetupRunning) { + throw new Error( + "run_next_test() called from an add_setup() test function. " + + "run_next_test() should not be called from inside add_setup() or add_task() " + + "under any circumstances!" + ); + } + + function _run_next_test() { + if (_gTestIndex < _gTests.length) { + // Check for uncaught rejections as early and often as possible. + _PromiseTestUtils.assertNoUncaughtRejections(); + let _properties; + [_properties, _gRunningTest] = _gTests[_gTestIndex++]; + + // Must set to pending before we check for skip, so that we keep the + // running counts correct. + _testLogger.info( + `${_TEST_NAME} | Starting ${_properties.isSetup ? "setup " : ""}${ + _gRunningTest.name + }` + ); + do_test_pending(_gRunningTest.name); + + if ( + (typeof _properties.skip_if == "function" && _properties.skip_if()) || + (_gRunOnlyThisTest && + _gRunningTest != _gRunOnlyThisTest && + !_properties.isSetup) + ) { + let _condition = _gRunOnlyThisTest + ? "only one task may run." + : _properties.skip_if.toSource().replace(/\(\)\s*=>\s*/, ""); + if (_condition == "true") { + _condition = "explicitly skipped."; + } + let _message = + _gRunningTest.name + + " skipped because the following conditions were" + + " met: (" + + _condition + + ")"; + _testLogger.testStatus( + _TEST_NAME, + _gRunningTest.name, + "SKIP", + "SKIP", + _message + ); + executeSoon(run_next_test); + return; + } + + let initialPrefsValues = []; + if (_properties.pref_set) { + initialPrefsValues = _getTaskPrefs(_properties.pref_set); + _setTaskPrefs(_properties.pref_set); + } + + if (_properties.isTask) { + if (_properties.isSetup) { + _gSetupRunning = true; + } else { + _gTaskRunning = true; + } + let startTime = Cu.now(); + (async () => _gRunningTest())().then( + result => { + _gTaskRunning = _gSetupRunning = false; + ChromeUtils.addProfilerMarker( + "task", + { category: "Test", startTime }, + _gRunningTest.name || undefined + ); + if (_isGenerator(result)) { + Assert.ok(false, "Task returned a generator"); + } + _setTaskPrefs(initialPrefsValues); + run_next_test(); + }, + ex => { + _gTaskRunning = _gSetupRunning = false; + ChromeUtils.addProfilerMarker( + "task", + { category: "Test", startTime }, + _gRunningTest.name || undefined + ); + _setTaskPrefs(initialPrefsValues); + try { + do_report_unexpected_exception(ex); + } catch (error) { + // The above throws NS_ERROR_ABORT and we don't want this to show up + // as an unhandled rejection later. + } + } + ); + } else { + // Exceptions do not kill asynchronous tests, so they'll time out. + let startTime = Cu.now(); + try { + _gRunningTest(); + } catch (e) { + do_throw(e); + } finally { + ChromeUtils.addProfilerMarker( + "xpcshell-test", + { category: "Test", startTime }, + _gRunningTest.name || undefined + ); + _setTaskPrefs(initialPrefsValues); + } + } + } + } + + function frontLoadSetups() { + _gTests.sort(([propsA, funcA], [propsB, funcB]) => { + if (propsB.isSetup === propsA.isSetup) { + return 0; + } + return propsB.isSetup ? 1 : -1; + }); + } + + if (!_gTestIndex) { + frontLoadSetups(); + } + + // For sane stacks during failures, we execute this code soon, but not now. + // We do this now, before we call do_test_finished(), to ensure the pending + // counter (_tests_pending) never reaches 0 while we still have tests to run + // (executeSoon bumps that counter). + executeSoon(_run_next_test, "run_next_test " + _gTestIndex); + + if (_gRunningTest !== null) { + // Close the previous test do_test_pending call. + do_test_finished(_gRunningTest.name); + } +} + +try { + // Set global preferences + if (runningInParent) { + let prefsFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + prefsFile.initWithPath(_PREFS_FILE); + _Services.prefs.readUserPrefsFromFile(prefsFile); + } +} catch (e) { + do_throw(e); +} + +/** + * Changing/Adding scalars or events to Telemetry is supported in build-faster/artifacts builds. + * These need to be loaded explicitly at start. + * It usually happens once all of Telemetry is initialized and set up. + * However in xpcshell tests Telemetry is not necessarily fully loaded, + * so we help out users by loading at least the dynamic-builtin probes. + */ +try { + // We only need to run this in the parent process. + // We only want to run this for local developer builds (which should have a "default" update channel). + if (runningInParent && _AppConstants.MOZ_UPDATE_CHANNEL == "default") { + let startTime = Cu.now(); + let { TelemetryController: _TelemetryController } = + ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" + ); + + let complete = false; + _TelemetryController.testRegisterJsProbes().finally(() => { + ChromeUtils.addProfilerMarker( + "xpcshell-test", + { category: "Test", startTime }, + "TelemetryController.testRegisterJsProbes" + ); + complete = true; + }); + _Services.tm.spinEventLoopUntil( + "Test(xpcshell/head.js:run_next-Test)", + () => complete + ); + } +} catch (e) { + do_throw(e); +} + +function _load_mozinfo() { + let mozinfoFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + mozinfoFile.initWithPath(_MOZINFO_JS_PATH); + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + stream.init(mozinfoFile, -1, 0, 0); + let bytes = _NetUtil.readInputStream(stream, stream.available()); + let decoded = JSON.parse(new TextDecoder().decode(bytes)); + stream.close(); + return decoded; +} + +Object.defineProperty(this, "mozinfo", { + configurable: true, + get() { + let _mozinfo = _load_mozinfo(); + Object.defineProperty(this, "mozinfo", { + configurable: false, + value: _mozinfo, + }); + return _mozinfo; + }, +}); diff --git a/testing/xpcshell/mach_commands.py b/testing/xpcshell/mach_commands.py new file mode 100644 index 0000000000..bd2c041c87 --- /dev/null +++ b/testing/xpcshell/mach_commands.py @@ -0,0 +1,277 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Integrates the xpcshell test runner with mach. + +import errno +import logging +import os +import sys +from multiprocessing import cpu_count + +from mach.decorators import Command +from mozbuild.base import BinaryNotFoundException, MozbuildObject +from mozbuild.base import MachCommandConditions as conditions +from mozlog import structured +from xpcshellcommandline import parser_desktop, parser_remote + +here = os.path.abspath(os.path.dirname(__file__)) + + +# This should probably be consolidated with similar classes in other test +# runners. +class InvalidTestPathError(Exception): + """Exception raised when the test path is not valid.""" + + +class XPCShellRunner(MozbuildObject): + """Run xpcshell tests.""" + + def run_suite(self, **kwargs): + return self._run_xpcshell_harness(**kwargs) + + def run_test(self, **kwargs): + """Runs an individual xpcshell test.""" + + # TODO Bug 794506 remove once mach integrates with virtualenv. + build_path = os.path.join(self.topobjdir, "build") + if build_path not in sys.path: + sys.path.append(build_path) + + src_build_path = os.path.join(self.topsrcdir, "mozilla", "build") + if os.path.isdir(src_build_path): + sys.path.append(src_build_path) + + return self.run_suite(**kwargs) + + def _run_xpcshell_harness(self, **kwargs): + # Obtain a reference to the xpcshell test runner. + import runxpcshelltests + + log = kwargs.pop("log") + + xpcshell = runxpcshelltests.XPCShellTests(log=log) + self.log_manager.enable_unstructured() + + tests_dir = os.path.join(self.topobjdir, "_tests", "xpcshell") + # We want output from the test to be written immediately if we are only + # running a single test. + single_test = ( + len(kwargs["testPaths"]) == 1 + and os.path.isfile(kwargs["testPaths"][0]) + or kwargs["manifest"] + and (len(kwargs["manifest"].test_paths()) == 1) + ) + + if single_test: + kwargs["verbose"] = True + + if kwargs["xpcshell"] is None: + try: + kwargs["xpcshell"] = self.get_binary_path("xpcshell") + except BinaryNotFoundException as e: + self.log( + logging.ERROR, "xpcshell-test", {"error": str(e)}, "ERROR: {error}" + ) + self.log(logging.INFO, "xpcshell-test", {"help": e.help()}, "{help}") + return 1 + + if kwargs["mozInfo"] is None: + kwargs["mozInfo"] = os.path.join(self.topobjdir, "mozinfo.json") + + if kwargs["symbolsPath"] is None: + kwargs["symbolsPath"] = os.path.join(self.distdir, "crashreporter-symbols") + + if kwargs["logfiles"] is None: + kwargs["logfiles"] = False + + if kwargs["profileName"] is None: + kwargs["profileName"] = "firefox" + + if kwargs["testingModulesDir"] is None: + kwargs["testingModulesDir"] = os.path.join(self.topobjdir, "_tests/modules") + + if kwargs["utility_path"] is None: + kwargs["utility_path"] = self.bindir + + if kwargs["manifest"] is None: + kwargs["manifest"] = os.path.join(tests_dir, "xpcshell.ini") + + if kwargs["failure_manifest"] is None: + kwargs["failure_manifest"] = os.path.join( + self.statedir, "xpcshell.failures.ini" + ) + + # Use the object directory for the temp directory to minimize the chance + # of file scanning. The overhead from e.g. search indexers and anti-virus + # scanners like Windows Defender can add tons of overhead to test execution. + # We encourage people to disable these things in the object directory. + temp_dir = os.path.join(self.topobjdir, "temp") + try: + os.mkdir(temp_dir) + except OSError as e: + if e.errno != errno.EEXIST: + raise + kwargs["tempDir"] = temp_dir + + result = xpcshell.runTests(kwargs) + + self.log_manager.disable_unstructured() + + if not result and not xpcshell.sequential: + print( + "Tests were run in parallel. Try running with --sequential " + "to make sure the failures were not caused by this." + ) + return int(not result) + + +class AndroidXPCShellRunner(MozbuildObject): + """Run Android xpcshell tests.""" + + def run_test(self, **kwargs): + # TODO Bug 794506 remove once mach integrates with virtualenv. + build_path = os.path.join(self.topobjdir, "build") + if build_path not in sys.path: + sys.path.append(build_path) + + import remotexpcshelltests + + log = kwargs.pop("log") + self.log_manager.enable_unstructured() + + if kwargs["xpcshell"] is None: + kwargs["xpcshell"] = "xpcshell" + + if not kwargs["objdir"]: + kwargs["objdir"] = self.topobjdir + + if not kwargs["localBin"]: + kwargs["localBin"] = os.path.join(self.topobjdir, "dist/bin") + + if not kwargs["testingModulesDir"]: + kwargs["testingModulesDir"] = os.path.join(self.topobjdir, "_tests/modules") + + if not kwargs["mozInfo"]: + kwargs["mozInfo"] = os.path.join(self.topobjdir, "mozinfo.json") + + if not kwargs["manifest"]: + kwargs["manifest"] = os.path.join( + self.topobjdir, "_tests/xpcshell/xpcshell.ini" + ) + + if not kwargs["symbolsPath"]: + kwargs["symbolsPath"] = os.path.join(self.distdir, "crashreporter-symbols") + + if self.substs.get("MOZ_BUILD_APP") == "b2g": + kwargs["localAPK"] = None + elif not kwargs["localAPK"]: + for root, _, paths in os.walk(os.path.join(kwargs["objdir"], "gradle")): + for file_name in paths: + if file_name.endswith(".apk") and file_name.startswith( + "test_runner-withGeckoBinaries" + ): + kwargs["localAPK"] = os.path.join(root, file_name) + print("using APK: %s" % kwargs["localAPK"]) + break + if kwargs["localAPK"]: + break + else: + raise Exception("APK not found in objdir. You must specify an APK.") + + xpcshell = remotexpcshelltests.XPCShellRemote(kwargs, log) + + result = xpcshell.runTests( + kwargs, + testClass=remotexpcshelltests.RemoteXPCShellTestThread, + mobileArgs=xpcshell.mobileArgs, + ) + + self.log_manager.disable_unstructured() + + return int(not result) + + +def get_parser(): + build_obj = MozbuildObject.from_environment(cwd=here) + if ( + conditions.is_android(build_obj) + or build_obj.substs.get("MOZ_BUILD_APP") == "b2g" + ): + return parser_remote() + else: + return parser_desktop() + + +@Command( + "xpcshell-test", + category="testing", + description="Run XPCOM Shell tests (API direct unit testing)", + conditions=[lambda *args: True], + parser=get_parser, +) +def run_xpcshell_test(command_context, test_objects=None, **params): + from mozbuild.controller.building import BuildDriver + + if test_objects is not None: + from manifestparser import TestManifest + + m = TestManifest() + m.tests.extend(test_objects) + params["manifest"] = m + + driver = command_context._spawn(BuildDriver) + driver.install_tests() + + # We should probably have a utility function to ensure the tree is + # ready to run tests. Until then, we just create the state dir (in + # case the tree wasn't built with mach). + command_context._ensure_state_subdir_exists(".") + + if not params.get("log"): + log_defaults = { + command_context._mach_context.settings["test"]["format"]: sys.stdout + } + fmt_defaults = { + "level": command_context._mach_context.settings["test"]["level"], + "verbose": True, + } + params["log"] = structured.commandline.setup_logging( + "XPCShellTests", params, log_defaults, fmt_defaults + ) + + if not params["threadCount"]: + # pylint --py3k W1619 + params["threadCount"] = int((cpu_count() * 3) / 2) + + if ( + conditions.is_android(command_context) + or command_context.substs.get("MOZ_BUILD_APP") == "b2g" + ): + from mozrunner.devices.android_device import ( + InstallIntent, + get_adb_path, + verify_android_device, + ) + + install = InstallIntent.YES if params["setup"] else InstallIntent.NO + device_serial = params.get("deviceSerial") + verify_android_device( + command_context, + network=True, + install=install, + device_serial=device_serial, + ) + if not params["adbPath"]: + params["adbPath"] = get_adb_path(command_context) + xpcshell = command_context._spawn(AndroidXPCShellRunner) + else: + xpcshell = command_context._spawn(XPCShellRunner) + xpcshell.cwd = command_context._mach_context.cwd + + try: + return xpcshell.run_test(**params) + except InvalidTestPathError as e: + print(str(e)) + return 1 diff --git a/testing/xpcshell/mach_test_package_commands.py b/testing/xpcshell/mach_test_package_commands.py new file mode 100644 index 0000000000..0e882435d8 --- /dev/null +++ b/testing/xpcshell/mach_test_package_commands.py @@ -0,0 +1,48 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import sys +from argparse import Namespace +from functools import partial + +import mozlog +from mach.decorators import Command +from xpcshellcommandline import parser_desktop + + +def run_xpcshell(context, **kwargs): + args = Namespace(**kwargs) + args.appPath = args.appPath or os.path.dirname(context.firefox_bin) + args.utility_path = context.bin_dir + args.testingModulesDir = context.modules_dir + + if not args.xpcshell: + args.xpcshell = os.path.join(args.appPath, "xpcshell") + + log = mozlog.commandline.setup_logging( + "XPCShellTests", args, {"mach": sys.stdout}, {"verbose": True} + ) + + if args.testPaths: + test_root = os.path.join(context.package_root, "xpcshell", "tests") + normalize = partial(context.normalize_test_path, test_root) + # pylint --py3k: W1636 + args.testPaths = list(map(normalize, args.testPaths)) + + import runxpcshelltests + + xpcshell = runxpcshelltests.XPCShellTests(log=log) + return xpcshell.runTests(**vars(args)) + + +@Command( + "xpcshell-test", + category="testing", + description="Run the xpcshell harness.", + parser=parser_desktop, +) +def xpcshell(command_context, **kwargs): + command_context._mach_context.activate_mozharness_venv() + return run_xpcshell(command_context._mach_context, **kwargs) diff --git a/testing/xpcshell/moz-http2/http2-cert.key b/testing/xpcshell/moz-http2/http2-cert.key new file mode 100644 index 0000000000..09e044f5e0 --- /dev/null +++ b/testing/xpcshell/moz-http2/http2-cert.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6iFGoRI4W1kH9 +braIBjYQPTwT2erkNUq07PVoV2wke8HHJajg2B+9sZwGm24ahvJr4q9adWtqZHEI +eqVap0WH9xzVJJwCfs1D/B5p0DggKZOrIMNJ5Nu5TMJrbA7tFYIP8X6taRqx0wI6 +iypB7qdw4A8Njf1mCyuwJJKkfbmIYXmQsVeQPdI7xeC4SB+oN9OIQ+8nFthVt2Za +qn4CkC86exCABiTMHGyXrZZhW7filhLAdTGjDJHdtMr3/K0dJdMJ77kXDqdo4bN7 +LyJvaeO0ipVhHe4m1iWdq5EITjbLHCQELL8Wiy/l8Y+ZFzG4s/5JI/pyUcQx1QOs +2hgKNe2NAgMBAAECggEBAJ7LzjhhpFTsseD+j4XdQ8kvWCXOLpl4hNDhqUnaosWs +VZskBFDlrJ/gw+McDu+mUlpl8MIhlABO4atGPd6e6CKHzJPnRqkZKcXmrD2IdT9s +JbpZeec+XY+yOREaPNq4pLDN9fnKsF8SM6ODNcZLVWBSXn47kq18dQTPHcfLAFeI +r8vh6Pld90AqFRUw1YCDRoZOs3CqeZVqWHhiy1M3kTB/cNkcltItABppAJuSPGgz +iMnzbLm16+ZDAgQceNkIIGuHAJy4yrrK09vbJ5L7kRss9NtmA1hb6a4Mo7jmQXqg +SwbkcOoaO1gcoDpngckxW2KzDmAR8iRyWUbuxXxtlEECgYEA3W4dT//r9o2InE0R +TNqqnKpjpZN0KGyKXCmnF7umA3VkTVyqZ0xLi8cyY1hkYiDkVQ12CKwn1Vttt0+N +gSfvj6CQmLaRR94GVXNEfhg9Iv59iFrOtRPZWB3V4HwakPXOCHneExNx7O/JznLp +xD3BJ9I4GQ3oEXc8pdGTAfSMdCsCgYEA16dz2evDgKdn0v7Ak0rU6LVmckB3Gs3r +ta15b0eP7E1FmF77yVMpaCicjYkQL63yHzTi3UlA66jAnW0fFtzClyl3TEMnXpJR +3b5JCeH9O/Hkvt9Go5uLODMo70rjuVuS8gcK8myefFybWH/t3gXo59hspXiG+xZY +EKd7mEW8MScCgYEAlkcrQaYQwK3hryJmwWAONnE1W6QtS1oOtOnX6zWBQAul3RMs +2xpekyjHu8C7sBVeoZKXLt+X0SdR2Pz2rlcqMLHqMJqHEt1OMyQdse5FX8CT9byb +WS11bmYhR08ywHryL7J100B5KzK6JZC7smGu+5WiWO6lN2VTFb6cJNGRmS0CgYAo +tFCnp1qFZBOyvab3pj49lk+57PUOOCPvbMjo+ibuQT+LnRIFVA8Su+egx2got7pl +rYPMpND+KiIBFOGzXQPVqFv+Jwa9UPzmz83VcbRspiG47UfWBbvnZbCqSgZlrCU2 +TaIBVAMuEgS4VZ0+NPtbF3yaVv+TUQpaSmKHwVHeLQKBgCgGe5NVgB0u9S36ltit +tYlnPPjuipxv9yruq+nva+WKT0q/BfeIlH3IUf2qNFQhR6caJGv7BU7naqNGq80m +ks/J5ExR5vBpxzXgc7oBn2pyFJYckbJoccrqv48GRBigJpDjmo1f8wZ7fNt/ULH1 +NBinA5ZsT8d0v3QCr2xDJH9D +-----END PRIVATE KEY----- diff --git a/testing/xpcshell/moz-http2/http2-cert.key.keyspec b/testing/xpcshell/moz-http2/http2-cert.key.keyspec new file mode 100644 index 0000000000..4ad96d5159 --- /dev/null +++ b/testing/xpcshell/moz-http2/http2-cert.key.keyspec @@ -0,0 +1 @@ +default diff --git a/testing/xpcshell/moz-http2/http2-cert.pem b/testing/xpcshell/moz-http2/http2-cert.pem new file mode 100644 index 0000000000..1f89de1a45 --- /dev/null +++ b/testing/xpcshell/moz-http2/http2-cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDEzCCAfugAwIBAgIUCTTdK3eSofAM6mNwAi4Z4YUn8WEwDQYJKoZIhvcNAQEL +BQAwGTEXMBUGA1UEAwwOIEhUVFAyIFRlc3QgQ0EwIhgPMjAxNzAxMDEwMDAwMDBa +GA8yMDI3MDEwMTAwMDAwMFowGzEZMBcGA1UEAwwQIEhUVFAyIFRlc3QgQ2VydDCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALqIUahEjhbWQf1utogGNhA9 +PBPZ6uQ1SrTs9WhXbCR7wcclqODYH72xnAabbhqG8mvir1p1a2pkcQh6pVqnRYf3 +HNUknAJ+zUP8HmnQOCApk6sgw0nk27lMwmtsDu0Vgg/xfq1pGrHTAjqLKkHup3Dg +Dw2N/WYLK7AkkqR9uYhheZCxV5A90jvF4LhIH6g304hD7ycW2FW3ZlqqfgKQLzp7 +EIAGJMwcbJetlmFbt+KWEsB1MaMMkd20yvf8rR0l0wnvuRcOp2jhs3svIm9p47SK +lWEd7ibWJZ2rkQhONsscJAQsvxaLL+Xxj5kXMbiz/kkj+nJRxDHVA6zaGAo17Y0C +AwEAAaNNMEswSQYDVR0RBEIwQIIJbG9jYWxob3N0gg9mb28uZXhhbXBsZS5jb22C +EGFsdDEuZXhhbXBsZS5jb22CEGFsdDIuZXhhbXBsZS5jb20wDQYJKoZIhvcNAQEL +BQADggEBAE5aEiXOkvEYeWpMhkGheeeaKwgr44qiWJKC5N/8t+NprB3vNCbTMzE9 +09iWQh9EXbwMjMQ8H0uZwedek2sryxsTzxsdTC5qmEtxs/kbf0rTNUwQDjGHvzMk +gO+ULESdLTcIFJ57olHaZaXtPGm2ELJAOiEpsYFTafmCEPXZ/b+UkGsSkuVLSOIA +ClaIJgjff/ucvCvRwl79GzGDCoh3qpqhvxQpC/Fcdz1iQDYEVAmjgUrYJe1lTfj8 +ZozM1WIq8fQ3SCXTJK82CnX818tJio2PWq3uzb9vhpuxJJif7WoMP88Jpdh8zcEb +YL15XPzhQMyor2p6XfwNI3J6347fd7U= +-----END CERTIFICATE----- diff --git a/testing/xpcshell/moz-http2/http2-cert.pem.certspec b/testing/xpcshell/moz-http2/http2-cert.pem.certspec new file mode 100644 index 0000000000..69b3bc83e6 --- /dev/null +++ b/testing/xpcshell/moz-http2/http2-cert.pem.certspec @@ -0,0 +1,4 @@ +issuer: HTTP2 Test CA +subject: HTTP2 Test Cert +validity:20170101-20270101 +extension:subjectAlternativeName:localhost,foo.example.com,alt1.example.com,alt2.example.com diff --git a/testing/xpcshell/moz-http2/moz-http2-child.js b/testing/xpcshell/moz-http2/moz-http2-child.js new file mode 100644 index 0000000000..c8f5d99669 --- /dev/null +++ b/testing/xpcshell/moz-http2/moz-http2-child.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env node */ + +function sendBackResponse(evalResult, e) { + const output = { result: evalResult, error: "", errorStack: "" }; + if (e) { + output.error = e.toString(); + output.errorStack = e.stack; + } + process.send(output); +} + +process.on("message", msg => { + const code = msg.code; + let evalResult = null; + try { + // eslint-disable-next-line no-eval + evalResult = eval(code); + if (evalResult instanceof Promise) { + evalResult + .then(x => sendBackResponse(x)) + .catch(e => sendBackResponse(undefined, e)); + return; + } + } catch (e) { + sendBackResponse(undefined, e); + return; + } + sendBackResponse(evalResult); +}); diff --git a/testing/xpcshell/moz-http2/moz-http2.js b/testing/xpcshell/moz-http2/moz-http2.js new file mode 100644 index 0000000000..334ccd8e1c --- /dev/null +++ b/testing/xpcshell/moz-http2/moz-http2.js @@ -0,0 +1,1920 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This module is the stateful server side of test_http2.js and is meant +// to have node be restarted in between each invocation + +/* eslint-env node */ + +var node_http2_root = "../node-http2"; +if (process.env.NODE_HTTP2_ROOT) { + node_http2_root = process.env.NODE_HTTP2_ROOT; +} +var http2 = require(node_http2_root); +var fs = require("fs"); +var url = require("url"); +var crypto = require("crypto"); +const dnsPacket = require(`${node_http2_root}/../dns-packet`); +const ip = require(`${node_http2_root}/../node_ip`); +const { fork } = require("child_process"); +const path = require("path"); +const zlib = require("zlib"); + +// Hook into the decompression code to log the decompressed name-value pairs +var compression_module = node_http2_root + "/lib/protocol/compressor"; +var http2_compression = require(compression_module); +var HeaderSetDecompressor = http2_compression.HeaderSetDecompressor; +var originalRead = HeaderSetDecompressor.prototype.read; +var lastDecompressor; +var decompressedPairs; +HeaderSetDecompressor.prototype.read = function () { + if (this != lastDecompressor) { + lastDecompressor = this; + decompressedPairs = []; + } + var pair = originalRead.apply(this, arguments); + if (pair) { + decompressedPairs.push(pair); + } + return pair; +}; + +var connection_module = node_http2_root + "/lib/protocol/connection"; +var http2_connection = require(connection_module); +var Connection = http2_connection.Connection; +var originalClose = Connection.prototype.close; +Connection.prototype.close = function (error, lastId) { + if (lastId !== undefined) { + this._lastIncomingStream = lastId; + } + + originalClose.apply(this, arguments); +}; + +var framer_module = node_http2_root + "/lib/protocol/framer"; +var http2_framer = require(framer_module); +var Serializer = http2_framer.Serializer; +var originalTransform = Serializer.prototype._transform; +var newTransform = function (frame, encoding, done) { + if (frame.type == "DATA") { + // Insert our empty DATA frame + const emptyFrame = {}; + emptyFrame.type = "DATA"; + emptyFrame.data = Buffer.alloc(0); + emptyFrame.flags = []; + emptyFrame.stream = frame.stream; + var buffers = []; + Serializer.DATA(emptyFrame, buffers); + Serializer.commonHeader(emptyFrame, buffers); + for (var i = 0; i < buffers.length; i++) { + this.push(buffers[i]); + } + + // Reset to the original version for later uses + Serializer.prototype._transform = originalTransform; + } + originalTransform.apply(this, arguments); +}; + +function getHttpContent(pathName) { + var content = + "" + + "" + + "HOORAY!" + + // 'You Win!' used in tests to check we reached this server + "You Win! (by requesting" + + pathName + + ")" + + ""; + return content; +} + +function generateContent(size) { + var content = ""; + for (var i = 0; i < size; i++) { + content += "0"; + } + return content; +} + +/* This takes care of responding to the multiplexed request for us */ +var m = { + mp1res: null, + mp2res: null, + buf: null, + mp1start: 0, + mp2start: 0, + + checkReady() { + if (this.mp1res != null && this.mp2res != null) { + this.buf = generateContent(30 * 1024); + this.mp1start = 0; + this.mp2start = 0; + this.send(this.mp1res, 0); + setTimeout(this.send.bind(this, this.mp2res, 0), 5); + } + }, + + send(res, start) { + var end = Math.min(start + 1024, this.buf.length); + var content = this.buf.substring(start, end); + res.write(content); + if (end < this.buf.length) { + setTimeout(this.send.bind(this, res, end), 10); + } else { + // Clear these variables so we can run the test again with --verify + if (res == this.mp1res) { + this.mp1res = null; + } else { + this.mp2res = null; + } + res.end(); + } + }, +}; + +var runlater = function () {}; +runlater.prototype = { + req: null, + resp: null, + fin: true, + + onTimeout: function onTimeout() { + this.resp.writeHead(200); + if (this.fin) { + this.resp.end("It's all good 750ms."); + } + }, +}; + +var runConnectLater = function () {}; +runConnectLater.prototype = { + req: null, + resp: null, + connect: false, + + onTimeout: function onTimeout() { + if (this.connect) { + this.resp.writeHead(200); + this.connect = true; + setTimeout(executeRunLaterCatchError, 50, this); + } else { + this.resp.end("HTTP/1.1 200\n\r\n\r"); + } + }, +}; + +var moreData = function () {}; +moreData.prototype = { + req: null, + resp: null, + iter: 3, + + onTimeout: function onTimeout() { + // 1mb of data + const content = generateContent(1024 * 1024); + this.resp.write(content); // 1mb chunk + this.iter--; + if (!this.iter) { + this.resp.end(); + } else { + setTimeout(executeRunLater, 1, this); + } + }, +}; + +function executeRunLater(arg) { + arg.onTimeout(); +} + +function executeRunLaterCatchError(arg) { + arg.onTimeout(); +} + +var h11required_conn = null; +var h11required_header = "yes"; +var didRst = false; +var rstConnection = null; +var illegalheader_conn = null; + +var gDoHPortsLog = []; +var gDoHNewConnLog = {}; +var gDoHRequestCount = 0; + +// eslint-disable-next-line complexity +function handleRequest(req, res) { + var u = ""; + if (req.url != undefined) { + u = url.parse(req.url, true); + } + var content = getHttpContent(u.pathname); + var push, push1, push1a, push2, push3; + + // PushService tests. + var pushPushServer1, pushPushServer2, pushPushServer3, pushPushServer4; + + function createCNameContent(payload) { + let packet = dnsPacket.decode(payload); + if ( + packet.questions[0].name == "cname.example.com" && + packet.questions[0].type == "A" + ) { + return dnsPacket.encode({ + id: 0, + type: "response", + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ name: packet.questions[0].name, type: "A", class: "IN" }], + answers: [ + { + name: packet.questions[0].name, + ttl: 55, + type: "CNAME", + flush: false, + data: "pointing-elsewhere.example.com", + }, + ], + }); + } + if ( + packet.questions[0].name == "pointing-elsewhere.example.com" && + packet.questions[0].type == "A" + ) { + return dnsPacket.encode({ + id: 0, + type: "response", + flags: dnsPacket.RECURSION_DESIRED, + questions: [{ name: packet.questions[0].name, type: "A", class: "IN" }], + answers: [ + { + name: packet.questions[0].name, + ttl: 55, + type: "A", + flush: false, + data: "99.88.77.66", + }, + ], + }); + } + + return dnsPacket.encode({ + id: 0, + type: "response", + flags: dnsPacket.RECURSION_DESIRED | dnsPacket.rcodes.toRcode("NXDOMAIN"), + questions: [ + { + name: packet.questions[0].name, + type: packet.questions[0].type, + class: "IN", + }, + ], + answers: [], + }); + } + + function createCNameARecord() { + // test23 asks for cname-a.example.com + // this responds with a CNAME to here.example.com *and* an A record + // for here.example.com + let rContent; + + rContent = Buffer.from( + "0000" + + "0100" + + "0001" + // QDCOUNT + "0002" + // ANCOUNT + "00000000" + // NSCOUNT + ARCOUNT + "07636E616D652d61" + // cname-a + "076578616D706C6503636F6D00" + // .example.com + "00010001" + // question type (A) + question class (IN) + // answer record 1 + "C00C" + // name pointer to cname-a.example.com + "0005" + // type (CNAME) + "0001" + // class + "00000037" + // TTL + "0012" + // RDLENGTH + "0468657265" + // here + "076578616D706C6503636F6D00" + // .example.com + // answer record 2, the A entry for the CNAME above + "0468657265" + // here + "076578616D706C6503636F6D00" + // .example.com + "0001" + // type (A) + "0001" + // class + "00000037" + // TTL + "0004" + // RDLENGTH + "09080706", // IPv4 address + "hex" + ); + + return rContent; + } + + function responseType(packet, responseIP) { + if ( + !!packet.questions.length && + packet.questions[0].name == "confirm.example.com" && + packet.questions[0].type == "NS" + ) { + return "NS"; + } + + return ip.isV4Format(responseIP) ? "A" : "AAAA"; + } + + function handleAuth() { + // There's a Set-Cookie: header in the response for "/dns" , which this + // request subsequently would include if the http channel wasn't + // anonymous. Thus, if there's a cookie in this request, we know Firefox + // mishaved. If there's not, we're fine. + if (req.headers.cookie) { + res.writeHead(403); + res.end("cookie for me, not for you"); + return false; + } + if (req.headers.authorization != "user:password") { + res.writeHead(401); + res.end("bad boy!"); + return false; + } + + return true; + } + + function createDNSAnswer(response, packet, responseIP, requestPayload) { + // This shuts down the connection so we can test if the client reconnects + if (packet.questions.length && packet.questions[0].name == "closeme.com") { + response.stream.connection.close("INTERNAL_ERROR", response.stream.id); + return null; + } + + if (packet.questions.length && packet.questions[0].name.endsWith(".pd")) { + // Bug 1543811: test edns padding extension. Return whether padding was + // included via the first half of the ip address (1.1 vs 2.2) and the + // size of the request in the second half of the ip address allowing to + // verify that the correct amount of padding was added. + if ( + !!packet.additionals.length && + packet.additionals[0].type == "OPT" && + packet.additionals[0].options.some(o => o.type === "PADDING") + ) { + responseIP = + "1.1." + + ((requestPayload.length >> 8) & 0xff) + + "." + + (requestPayload.length & 0xff); + } else { + responseIP = + "2.2." + + ((requestPayload.length >> 8) & 0xff) + + "." + + (requestPayload.length & 0xff); + } + } + + if (u.query.corruptedAnswer) { + // DNS response header is 12 bytes, we check for this minimum length + // at the start of decoding so this is the simplest way to force + // a decode error. + return "\xFF\xFF\xFF\xFF"; + } + + // Because we send two TRR requests (A and AAAA), skip the first two + // requests when testing retry. + if (u.query.retryOnDecodeFailure && gDoHRequestCount < 2) { + gDoHRequestCount++; + return "\xFF\xFF\xFF\xFF"; + } + + function responseData() { + if ( + !!packet.questions.length && + packet.questions[0].name == "confirm.example.com" && + packet.questions[0].type == "NS" + ) { + return "ns.example.com"; + } + + return responseIP; + } + + let answers = []; + if ( + responseIP != "none" && + responseType(packet, responseIP) == packet.questions[0].type + ) { + answers.push({ + name: u.query.hostname ? u.query.hostname : packet.questions[0].name, + ttl: 55, + type: responseType(packet, responseIP), + flush: false, + data: responseData(), + }); + } + + // for use with test_dns_by_type_resolve.js + if (packet.questions[0].type == "TXT") { + answers.push({ + name: packet.questions[0].name, + type: packet.questions[0].type, + ttl: 55, + class: "IN", + flush: false, + data: Buffer.from( + "62586B67646D39705932556761584D6762586B676347467A63336476636D513D", + "hex" + ), + }); + } + + if (u.query.cnameloop) { + answers.push({ + name: "cname.example.com", + type: "CNAME", + ttl: 55, + class: "IN", + flush: false, + data: "pointing-elsewhere.example.com", + }); + } + + if (req.headers["accept-language"] || req.headers["user-agent"]) { + // If we get this header, don't send back any response. This should + // cause the tests to fail. This is easier then actually sending back + // the header value into test_trr.js + answers = []; + } + + let buf = dnsPacket.encode({ + type: "response", + id: packet.id, + flags: dnsPacket.RECURSION_DESIRED, + questions: packet.questions, + answers, + }); + + return buf; + } + + function getDelayFromPacket(packet, type) { + let delay = 0; + if (packet.questions[0].type == "A") { + delay = parseInt(u.query.delayIPv4); + } else if (packet.questions[0].type == "AAAA") { + delay = parseInt(u.query.delayIPv6); + } + + if (u.query.slowConfirm && type == "NS") { + delay += 1000; + } + + return delay; + } + + function writeDNSResponse(response, buf, delay, contentType) { + function writeResponse(resp, buffer) { + resp.setHeader("Set-Cookie", "trackyou=yes; path=/; max-age=100000;"); + resp.setHeader("Content-Type", contentType); + if (req.headers["accept-encoding"].includes("gzip")) { + zlib.gzip(buffer, function (err, result) { + resp.setHeader("Content-Encoding", "gzip"); + resp.setHeader("Content-Length", result.length); + try { + resp.writeHead(200); + resp.end(result); + } catch (e) { + // connection was closed by the time we started writing. + } + }); + } else { + const output = Buffer.from(buffer, "utf-8"); + resp.setHeader("Content-Length", output.length); + try { + resp.writeHead(200); + resp.write(output); + resp.end(""); + } catch (e) { + // connection was closed by the time we started writing. + } + } + } + + if (delay) { + setTimeout( + arg => { + writeResponse(arg[0], arg[1]); + }, + delay, + [response, buf] + ); + return; + } + + writeResponse(response, buf); + } + + if (req.httpVersionMajor === 2) { + res.setHeader("X-Connection-Http2", "yes"); + res.setHeader("X-Http2-StreamId", "" + req.stream.id); + } else { + res.setHeader("X-Connection-Http2", "no"); + } + + if (u.pathname === "/exit") { + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Connection", "close"); + res.writeHead(200); + res.end("ok"); + process.exit(); + } + + if (req.method == "CONNECT") { + if (req.headers.host == "illegalhpacksoft.example.com:80") { + illegalheader_conn = req.stream.connection; + res.setHeader("Content-Type", "text/html"); + res.setHeader("x-softillegalhpack", "true"); + res.writeHead(200); + res.end(content); + return; + } else if (req.headers.host == "illegalhpackhard.example.com:80") { + res.setHeader("Content-Type", "text/html"); + res.setHeader("x-hardillegalhpack", "true"); + res.writeHead(200); + res.end(content); + return; + } else if (req.headers.host == "750.example.com:80") { + // This response will mock a response through a proxy to a HTTP server. + // After 750ms , a 200 response for the proxy will be sent then + // after additional 50ms a 200 response for the HTTP GET request. + let rl = new runConnectLater(); + rl.req = req; + rl.resp = res; + setTimeout(executeRunLaterCatchError, 750, rl); + return; + } else if (req.headers.host == "h11required.com:80") { + if (req.httpVersionMajor === 2) { + res.stream.reset("HTTP_1_1_REQUIRED"); + } + return; + } + } else if (u.pathname === "/750ms") { + let rl = new runlater(); + rl.req = req; + rl.resp = res; + setTimeout(executeRunLater, 750, rl); + return; + } else if (u.pathname === "/750msNoData") { + let rl = new runlater(); + rl.req = req; + rl.resp = res; + rl.fin = false; + setTimeout(executeRunLater, 750, rl); + return; + } else if (u.pathname === "/multiplex1" && req.httpVersionMajor === 2) { + res.setHeader("Content-Type", "text/plain"); + res.writeHead(200); + m.mp1res = res; + m.checkReady(); + return; + } else if (u.pathname === "/multiplex2" && req.httpVersionMajor === 2) { + res.setHeader("Content-Type", "text/plain"); + res.writeHead(200); + m.mp2res = res; + m.checkReady(); + return; + } else if (u.pathname === "/header") { + var val = req.headers["x-test-header"]; + if (val) { + res.setHeader("X-Received-Test-Header", val); + } + } else if (u.pathname === "/doubleheader") { + res.setHeader("Content-Type", "text/html"); + res.writeHead(200); + res.write(content); + res.writeHead(200); + res.end(); + return; + } else if (u.pathname === "/cookie_crumbling") { + res.setHeader("X-Received-Header-Pairs", JSON.stringify(decompressedPairs)); + } else if (u.pathname === "/push") { + push = res.push("/push.js"); + push.writeHead(200, { + "content-type": "application/javascript", + pushed: "yes", + "content-length": 11, + "X-Connection-Http2": "yes", + }); + push.end("// comments"); + content = ' + + diff --git a/testing/xpcshell/node-ws/examples/server-stats/index.js b/testing/xpcshell/node-ws/examples/server-stats/index.js new file mode 100644 index 0000000000..e8754b5b28 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/server-stats/index.js @@ -0,0 +1,33 @@ +'use strict'; + +const express = require('express'); +const path = require('path'); +const { createServer } = require('http'); + +const { WebSocketServer } = require('../..'); + +const app = express(); +app.use(express.static(path.join(__dirname, '/public'))); + +const server = createServer(app); +const wss = new WebSocketServer({ server }); + +wss.on('connection', function (ws) { + const id = setInterval(function () { + ws.send(JSON.stringify(process.memoryUsage()), function () { + // + // Ignore errors. + // + }); + }, 100); + console.log('started client interval'); + + ws.on('close', function () { + console.log('stopping client interval'); + clearInterval(id); + }); +}); + +server.listen(8080, function () { + console.log('Listening on http://localhost:8080'); +}); diff --git a/testing/xpcshell/node-ws/examples/server-stats/package.json b/testing/xpcshell/node-ws/examples/server-stats/package.json new file mode 100644 index 0000000000..20e2029133 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/server-stats/package.json @@ -0,0 +1,9 @@ +{ + "author": "", + "name": "serverstats", + "version": "0.0.0", + "repository": "websockets/ws", + "dependencies": { + "express": "^4.16.4" + } +} diff --git a/testing/xpcshell/node-ws/examples/server-stats/public/index.html b/testing/xpcshell/node-ws/examples/server-stats/public/index.html new file mode 100644 index 0000000000..a82815af6f --- /dev/null +++ b/testing/xpcshell/node-ws/examples/server-stats/public/index.html @@ -0,0 +1,63 @@ + + + + + Server stats + + + +

Server stats

+ + + + + + + + + + + + + + + + + + + + + + + + +
Memory usage
RSS
Heap total
Heap used
External
+ + + diff --git a/testing/xpcshell/node-ws/examples/ssl.js b/testing/xpcshell/node-ws/examples/ssl.js new file mode 100644 index 0000000000..a5e750b799 --- /dev/null +++ b/testing/xpcshell/node-ws/examples/ssl.js @@ -0,0 +1,37 @@ +'use strict'; + +const https = require('https'); +const fs = require('fs'); + +const { WebSocket, WebSocketServer } = require('..'); + +const server = https.createServer({ + cert: fs.readFileSync('../test/fixtures/certificate.pem'), + key: fs.readFileSync('../test/fixtures/key.pem') +}); + +const wss = new WebSocketServer({ server }); + +wss.on('connection', function connection(ws) { + ws.on('message', function message(msg) { + console.log(msg.toString()); + }); +}); + +server.listen(function listening() { + // + // If the `rejectUnauthorized` option is not `false`, the server certificate + // is verified against a list of well-known CAs. An 'error' event is emitted + // if verification fails. + // + // The certificate used in this example is self-signed so `rejectUnauthorized` + // is set to `false`. + // + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', function open() { + ws.send('All glory to WebSockets!'); + }); +}); diff --git a/testing/xpcshell/node-ws/index.js b/testing/xpcshell/node-ws/index.js new file mode 100644 index 0000000000..41edb3b81b --- /dev/null +++ b/testing/xpcshell/node-ws/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const WebSocket = require('./lib/websocket'); + +WebSocket.createWebSocketStream = require('./lib/stream'); +WebSocket.Server = require('./lib/websocket-server'); +WebSocket.Receiver = require('./lib/receiver'); +WebSocket.Sender = require('./lib/sender'); + +WebSocket.WebSocket = WebSocket; +WebSocket.WebSocketServer = WebSocket.Server; + +module.exports = WebSocket; diff --git a/testing/xpcshell/node-ws/lib/buffer-util.js b/testing/xpcshell/node-ws/lib/buffer-util.js new file mode 100644 index 0000000000..df75955467 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/buffer-util.js @@ -0,0 +1,127 @@ +'use strict'; + +const { EMPTY_BUFFER } = require('./constants'); + +/** + * Merges an array of buffers into a new buffer. + * + * @param {Buffer[]} list The array of buffers to concat + * @param {Number} totalLength The total length of buffers in the list + * @return {Buffer} The resulting buffer + * @public + */ +function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + + for (let i = 0; i < list.length; i++) { + const buf = list[i]; + target.set(buf, offset); + offset += buf.length; + } + + if (offset < totalLength) return target.slice(0, offset); + + return target; +} + +/** + * Masks a buffer using the given mask. + * + * @param {Buffer} source The buffer to mask + * @param {Buffer} mask The mask to use + * @param {Buffer} output The buffer where to store the result + * @param {Number} offset The offset at which to start writing + * @param {Number} length The number of bytes to mask. + * @public + */ +function _mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } +} + +/** + * Unmasks a buffer using the given mask. + * + * @param {Buffer} buffer The buffer to unmask + * @param {Buffer} mask The mask to use + * @public + */ +function _unmask(buffer, mask) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] ^= mask[i & 3]; + } +} + +/** + * Converts a buffer to an `ArrayBuffer`. + * + * @param {Buffer} buf The buffer to convert + * @return {ArrayBuffer} Converted buffer + * @public + */ +function toArrayBuffer(buf) { + if (buf.byteLength === buf.buffer.byteLength) { + return buf.buffer; + } + + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +/** + * Converts `data` to a `Buffer`. + * + * @param {*} data The data to convert + * @return {Buffer} The buffer + * @throws {TypeError} + * @public + */ +function toBuffer(data) { + toBuffer.readOnly = true; + + if (Buffer.isBuffer(data)) return data; + + let buf; + + if (data instanceof ArrayBuffer) { + buf = Buffer.from(data); + } else if (ArrayBuffer.isView(data)) { + buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + + return buf; +} + +module.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask +}; + +/* istanbul ignore else */ +if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require('bufferutil'); + + module.exports.mask = function (source, mask, output, offset, length) { + if (length < 48) _mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); + }; + + module.exports.unmask = function (buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + // Continue regardless of the error. + } +} diff --git a/testing/xpcshell/node-ws/lib/constants.js b/testing/xpcshell/node-ws/lib/constants.js new file mode 100644 index 0000000000..d691b30a17 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/constants.js @@ -0,0 +1,12 @@ +'use strict'; + +module.exports = { + BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'], + EMPTY_BUFFER: Buffer.alloc(0), + GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), + kListener: Symbol('kListener'), + kStatusCode: Symbol('status-code'), + kWebSocket: Symbol('websocket'), + NOOP: () => {} +}; diff --git a/testing/xpcshell/node-ws/lib/event-target.js b/testing/xpcshell/node-ws/lib/event-target.js new file mode 100644 index 0000000000..d5abd83a0f --- /dev/null +++ b/testing/xpcshell/node-ws/lib/event-target.js @@ -0,0 +1,266 @@ +'use strict'; + +const { kForOnEventAttribute, kListener } = require('./constants'); + +const kCode = Symbol('kCode'); +const kData = Symbol('kData'); +const kError = Symbol('kError'); +const kMessage = Symbol('kMessage'); +const kReason = Symbol('kReason'); +const kTarget = Symbol('kTarget'); +const kType = Symbol('kType'); +const kWasClean = Symbol('kWasClean'); + +/** + * Class representing an event. + */ +class Event { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + + /** + * @type {String} + */ + get type() { + return this[kType]; + } +} + +Object.defineProperty(Event.prototype, 'target', { enumerable: true }); +Object.defineProperty(Event.prototype, 'type', { enumerable: true }); + +/** + * Class representing a close event. + * + * @extends Event + */ +class CloseEvent extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options = {}) { + super(type); + + this[kCode] = options.code === undefined ? 0 : options.code; + this[kReason] = options.reason === undefined ? '' : options.reason; + this[kWasClean] = options.wasClean === undefined ? false : options.wasClean; + } + + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } +} + +Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true }); +Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true }); +Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true }); + +/** + * Class representing an error event. + * + * @extends Event + */ +class ErrorEvent extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options = {}) { + super(type); + + this[kError] = options.error === undefined ? null : options.error; + this[kMessage] = options.message === undefined ? '' : options.message; + } + + /** + * @type {*} + */ + get error() { + return this[kError]; + } + + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } +} + +Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true }); +Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true }); + +/** + * Class representing a message event. + * + * @extends Event + */ +class MessageEvent extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options = {}) { + super(type); + + this[kData] = options.data === undefined ? null : options.data; + } + + /** + * @type {*} + */ + get data() { + return this[kData]; + } +} + +Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true }); + +/** + * This provides methods for emulating the `EventTarget` interface. It's not + * meant to be used directly. + * + * @mixin + */ +const EventTarget = { + /** + * Register an event listener. + * + * @param {String} type A string representing the event type to listen for + * @param {Function} listener The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. + * @public + */ + addEventListener(type, listener, options = {}) { + let wrapper; + + if (type === 'message') { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent('message', { + data: isBinary ? data : data.toString() + }); + + event[kTarget] = this; + listener.call(this, event); + }; + } else if (type === 'close') { + wrapper = function onClose(code, message) { + const event = new CloseEvent('close', { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + + event[kTarget] = this; + listener.call(this, event); + }; + } else if (type === 'error') { + wrapper = function onError(error) { + const event = new ErrorEvent('error', { + error, + message: error.message + }); + + event[kTarget] = this; + listener.call(this, event); + }; + } else if (type === 'open') { + wrapper = function onOpen() { + const event = new Event('open'); + + event[kTarget] = this; + listener.call(this, event); + }; + } else { + return; + } + + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; + wrapper[kListener] = listener; + + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {Function} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } +}; + +module.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent +}; diff --git a/testing/xpcshell/node-ws/lib/extension.js b/testing/xpcshell/node-ws/lib/extension.js new file mode 100644 index 0000000000..3d7895c1b0 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/extension.js @@ -0,0 +1,203 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Adds an offer to the map of extension offers or a parameter to the map of + * parameters. + * + * @param {Object} dest The map of extension offers or parameters + * @param {String} name The extension or parameter name + * @param {(Object|Boolean|String)} elem The extension parameters or the + * parameter value + * @private + */ +function push(dest, name, elem) { + if (dest[name] === undefined) dest[name] = [elem]; + else dest[name].push(elem); +} + +/** + * Parses the `Sec-WebSocket-Extensions` header into an object. + * + * @param {String} header The field value of the header + * @return {Object} The parsed object + * @public + */ +function parse(header) { + const offers = Object.create(null); + let params = Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i = 0; + + for (; i < header.length; i++) { + code = header.charCodeAt(i); + + if (extensionName === undefined) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + const name = header.slice(start, end); + if (code === 0x2c) { + push(offers, name, params); + params = Object.create(null); + } else { + extensionName = name; + } + + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (paramName === undefined) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 0x20 || code === 0x09) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x3b || code === 0x2c) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + push(params, header.slice(start, end), true); + if (code === 0x2c) { + push(offers, extensionName, params); + params = Object.create(null); + extensionName = undefined; + } + + start = end = -1; + } else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) { + paramName = header.slice(start, i); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else { + // + // The value of a quoted-string after unescaping must conform to the + // token ABNF, so only token characters are valid. + // Ref: https://tools.ietf.org/html/rfc6455#section-9.1 + // + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (start === -1) start = i; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 0x22 /* '"' */ && start !== -1) { + inQuotes = false; + end = i; + } else if (code === 0x5c /* '\' */) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (start !== -1 && (code === 0x20 || code === 0x09)) { + if (end === -1) end = i; + } else if (code === 0x3b || code === 0x2c) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ''); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 0x2c) { + push(offers, extensionName, params); + params = Object.create(null); + extensionName = undefined; + } + + paramName = undefined; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + } + + if (start === -1 || inQuotes || code === 0x20 || code === 0x09) { + throw new SyntaxError('Unexpected end of input'); + } + + if (end === -1) end = i; + const token = header.slice(start, end); + if (extensionName === undefined) { + push(offers, token, params); + } else { + if (paramName === undefined) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, '')); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + + return offers; +} + +/** + * Builds the `Sec-WebSocket-Extensions` header field value. + * + * @param {Object} extensions The map of extensions and parameters to format + * @return {String} A string representing the given object + * @public + */ +function format(extensions) { + return Object.keys(extensions) + .map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations + .map((params) => { + return [extension] + .concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values + .map((v) => (v === true ? k : `${k}=${v}`)) + .join('; '); + }) + ) + .join('; '); + }) + .join(', '); + }) + .join(', '); +} + +module.exports = { format, parse }; diff --git a/testing/xpcshell/node-ws/lib/limiter.js b/testing/xpcshell/node-ws/lib/limiter.js new file mode 100644 index 0000000000..3fd35784ea --- /dev/null +++ b/testing/xpcshell/node-ws/lib/limiter.js @@ -0,0 +1,55 @@ +'use strict'; + +const kDone = Symbol('kDone'); +const kRun = Symbol('kRun'); + +/** + * A very simple job queue with adjustable concurrency. Adapted from + * https://github.com/STRML/async-limiter + */ +class Limiter { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + + if (this.jobs.length) { + const job = this.jobs.shift(); + + this.pending++; + job(this[kDone]); + } + } +} + +module.exports = Limiter; diff --git a/testing/xpcshell/node-ws/lib/permessage-deflate.js b/testing/xpcshell/node-ws/lib/permessage-deflate.js new file mode 100644 index 0000000000..94603c98da --- /dev/null +++ b/testing/xpcshell/node-ws/lib/permessage-deflate.js @@ -0,0 +1,511 @@ +'use strict'; + +const zlib = require('zlib'); + +const bufferUtil = require('./buffer-util'); +const Limiter = require('./limiter'); +const { kStatusCode } = require('./constants'); + +const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); +const kPerMessageDeflate = Symbol('permessage-deflate'); +const kTotalLength = Symbol('total-length'); +const kCallback = Symbol('callback'); +const kBuffers = Symbol('buffers'); +const kError = Symbol('error'); + +// +// We limit zlib concurrency, which prevents severe memory fragmentation +// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913 +// and https://github.com/websockets/ws/issues/1202 +// +// Intentionally global; it's the global thread pool that's an issue. +// +let zlibLimiter; + +/** + * permessage-deflate implementation. + */ +class PerMessageDeflate { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + * @param {Boolean} [isServer=false] Create the instance in either server or + * client mode + * @param {Number} [maxPayload=0] The maximum allowed message length + */ + constructor(options, isServer, maxPayload) { + this._maxPayload = maxPayload | 0; + this._options = options || {}; + this._threshold = + this._options.threshold !== undefined ? this._options.threshold : 1024; + this._isServer = !!isServer; + this._deflate = null; + this._inflate = null; + + this.params = null; + + if (!zlibLimiter) { + const concurrency = + this._options.concurrencyLimit !== undefined + ? this._options.concurrencyLimit + : 10; + zlibLimiter = new Limiter(concurrency); + } + } + + /** + * @type {String} + */ + static get extensionName() { + return 'permessage-deflate'; + } + + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + + return params; + } + + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + + this.params = this._isServer + ? this.acceptAsServer(configurations) + : this.acceptAsClient(configurations); + + return this.params; + } + + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + + if (this._deflate) { + const callback = this._deflate[kCallback]; + + this._deflate.close(); + this._deflate = null; + + if (callback) { + callback( + new Error( + 'The deflate stream was closed while data was being processed' + ) + ); + } + } + } + + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if ( + (opts.serverNoContextTakeover === false && + params.server_no_context_takeover) || + (params.server_max_window_bits && + (opts.serverMaxWindowBits === false || + (typeof opts.serverMaxWindowBits === 'number' && + opts.serverMaxWindowBits > params.server_max_window_bits))) || + (typeof opts.clientMaxWindowBits === 'number' && + !params.client_max_window_bits) + ) { + return false; + } + + return true; + }); + + if (!accepted) { + throw new Error('None of the extension offers can be accepted'); + } + + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === 'number') { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === 'number') { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if ( + accepted.client_max_window_bits === true || + opts.clientMaxWindowBits === false + ) { + delete accepted.client_max_window_bits; + } + + return accepted; + } + + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + + if ( + this._options.clientNoContextTakeover === false && + params.client_no_context_takeover + ) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === 'number') { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if ( + this._options.clientMaxWindowBits === false || + (typeof this._options.clientMaxWindowBits === 'number' && + params.client_max_window_bits > this._options.clientMaxWindowBits) + ) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + + return params; + } + + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + + value = value[0]; + + if (key === 'client_max_window_bits') { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === 'server_max_window_bits') { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if ( + key === 'client_no_context_takeover' || + key === 'server_no_context_takeover' + ) { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + + params[key] = value; + }); + }); + + return configurations; + } + + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? 'client' : 'server'; + + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = + typeof this.params[key] !== 'number' + ? zlib.Z_DEFAULT_WINDOWBITS + : this.params[key]; + + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on('error', inflateOnError); + this._inflate.on('data', inflateOnData); + } + + this._inflate[kCallback] = callback; + + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + + this._inflate.flush(() => { + const err = this._inflate[kError]; + + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + + const data = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + + if (this._inflate._readableState.endEmitted) { + this._inflate.close(); + this._inflate = null; + } else { + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } + } + + callback(null, data); + }); + } + + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? 'server' : 'client'; + + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = + typeof this.params[key] !== 'number' + ? zlib.Z_DEFAULT_WINDOWBITS + : this.params[key]; + + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + + this._deflate.on('data', deflateOnData); + } + + this._deflate[kCallback] = callback; + + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + // + // The deflate stream was closed while data was being processed. + // + return; + } + + let data = bufferUtil.concat( + this._deflate[kBuffers], + this._deflate[kTotalLength] + ); + + if (fin) data = data.slice(0, data.length - 4); + + // + // Ensure that the callback will not be called again in + // `PerMessageDeflate#cleanup()`. + // + this._deflate[kCallback] = null; + + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._deflate.reset(); + } + + callback(null, data); + }); + } +} + +module.exports = PerMessageDeflate; + +/** + * The listener of the `zlib.DeflateRaw` stream `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; +} + +/** + * The listener of the `zlib.InflateRaw` stream `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + + if ( + this[kPerMessageDeflate]._maxPayload < 1 || + this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload + ) { + this[kBuffers].push(chunk); + return; + } + + this[kError] = new RangeError('Max payload size exceeded'); + this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'; + this[kError][kStatusCode] = 1009; + this.removeListener('data', inflateOnData); + this.reset(); +} + +/** + * The listener of the `zlib.InflateRaw` stream `'error'` event. + * + * @param {Error} err The emitted error + * @private + */ +function inflateOnError(err) { + // + // There is no need to call `Zlib#close()` as the handle is automatically + // closed when an error is emitted. + // + this[kPerMessageDeflate]._inflate = null; + err[kStatusCode] = 1007; + this[kCallback](err); +} diff --git a/testing/xpcshell/node-ws/lib/receiver.js b/testing/xpcshell/node-ws/lib/receiver.js new file mode 100644 index 0000000000..2d29d62bb0 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/receiver.js @@ -0,0 +1,618 @@ +'use strict'; + +const { Writable } = require('stream'); + +const PerMessageDeflate = require('./permessage-deflate'); +const { + BINARY_TYPES, + EMPTY_BUFFER, + kStatusCode, + kWebSocket +} = require('./constants'); +const { concat, toArrayBuffer, unmask } = require('./buffer-util'); +const { isValidStatusCode, isValidUTF8 } = require('./validation'); + +const GET_INFO = 0; +const GET_PAYLOAD_LENGTH_16 = 1; +const GET_PAYLOAD_LENGTH_64 = 2; +const GET_MASK = 3; +const GET_DATA = 4; +const INFLATING = 5; + +/** + * HyBi Receiver implementation. + * + * @extends Writable + */ +class Receiver extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options = {}) { + super(); + + this._binaryType = options.binaryType || BINARY_TYPES[0]; + this._extensions = options.extensions || {}; + this._isServer = !!options.isServer; + this._maxPayload = options.maxPayload | 0; + this._skipUTF8Validation = !!options.skipUTF8Validation; + this[kWebSocket] = undefined; + + this._bufferedBytes = 0; + this._buffers = []; + + this._compressed = false; + this._payloadLength = 0; + this._mask = undefined; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragments = []; + + this._state = GET_INFO; + this._loop = false; + } + + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 0x08 && this._state == GET_INFO) return cb(); + + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + + if (n === this._buffers[0].length) return this._buffers.shift(); + + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = buf.slice(n); + return buf.slice(0, n); + } + + const dst = Buffer.allocUnsafe(n); + + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = buf.slice(n); + } + + n -= buf.length; + } while (n > 0); + + return dst; + } + + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + let err; + this._loop = true; + + do { + switch (this._state) { + case GET_INFO: + err = this.getInfo(); + break; + case GET_PAYLOAD_LENGTH_16: + err = this.getPayloadLength16(); + break; + case GET_PAYLOAD_LENGTH_64: + err = this.getPayloadLength64(); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + err = this.getData(cb); + break; + default: + // `INFLATING` + this._loop = false; + return; + } + } while (this._loop); + + cb(err); + } + + /** + * Reads the first two bytes of a frame. + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getInfo() { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + const buf = this.consume(2); + + if ((buf[0] & 0x30) !== 0x00) { + this._loop = false; + return error( + RangeError, + 'RSV2 and RSV3 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_2_3' + ); + } + + const compressed = (buf[0] & 0x40) === 0x40; + + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + this._fin = (buf[0] & 0x80) === 0x80; + this._opcode = buf[0] & 0x0f; + this._payloadLength = buf[1] & 0x7f; + + if (this._opcode === 0x00) { + if (compressed) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + if (!this._fragmented) { + this._loop = false; + return error( + RangeError, + 'invalid opcode 0', + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + this._opcode = this._fragmented; + } else if (this._opcode === 0x01 || this._opcode === 0x02) { + if (this._fragmented) { + this._loop = false; + return error( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + this._compressed = compressed; + } else if (this._opcode > 0x07 && this._opcode < 0x0b) { + if (!this._fin) { + this._loop = false; + return error( + RangeError, + 'FIN must be set', + true, + 1002, + 'WS_ERR_EXPECTED_FIN' + ); + } + + if (compressed) { + this._loop = false; + return error( + RangeError, + 'RSV1 must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_RSV_1' + ); + } + + if (this._payloadLength > 0x7d) { + this._loop = false; + return error( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' + ); + } + } else { + this._loop = false; + return error( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + 'WS_ERR_INVALID_OPCODE' + ); + } + + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 0x80) === 0x80; + + if (this._isServer) { + if (!this._masked) { + this._loop = false; + return error( + RangeError, + 'MASK must be set', + true, + 1002, + 'WS_ERR_EXPECTED_MASK' + ); + } + } else if (this._masked) { + this._loop = false; + return error( + RangeError, + 'MASK must be clear', + true, + 1002, + 'WS_ERR_UNEXPECTED_MASK' + ); + } + + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else return this.haveLength(); + } + + /** + * Gets extended payload length (7+16). + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getPayloadLength16() { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + + this._payloadLength = this.consume(2).readUInt16BE(0); + return this.haveLength(); + } + + /** + * Gets extended payload length (7+64). + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + getPayloadLength64() { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + + // + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + // if payload length is greater than this number. + // + if (num > Math.pow(2, 53 - 32) - 1) { + this._loop = false; + return error( + RangeError, + 'Unsupported WebSocket frame: payload length > 2^53 - 1', + false, + 1009, + 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH' + ); + } + + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + return this.haveLength(); + } + + /** + * Payload length has been read. + * + * @return {(RangeError|undefined)} A possible error + * @private + */ + haveLength() { + if (this._payloadLength && this._opcode < 0x08) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + this._loop = false; + return error( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ); + } + } + + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + + this._mask = this.consume(4); + this._state = GET_DATA; + } + + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + + data = this.consume(this._payloadLength); + + if ( + this._masked && + (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0 + ) { + unmask(data, this._mask); + } + } + + if (this._opcode > 0x07) return this.controlMessage(data); + + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + + if (data.length) { + // + // This message is not compressed so its length is the sum of the payload + // length of all fragments. + // + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + + return this.dataMessage(); + } + + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + return cb( + error( + RangeError, + 'Max payload size exceeded', + false, + 1009, + 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH' + ) + ); + } + + this._fragments.push(buf); + } + + const er = this.dataMessage(); + if (er) return cb(er); + + this.startLoop(cb); + }); + } + + /** + * Handles a data message. + * + * @return {(Error|undefined)} A possible error + * @private + */ + dataMessage() { + if (this._fin) { + const messageLength = this._messageLength; + const fragments = this._fragments; + + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._fragments = []; + + if (this._opcode === 2) { + let data; + + if (this._binaryType === 'nodebuffer') { + data = concat(fragments, messageLength); + } else if (this._binaryType === 'arraybuffer') { + data = toArrayBuffer(concat(fragments, messageLength)); + } else { + data = fragments; + } + + this.emit('message', data, true); + } else { + const buf = concat(fragments, messageLength); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + this._loop = false; + return error( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + } + + this.emit('message', buf, false); + } + } + + this._state = GET_INFO; + } + + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data) { + if (this._opcode === 0x08) { + this._loop = false; + + if (data.length === 0) { + this.emit('conclude', 1005, EMPTY_BUFFER); + this.end(); + } else if (data.length === 1) { + return error( + RangeError, + 'invalid payload length 1', + true, + 1002, + 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH' + ); + } else { + const code = data.readUInt16BE(0); + + if (!isValidStatusCode(code)) { + return error( + RangeError, + `invalid status code ${code}`, + true, + 1002, + 'WS_ERR_INVALID_CLOSE_CODE' + ); + } + + const buf = data.slice(2); + + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + return error( + Error, + 'invalid UTF-8 sequence', + true, + 1007, + 'WS_ERR_INVALID_UTF8' + ); + } + + this.emit('conclude', code, buf); + this.end(); + } + } else if (this._opcode === 0x09) { + this.emit('ping', data); + } else { + this.emit('pong', data); + } + + this._state = GET_INFO; + } +} + +module.exports = Receiver; + +/** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ +function error(ErrorCtor, message, prefix, statusCode, errorCode) { + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + + Error.captureStackTrace(err, error); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; +} diff --git a/testing/xpcshell/node-ws/lib/sender.js b/testing/xpcshell/node-ws/lib/sender.js new file mode 100644 index 0000000000..c848853629 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/sender.js @@ -0,0 +1,478 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls$" }] */ + +'use strict'; + +const net = require('net'); +const tls = require('tls'); +const { randomFillSync } = require('crypto'); + +const PerMessageDeflate = require('./permessage-deflate'); +const { EMPTY_BUFFER } = require('./constants'); +const { isValidStatusCode } = require('./validation'); +const { mask: applyMask, toBuffer } = require('./buffer-util'); + +const kByteLength = Symbol('kByteLength'); +const maskBuffer = Buffer.alloc(4); + +/** + * HyBi Sender implementation. + */ +class Sender { + /** + * Creates a Sender instance. + * + * @param {(net.Socket|tls.Socket)} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + + this._socket = socket; + + this._firstFragment = true; + this._compress = false; + + this._bufferedBytes = 0; + this._deflating = false; + this._queue = []; + } + + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options) { + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + + if (options.mask) { + mask = options.maskBuffer || maskBuffer; + + if (options.generateMask) { + options.generateMask(mask); + } else { + randomFillSync(mask, 0, 4); + } + + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + + let dataLength; + + if (typeof data === 'string') { + if ( + (!options.mask || skipMasking) && + options[kByteLength] !== undefined + ) { + dataLength = options[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge = options.mask && options.readOnly && !skipMasking; + } + + let payloadLength = dataLength; + + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); + + target[0] = options.fin ? options.opcode | 0x80 : options.opcode; + if (options.rsv1) target[0] |= 0x40; + + target[1] = payloadLength; + + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + + if (!options.mask) return [target, data]; + + target[1] |= 0x80; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + + if (skipMasking) return [target, data]; + + if (merge) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback + * @public + */ + close(code, data, mask, cb) { + let buf; + + if (code === undefined) { + buf = EMPTY_BUFFER; + } else if (typeof code !== 'number' || !isValidStatusCode(code)) { + throw new TypeError('First argument must be a valid error code number'); + } else if (data === undefined || !data.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + const length = Buffer.byteLength(data); + + if (length > 123) { + throw new RangeError('The message must not be greater than 123 bytes'); + } + + buf = Buffer.allocUnsafe(2 + length); + buf.writeUInt16BE(code, 0); + + if (typeof data === 'string') { + buf.write(data, 2); + } else { + buf.set(data, 2); + } + } + + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x08, + readOnly: false, + rsv1: false + }; + + if (this._deflating) { + this.enqueue([this.dispatch, buf, false, options, cb]); + } else { + this.sendFrame(Sender.frame(buf, options), cb); + } + } + + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); + } + + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x09, + readOnly, + rsv1: false + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(Sender.frame(data, options), cb); + } + } + + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (byteLength > 125) { + throw new RangeError('The data size must not be greater than 125 bytes'); + } + + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 0x0a, + readOnly, + rsv1: false + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(Sender.frame(data, options), cb); + } + } + + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback + * @public + */ + send(data, options, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options.binary ? 2 : 1; + let rsv1 = options.compress; + + let byteLength; + let readOnly; + + if (typeof data === 'string') { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + + if (this._firstFragment) { + this._firstFragment = false; + if ( + rsv1 && + perMessageDeflate && + perMessageDeflate.params[ + perMessageDeflate._isServer + ? 'server_no_context_takeover' + : 'client_no_context_takeover' + ] + ) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + + if (options.fin) this._firstFragment = true; + + if (perMessageDeflate) { + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + + if (this._deflating) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } else { + this.sendFrame( + Sender.frame(data, { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1: false + }), + cb + ); + } + } + + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options, cb) { + if (!compress) { + this.sendFrame(Sender.frame(data, options), cb); + return; + } + + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + + this._bufferedBytes += options[kByteLength]; + this._deflating = true; + perMessageDeflate.compress(data, options.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + 'The socket was closed while data was being compressed' + ); + + if (typeof cb === 'function') cb(err); + + for (let i = 0; i < this._queue.length; i++) { + const params = this._queue[i]; + const callback = params[params.length - 1]; + + if (typeof callback === 'function') callback(err); + } + + return; + } + + this._bufferedBytes -= options[kByteLength]; + this._deflating = false; + options.readOnly = false; + this.sendFrame(Sender.frame(buf, options), cb); + this.dequeue(); + }); + } + + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (!this._deflating && this._queue.length) { + const params = this._queue.shift(); + + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + + /** + * Sends a frame. + * + * @param {Buffer[]} list The frame to send + * @param {Function} [cb] Callback + * @private + */ + sendFrame(list, cb) { + if (list.length === 2) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); + this._socket.uncork(); + } else { + this._socket.write(list[0], cb); + } + } +} + +module.exports = Sender; diff --git a/testing/xpcshell/node-ws/lib/stream.js b/testing/xpcshell/node-ws/lib/stream.js new file mode 100644 index 0000000000..230734b79a --- /dev/null +++ b/testing/xpcshell/node-ws/lib/stream.js @@ -0,0 +1,159 @@ +'use strict'; + +const { Duplex } = require('stream'); + +/** + * Emits the `'close'` event on a stream. + * + * @param {Duplex} stream The stream. + * @private + */ +function emitClose(stream) { + stream.emit('close'); +} + +/** + * The listener of the `'end'` event. + * + * @private + */ +function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } +} + +/** + * The listener of the `'error'` event. + * + * @param {Error} err The error + * @private + */ +function duplexOnError(err) { + this.removeListener('error', duplexOnError); + this.destroy(); + if (this.listenerCount('error') === 0) { + // Do not suppress the throwing behavior. + this.emit('error', err); + } +} + +/** + * Wraps a `WebSocket` in a duplex stream. + * + * @param {WebSocket} ws The `WebSocket` to wrap + * @param {Object} [options] The options for the `Duplex` constructor + * @return {Duplex} The duplex stream + * @public + */ +function createWebSocketStream(ws, options) { + let terminateOnDestroy = true; + + const duplex = new Duplex({ + ...options, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + + ws.on('message', function message(msg, isBinary) { + const data = + !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + + if (!duplex.push(data)) ws.pause(); + }); + + ws.once('error', function error(err) { + if (duplex.destroyed) return; + + // Prevent `ws.terminate()` from being called by `duplex._destroy()`. + // + // - If the `'error'` event is emitted before the `'open'` event, then + // `ws.terminate()` is a noop as no socket is assigned. + // - Otherwise, the error is re-emitted by the listener of the `'error'` + // event of the `Receiver` object. The listener already closes the + // connection by calling `ws.close()`. This allows a close frame to be + // sent to the other peer. If `ws.terminate()` is called right after this, + // then the close frame might not be sent. + terminateOnDestroy = false; + duplex.destroy(err); + }); + + ws.once('close', function close() { + if (duplex.destroyed) return; + + duplex.push(null); + }); + + duplex._destroy = function (err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + + let called = false; + + ws.once('error', function error(err) { + called = true; + callback(err); + }); + + ws.once('close', function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + + if (terminateOnDestroy) ws.terminate(); + }; + + duplex._final = function (callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + duplex._final(callback); + }); + return; + } + + // If the value of the `_socket` property is `null` it means that `ws` is a + // client websocket and the handshake failed. In fact, when this happens, a + // socket is never assigned to the websocket. Wait for the `'error'` event + // that will be emitted by the websocket. + if (ws._socket === null) return; + + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once('finish', function finish() { + // `duplex` is not destroyed here because the `'end'` event will be + // emitted on `duplex` after this `'finish'` event. The EOF signaling + // `null` chunk is, in fact, pushed when the websocket emits `'close'`. + callback(); + }); + ws.close(); + } + }; + + duplex._read = function () { + if (ws.isPaused) ws.resume(); + }; + + duplex._write = function (chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once('open', function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + + ws.send(chunk, callback); + }; + + duplex.on('end', duplexOnEnd); + duplex.on('error', duplexOnError); + return duplex; +} + +module.exports = createWebSocketStream; diff --git a/testing/xpcshell/node-ws/lib/subprotocol.js b/testing/xpcshell/node-ws/lib/subprotocol.js new file mode 100644 index 0000000000..d4381e8864 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/subprotocol.js @@ -0,0 +1,62 @@ +'use strict'; + +const { tokenChars } = require('./validation'); + +/** + * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names. + * + * @param {String} header The field value of the header + * @return {Set} The subprotocol names + * @public + */ +function parse(header) { + const protocols = new Set(); + let start = -1; + let end = -1; + let i = 0; + + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if ( + i !== 0 && + (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ + ) { + if (end === -1 && start !== -1) end = i; + } else if (code === 0x2c /* ',' */) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + + if (end === -1) end = i; + + const protocol = header.slice(start, end); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + + if (start === -1 || end !== -1) { + throw new SyntaxError('Unexpected end of input'); + } + + const protocol = header.slice(start, i); + + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + + protocols.add(protocol); + return protocols; +} + +module.exports = { parse }; diff --git a/testing/xpcshell/node-ws/lib/validation.js b/testing/xpcshell/node-ws/lib/validation.js new file mode 100644 index 0000000000..44fc202906 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/validation.js @@ -0,0 +1,125 @@ +'use strict'; + +// +// Allowed token characters: +// +// '!', '#', '$', '%', '&', ''', '*', '+', '-', +// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~' +// +// tokenChars[32] === 0 // ' ' +// tokenChars[33] === 1 // '!' +// tokenChars[34] === 0 // '"' +// ... +// +// prettier-ignore +const tokenChars = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 + 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63 + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127 +]; + +/** + * Checks if a status code is allowed in a close frame. + * + * @param {Number} code The status code + * @return {Boolean} `true` if the status code is valid, else `false` + * @public + */ +function isValidStatusCode(code) { + return ( + (code >= 1000 && + code <= 1014 && + code !== 1004 && + code !== 1005 && + code !== 1006) || + (code >= 3000 && code <= 4999) + ); +} + +/** + * Checks if a given buffer contains only correct UTF-8. + * Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by + * Markus Kuhn. + * + * @param {Buffer} buf The buffer to check + * @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false` + * @public + */ +function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + + while (i < len) { + if ((buf[i] & 0x80) === 0) { + // 0xxxxxxx + i++; + } else if ((buf[i] & 0xe0) === 0xc0) { + // 110xxxxx 10xxxxxx + if ( + i + 1 === len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i] & 0xfe) === 0xc0 // Overlong + ) { + return false; + } + + i += 2; + } else if ((buf[i] & 0xf0) === 0xe0) { + // 1110xxxx 10xxxxxx 10xxxxxx + if ( + i + 2 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong + (buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF) + ) { + return false; + } + + i += 3; + } else if ((buf[i] & 0xf8) === 0xf0) { + // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + if ( + i + 3 >= len || + (buf[i + 1] & 0xc0) !== 0x80 || + (buf[i + 2] & 0xc0) !== 0x80 || + (buf[i + 3] & 0xc0) !== 0x80 || + (buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong + (buf[i] === 0xf4 && buf[i + 1] > 0x8f) || + buf[i] > 0xf4 // > U+10FFFF + ) { + return false; + } + + i += 4; + } else { + return false; + } + } + + return true; +} + +module.exports = { + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars +}; + +/* istanbul ignore else */ +if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require('utf-8-validate'); + + module.exports.isValidUTF8 = function (buf) { + return buf.length < 150 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + // Continue regardless of the error. + } +} diff --git a/testing/xpcshell/node-ws/lib/websocket-server.js b/testing/xpcshell/node-ws/lib/websocket-server.js new file mode 100644 index 0000000000..bac30eb330 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/websocket-server.js @@ -0,0 +1,535 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^net|tls|https$" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const http = require('http'); +const https = require('https'); +const net = require('net'); +const tls = require('tls'); +const { createHash } = require('crypto'); + +const extension = require('./extension'); +const PerMessageDeflate = require('./permessage-deflate'); +const subprotocol = require('./subprotocol'); +const WebSocket = require('./websocket'); +const { GUID, kWebSocket } = require('./constants'); + +const keyRegex = /^[+/0-9A-Za-z]{22}==$/; + +const RUNNING = 0; +const CLOSING = 1; +const CLOSED = 2; + +/** + * Class representing a WebSocket server. + * + * @extends EventEmitter + */ +class WebSocketServer extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options, callback) { + super(); + + options = { + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + verifyClient: null, + noServer: false, + backlog: null, // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + WebSocket, + ...options + }; + + if ( + (options.port == null && !options.server && !options.noServer) || + (options.port != null && (options.server || options.noServer)) || + (options.server && options.noServer) + ) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options ' + + 'must be specified' + ); + } + + if (options.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + + res.writeHead(426, { + 'Content-Length': body.length, + 'Content-Type': 'text/plain' + }); + res.end(body); + }); + this._server.listen( + options.port, + options.host, + options.backlog, + callback + ); + } else if (options.server) { + this._server = options.server; + } + + if (this._server) { + const emitConnection = this.emit.bind(this, 'connection'); + + this._removeListeners = addListeners(this._server, { + listening: this.emit.bind(this, 'listening'), + error: this.emit.bind(this, 'error'), + upgrade: (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + } + }); + } + + if (options.perMessageDeflate === true) options.perMessageDeflate = {}; + if (options.clientTracking) { + this.clients = new Set(); + this._shouldEmitClose = false; + } + + this.options = options; + this._state = RUNNING; + } + + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + + if (!this._server) return null; + return this._server.address(); + } + + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once('close', () => { + cb(new Error('The server is not running')); + }); + } + + process.nextTick(emitClose, this); + return; + } + + if (cb) this.once('close', cb); + + if (this._state === CLOSING) return; + this._state = CLOSING; + + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; + + this._removeListeners(); + this._removeListeners = this._server = null; + + // + // The HTTP/S server was created internally. Close it, and rely on its + // `'close'` event. + // + server.close(() => { + emitClose(this); + }); + } + } + + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf('?'); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + + if (pathname !== this.options.path) return false; + } + + return true; + } + + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on('error', socketOnError); + + const key = req.headers['sec-websocket-key']; + const version = +req.headers['sec-websocket-version']; + + if (req.method !== 'GET') { + const message = 'Invalid HTTP method'; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + + if (req.headers.upgrade.toLowerCase() !== 'websocket') { + const message = 'Invalid Upgrade header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (!key || !keyRegex.test(key)) { + const message = 'Missing or invalid Sec-WebSocket-Key header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (version !== 8 && version !== 13) { + const message = 'Missing or invalid Sec-WebSocket-Version header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + + const secWebSocketProtocol = req.headers['sec-websocket-protocol']; + let protocols = new Set(); + + if (secWebSocketProtocol !== undefined) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Protocol header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + + const secWebSocketExtensions = req.headers['sec-websocket-extensions']; + const extensions = {}; + + if ( + this.options.perMessageDeflate && + secWebSocketExtensions !== undefined + ) { + const perMessageDeflate = new PerMessageDeflate( + this.options.perMessageDeflate, + true, + this.options.maxPayload + ); + + try { + const offers = extension.parse(secWebSocketExtensions); + + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = + 'Invalid or unacceptable Sec-WebSocket-Extensions header'; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + + // + // Optionally call external client verification handler. + // + if (this.options.verifyClient) { + const info = { + origin: + req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { + // + // Destroy the socket if the client has already sent a FIN packet. + // + if (!socket.readable || !socket.writable) return socket.destroy(); + + if (socket[kWebSocket]) { + throw new Error( + 'server.handleUpgrade() was called more than once with the same ' + + 'socket, possibly due to a misconfiguration' + ); + } + + if (this._state > RUNNING) return abortHandshake(socket, 503); + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + const headers = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${digest}` + ]; + + const ws = new this.options.WebSocket(null); + + if (protocols.size) { + // + // Optionally call external protocol selection handler. + // + const protocol = this.options.handleProtocols + ? this.options.handleProtocols(protocols, req) + : protocols.values().next().value; + + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + + // + // Allow external modification/inspection of handshake headers. + // + this.emit('headers', headers, req); + + socket.write(headers.concat('\r\n').join('\r\n')); + socket.removeListener('error', socketOnError); + + ws.setSocket(socket, head, { + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + + if (this.clients) { + this.clients.add(ws); + ws.on('close', () => { + this.clients.delete(ws); + + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + + cb(ws, req); + } +} + +module.exports = WebSocketServer; + +/** + * Add event listeners on an `EventEmitter` using a map of + * pairs. + * + * @param {EventEmitter} server The event emitter + * @param {Object.} map The listeners to add + * @return {Function} A function that will remove the added listeners when + * called + * @private + */ +function addListeners(server, map) { + for (const event of Object.keys(map)) server.on(event, map[event]); + + return function removeListeners() { + for (const event of Object.keys(map)) { + server.removeListener(event, map[event]); + } + }; +} + +/** + * Emit a `'close'` event on an `EventEmitter`. + * + * @param {EventEmitter} server The event emitter + * @private + */ +function emitClose(server) { + server._state = CLOSED; + server.emit('close'); +} + +/** + * Handle socket errors. + * + * @private + */ +function socketOnError() { + this.destroy(); +} + +/** + * Close the connection when preconditions are not fulfilled. + * + * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} [message] The HTTP response body + * @param {Object} [headers] Additional HTTP response headers + * @private + */ +function abortHandshake(socket, code, message, headers) { + // + // The socket is writable unless the user destroyed or ended it before calling + // `server.handleUpgrade()` or in the `verifyClient` function, which is a user + // error. Handling this does not make much sense as the worst that can happen + // is that some of the data written by the user might be discarded due to the + // call to `socket.end()` below, which triggers an `'error'` event that in + // turn causes the socket to be destroyed. + // + message = message || http.STATUS_CODES[code]; + headers = { + Connection: 'close', + 'Content-Type': 'text/html', + 'Content-Length': Buffer.byteLength(message), + ...headers + }; + + socket.once('finish', socket.destroy); + + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + + Object.keys(headers) + .map((h) => `${h}: ${headers[h]}`) + .join('\r\n') + + '\r\n\r\n' + + message + ); +} + +/** + * Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least + * one listener for it, otherwise call `abortHandshake()`. + * + * @param {WebSocketServer} server The WebSocket server + * @param {http.IncomingMessage} req The request object + * @param {(net.Socket|tls.Socket)} socket The socket of the upgrade request + * @param {Number} code The HTTP response status code + * @param {String} message The HTTP response body + * @private + */ +function abortHandshakeOrEmitwsClientError(server, req, socket, code, message) { + if (server.listenerCount('wsClientError')) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + + server.emit('wsClientError', err, socket, req); + } else { + abortHandshake(socket, code, message); + } +} diff --git a/testing/xpcshell/node-ws/lib/websocket.js b/testing/xpcshell/node-ws/lib/websocket.js new file mode 100644 index 0000000000..3132cc1500 --- /dev/null +++ b/testing/xpcshell/node-ws/lib/websocket.js @@ -0,0 +1,1305 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Readable$" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const https = require('https'); +const http = require('http'); +const net = require('net'); +const tls = require('tls'); +const { randomBytes, createHash } = require('crypto'); +const { Readable } = require('stream'); +const { URL } = require('url'); + +const PerMessageDeflate = require('./permessage-deflate'); +const Receiver = require('./receiver'); +const Sender = require('./sender'); +const { + BINARY_TYPES, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP +} = require('./constants'); +const { + EventTarget: { addEventListener, removeEventListener } +} = require('./event-target'); +const { format, parse } = require('./extension'); +const { toBuffer } = require('./buffer-util'); + +const closeTimeout = 30 * 1000; +const kAborted = Symbol('kAborted'); +const protocolVersions = [8, 13]; +const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + +/** + * Class representing a WebSocket. + * + * @extends EventEmitter + */ +class WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options) { + super(); + + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._extensions = {}; + this._paused = false; + this._protocol = ''; + this._readyState = WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + + if (protocols === undefined) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === 'object' && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + + initAsClient(this, address, protocols, options); + } else { + this._isServer = true; + } + } + + /** + * This deviates from the WHATWG interface since ws doesn't support the + * required default "blob" type (instead we define a custom "nodebuffer" + * type). + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + + this._binaryType = type; + + // + // Allow to change `binaryType` on the fly. + // + if (this._receiver) this._receiver._binaryType = type; + } + + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + + return this._socket._writableState.length + this._sender._bufferedBytes; + } + + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + + /** + * @type {String} + */ + get url() { + return this._url; + } + + /** + * Set up the socket and the internal resources. + * + * @param {(net.Socket|tls.Socket)} socket The network socket between the + * server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver({ + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + this._sender = new Sender(socket, this._extensions, options.generateMask); + this._receiver = receiver; + this._socket = socket; + + receiver[kWebSocket] = this; + socket[kWebSocket] = this; + + receiver.on('conclude', receiverOnConclude); + receiver.on('drain', receiverOnDrain); + receiver.on('error', receiverOnError); + receiver.on('message', receiverOnMessage); + receiver.on('ping', receiverOnPing); + receiver.on('pong', receiverOnPong); + + socket.setTimeout(0); + socket.setNoDelay(); + + if (head.length > 0) socket.unshift(head); + + socket.on('close', socketOnClose); + socket.on('data', socketOnData); + socket.on('end', socketOnEnd); + socket.on('error', socketOnError); + + this._readyState = WebSocket.OPEN; + this.emit('open'); + } + + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + return; + } + + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + + this._receiver.removeAllListeners(); + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + } + + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + return abortHandshake(this, this._req, msg); + } + + if (this.readyState === WebSocket.CLOSING) { + if ( + this._closeFrameSent && + (this._closeFrameReceived || this._receiver._writableState.errorEmitted) + ) { + this._socket.end(); + } + + return; + } + + this._readyState = WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + // + // This error is handled by the `'error'` listener on the socket. We only + // want to know if the close frame has been sent here. + // + if (err) return; + + this._closeFrameSent = true; + + if ( + this._closeFrameReceived || + this._receiver._writableState.errorEmitted + ) { + this._socket.end(); + } + }); + + // + // Specify a timeout for the closing handshake to complete. + // + this._closeTimer = setTimeout( + this._socket.destroy.bind(this._socket), + closeTimeout + ); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); + } + + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Resume the socket. + * + * @public + */ + resume() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof options === 'function') { + cb = options; + options = {}; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + const opts = { + binary: typeof data !== 'string', + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; + + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + return abortHandshake(this, this._req, msg); + } + + if (this._socket) { + this._readyState = WebSocket.CLOSING; + this._socket.destroy(); + } + } +} + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'isPaused', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); +}); + +// +// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes. +// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface +// +['open', 'error', 'close', 'message'].forEach((method) => { + Object.defineProperty(WebSocket.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); +}); + +WebSocket.prototype.addEventListener = addEventListener; +WebSocket.prototype.removeEventListener = removeEventListener; + +module.exports = WebSocket; + +/** + * Initialize a WebSocket client. + * + * @param {WebSocket} websocket The client to initialize + * @param {(String|URL)} address The URL to which to connect + * @param {Array} protocols The subprotocols + * @param {Object} [options] Connection options + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the + * handshake request + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ +function initAsClient(websocket, address, protocols, options) { + const opts = { + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + createConnection: undefined, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: 'GET', + host: undefined, + path: undefined, + port: undefined + }; + + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} ` + + `(supported versions: ${protocolVersions.join(', ')})` + ); + } + + let parsedUrl; + + if (address instanceof URL) { + parsedUrl = address; + websocket._url = address.href; + } else { + try { + parsedUrl = new URL(address); + } catch (e) { + throw new SyntaxError(`Invalid URL: ${address}`); + } + + websocket._url = address; + } + + const isSecure = parsedUrl.protocol === 'wss:'; + const isUnixSocket = parsedUrl.protocol === 'ws+unix:'; + let invalidURLMessage; + + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isUnixSocket) { + invalidURLMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"'; + } else if (isUnixSocket && !parsedUrl.pathname) { + invalidURLMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidURLMessage = 'The URL contains a fragment identifier'; + } + + if (invalidURLMessage) { + const err = new SyntaxError(invalidURLMessage); + + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString('base64'); + const request = isSecure ? https.request : http.request; + const protocolSet = new Set(); + let perMessageDeflate; + + opts.createConnection = isSecure ? tlsConnect : netConnect; + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith('[') + ? parsedUrl.hostname.slice(1, -1) + : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + 'Sec-WebSocket-Version': opts.protocolVersion, + 'Sec-WebSocket-Key': key, + Connection: 'Upgrade', + Upgrade: 'websocket' + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate( + opts.perMessageDeflate !== true ? opts.perMessageDeflate : {}, + false, + opts.maxPayload + ); + opts.headers['Sec-WebSocket-Extensions'] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if ( + typeof protocol !== 'string' || + !subprotocolRegex.test(protocol) || + protocolSet.has(protocol) + ) { + throw new SyntaxError( + 'An invalid or duplicated subprotocol was specified' + ); + } + + protocolSet.add(protocol); + } + + opts.headers['Sec-WebSocket-Protocol'] = protocols.join(','); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers['Sec-WebSocket-Origin'] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + + if (isUnixSocket) { + const parts = opts.path.split(':'); + + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + + let req; + + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalUnixSocket = isUnixSocket; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isUnixSocket + ? opts.socketPath + : parsedUrl.host; + + const headers = options && options.headers; + + // + // Shallow copy the user provided options so that headers can be changed + // without mutating the original object. + // + options = { ...options, headers: {} }; + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + options.headers[key.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount('redirect') === 0) { + const isSameHost = isUnixSocket + ? websocket._originalUnixSocket + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalUnixSocket + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; + + if (!isSameHost || (websocket._originalSecure && !isSecure)) { + // + // Match curl 7.77.0 behavior and drop the following headers. These + // headers are also dropped when following a redirect to a subdomain. + // + delete opts.headers.authorization; + delete opts.headers.cookie; + + if (!isSameHost) delete opts.headers.host; + + opts.auth = undefined; + } + } + + // + // Match curl 7.77.0 behavior and make the first `Authorization` header win. + // If the `Authorization` header is set, then there is nothing to do as it + // will take precedence. + // + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = + 'Basic ' + Buffer.from(opts.auth).toString('base64'); + } + + req = websocket._req = request(opts); + + if (websocket._redirects) { + // + // Unlike what is done for the `'upgrade'` event, no early exit is + // triggered here if the user calls `websocket.close()` or + // `websocket.terminate()` from a listener of the `'redirect'` event. This + // is because the user can also call `request.destroy()` with an error + // before calling `websocket.close()` or `websocket.terminate()` and this + // would result in an error being emitted on the `request` object with no + // `'error'` event listeners attached. + // + websocket.emit('redirect', websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + + if (opts.timeout) { + req.on('timeout', () => { + abortHandshake(websocket, req, 'Opening handshake has timed out'); + }); + } + + req.on('error', (err) => { + if (req === null || req[kAborted]) return; + + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + + req.on('response', (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + + if ( + location && + opts.followRedirects && + statusCode >= 300 && + statusCode < 400 + ) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, 'Maximum redirects exceeded'); + return; + } + + req.abort(); + + let addr; + + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit('unexpected-response', req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + + req.on('upgrade', (res, socket, head) => { + websocket.emit('upgrade', res); + + // + // The user may have closed the connection from a listener of the + // `'upgrade'` event. + // + if (websocket.readyState !== WebSocket.CONNECTING) return; + + req = websocket._req = null; + + if (res.headers.upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + if (res.headers['sec-websocket-accept'] !== digest) { + abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); + return; + } + + const serverProt = res.headers['sec-websocket-protocol']; + let protError; + + if (serverProt !== undefined) { + if (!protocolSet.size) { + protError = 'Server sent a subprotocol but none was requested'; + } else if (!protocolSet.has(serverProt)) { + protError = 'Server sent an invalid subprotocol'; + } + } else if (protocolSet.size) { + protError = 'Server sent no subprotocol'; + } + + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + + if (serverProt) websocket._protocol = serverProt; + + const secWebSocketExtensions = res.headers['sec-websocket-extensions']; + + if (secWebSocketExtensions !== undefined) { + if (!perMessageDeflate) { + const message = + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested'; + abortHandshake(websocket, socket, message); + return; + } + + let extensions; + + try { + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + const extensionNames = Object.keys(extensions); + + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } + + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; + } + + websocket.setSocket(socket, head, { + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + + req.end(); +} + +/** + * Emit the `'error'` and `'close'` events. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {Error} The error to emit + * @private + */ +function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + websocket.emit('error', err); + websocket.emitClose(); +} + +/** + * Create a `net.Socket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {net.Socket} The newly created socket used to start the connection + * @private + */ +function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); +} + +/** + * Create a `tls.TLSSocket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {tls.TLSSocket} The newly created socket used to start the connection + * @private + */ +function tlsConnect(options) { + options.path = undefined; + + if (!options.servername && options.servername !== '') { + options.servername = net.isIP(options.host) ? '' : options.host; + } + + return tls.connect(options); +} + +/** + * Abort the handshake and emit an error. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to + * abort or the socket to destroy + * @param {String} message The error message + * @private + */ +function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket.CLOSING; + + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + + if (stream.socket && !stream.socket.destroyed) { + // + // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if + // called after the request completed. See + // https://github.com/websockets/ws/issues/1869. + // + stream.socket.destroy(); + } + + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once('error', websocket.emit.bind(websocket, 'error')); + stream.once('close', websocket.emitClose.bind(websocket)); + } +} + +/** + * Handle cases where the `ping()`, `pong()`, or `send()` methods are called + * when the `readyState` attribute is `CLOSING` or `CLOSED`. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {*} [data] The data to send + * @param {Function} [cb] Callback + * @private + */ +function sendAfterClose(websocket, data, cb) { + if (data) { + const length = toBuffer(data).length; + + // + // The `_bufferedAmount` property is used only when the peer is a client and + // the opening handshake fails. Under these circumstances, in fact, the + // `setSocket()` method is not called, so the `_socket` and `_sender` + // properties are set to `null`. + // + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} ` + + `(${readyStates[websocket.readyState]})` + ); + cb(err); + } +} + +/** + * The listener of the `Receiver` `'conclude'` event. + * + * @param {Number} code The status code + * @param {Buffer} reason The reason for closing + * @private + */ +function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + + if (websocket._socket[kWebSocket] === undefined) return; + + websocket._socket.removeListener('data', socketOnData); + process.nextTick(resume, websocket._socket); + + if (code === 1005) websocket.close(); + else websocket.close(code, reason); +} + +/** + * The listener of the `Receiver` `'drain'` event. + * + * @private + */ +function receiverOnDrain() { + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); +} + +/** + * The listener of the `Receiver` `'error'` event. + * + * @param {(RangeError|Error)} err The emitted error + * @private + */ +function receiverOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); + + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); + + websocket.close(err[kStatusCode]); + } + + websocket.emit('error', err); +} + +/** + * The listener of the `Receiver` `'finish'` event. + * + * @private + */ +function receiverOnFinish() { + this[kWebSocket].emitClose(); +} + +/** + * The listener of the `Receiver` `'message'` event. + * + * @param {Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Boolean} isBinary Specifies whether the message is binary or not + * @private + */ +function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit('message', data, isBinary); +} + +/** + * The listener of the `Receiver` `'ping'` event. + * + * @param {Buffer} data The data included in the ping frame + * @private + */ +function receiverOnPing(data) { + const websocket = this[kWebSocket]; + + websocket.pong(data, !websocket._isServer, NOOP); + websocket.emit('ping', data); +} + +/** + * The listener of the `Receiver` `'pong'` event. + * + * @param {Buffer} data The data included in the pong frame + * @private + */ +function receiverOnPong(data) { + this[kWebSocket].emit('pong', data); +} + +/** + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + +/** + * The listener of the `net.Socket` `'close'` event. + * + * @private + */ +function socketOnClose() { + const websocket = this[kWebSocket]; + + this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); + this.removeListener('end', socketOnEnd); + + websocket._readyState = WebSocket.CLOSING; + + let chunk; + + // + // The close frame might not have been received or the `'end'` event emitted, + // for example, if the socket was destroyed due to an error. Ensure that the + // `receiver` stream is closed after writing any remaining buffered data to + // it. If the readable side of the socket is in flowing mode then there is no + // buffered data as everything has been already written and `readable.read()` + // will return `null`. If instead, the socket is paused, any possible buffered + // data will be read as a single chunk. + // + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + (chunk = websocket._socket.read()) !== null + ) { + websocket._receiver.write(chunk); + } + + websocket._receiver.end(); + + this[kWebSocket] = undefined; + + clearTimeout(websocket._closeTimer); + + if ( + websocket._receiver._writableState.finished || + websocket._receiver._writableState.errorEmitted + ) { + websocket.emitClose(); + } else { + websocket._receiver.on('error', receiverOnFinish); + websocket._receiver.on('finish', receiverOnFinish); + } +} + +/** + * The listener of the `net.Socket` `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } +} + +/** + * The listener of the `net.Socket` `'end'` event. + * + * @private + */ +function socketOnEnd() { + const websocket = this[kWebSocket]; + + websocket._readyState = WebSocket.CLOSING; + websocket._receiver.end(); + this.end(); +} + +/** + * The listener of the `net.Socket` `'error'` event. + * + * @private + */ +function socketOnError() { + const websocket = this[kWebSocket]; + + this.removeListener('error', socketOnError); + this.on('error', NOOP); + + if (websocket) { + websocket._readyState = WebSocket.CLOSING; + this.destroy(); + } +} diff --git a/testing/xpcshell/node-ws/package.json b/testing/xpcshell/node-ws/package.json new file mode 100644 index 0000000000..27b9244a46 --- /dev/null +++ b/testing/xpcshell/node-ws/package.json @@ -0,0 +1,61 @@ +{ + "name": "ws", + "version": "8.8.1", + "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", + "keywords": [ + "HyBi", + "Push", + "RFC-6455", + "WebSocket", + "WebSockets", + "real-time" + ], + "homepage": "https://github.com/websockets/ws", + "bugs": "https://github.com/websockets/ws/issues", + "repository": "websockets/ws", + "author": "Einar Otto Stangvik (http://2x.io)", + "license": "MIT", + "main": "index.js", + "exports": { + "import": "./wrapper.mjs", + "require": "./index.js" + }, + "browser": "browser.js", + "engines": { + "node": ">=10.0.0" + }, + "files": [ + "browser.js", + "index.js", + "lib/*.js", + "wrapper.mjs" + ], + "scripts": { + "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js", + "integration": "mocha --throw-deprecation test/*.integration.js", + "lint": "eslint --ignore-path .gitignore . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + }, + "devDependencies": { + "benchmark": "^2.1.4", + "bufferutil": "^4.0.1", + "eslint": "^8.0.0", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-prettier": "^4.0.0", + "mocha": "^8.4.0", + "nyc": "^15.0.0", + "prettier": "^2.0.5", + "utf-8-validate": "^5.0.2" + } +} diff --git a/testing/xpcshell/node-ws/test/autobahn-server.js b/testing/xpcshell/node-ws/test/autobahn-server.js new file mode 100644 index 0000000000..24ade11497 --- /dev/null +++ b/testing/xpcshell/node-ws/test/autobahn-server.js @@ -0,0 +1,17 @@ +'use strict'; + +const WebSocket = require('../'); + +const port = process.argv.length > 2 ? parseInt(process.argv[2]) : 9001; +const wss = new WebSocket.Server({ port }, () => { + console.log( + `Listening to port ${port}. Use extra argument to define the port` + ); +}); + +wss.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); + ws.on('error', (e) => console.error(e)); +}); diff --git a/testing/xpcshell/node-ws/test/autobahn.js b/testing/xpcshell/node-ws/test/autobahn.js new file mode 100644 index 0000000000..51532fc52e --- /dev/null +++ b/testing/xpcshell/node-ws/test/autobahn.js @@ -0,0 +1,39 @@ +'use strict'; + +const WebSocket = require('../'); + +let currentTest = 1; +let testCount; + +function nextTest() { + let ws; + + if (currentTest > testCount) { + ws = new WebSocket('ws://localhost:9001/updateReports?agent=ws'); + return; + } + + console.log(`Running test case ${currentTest}/${testCount}`); + + ws = new WebSocket( + `ws://localhost:9001/runCase?case=${currentTest}&agent=ws` + ); + ws.on('message', (data, isBinary) => { + ws.send(data, { binary: isBinary }); + }); + ws.on('close', () => { + currentTest++; + process.nextTick(nextTest); + }); + ws.on('error', (e) => console.error(e)); +} + +const ws = new WebSocket('ws://localhost:9001/getCaseCount'); +ws.on('message', (data) => { + testCount = parseInt(data); +}); +ws.on('close', () => { + if (testCount > 0) { + nextTest(); + } +}); diff --git a/testing/xpcshell/node-ws/test/buffer-util.test.js b/testing/xpcshell/node-ws/test/buffer-util.test.js new file mode 100644 index 0000000000..a6b84c94b1 --- /dev/null +++ b/testing/xpcshell/node-ws/test/buffer-util.test.js @@ -0,0 +1,15 @@ +'use strict'; + +const assert = require('assert'); + +const { concat } = require('../lib/buffer-util'); + +describe('bufferUtil', () => { + describe('concat', () => { + it('never returns uninitialized data', () => { + const buf = concat([Buffer.from([1, 2]), Buffer.from([3, 4])], 6); + + assert.ok(buf.equals(Buffer.from([1, 2, 3, 4]))); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/create-websocket-stream.test.js b/testing/xpcshell/node-ws/test/create-websocket-stream.test.js new file mode 100644 index 0000000000..4d51958cd9 --- /dev/null +++ b/testing/xpcshell/node-ws/test/create-websocket-stream.test.js @@ -0,0 +1,598 @@ +'use strict'; + +const assert = require('assert'); +const EventEmitter = require('events'); +const { createServer } = require('http'); +const { Duplex } = require('stream'); +const { randomBytes } = require('crypto'); + +const createWebSocketStream = require('../lib/stream'); +const Sender = require('../lib/sender'); +const WebSocket = require('..'); +const { EMPTY_BUFFER } = require('../lib/constants'); + +describe('createWebSocketStream', () => { + it('is exposed as a property of the `WebSocket` class', () => { + assert.strictEqual(WebSocket.createWebSocketStream, createWebSocketStream); + }); + + it('returns a `Duplex` stream', () => { + const duplex = createWebSocketStream(new EventEmitter()); + + assert.ok(duplex instanceof Duplex); + }); + + it('passes the options object to the `Duplex` constructor', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws, { + allowHalfOpen: false, + encoding: 'utf8' + }); + + duplex.on('data', (chunk) => { + assert.strictEqual(chunk, 'hi'); + + duplex.on('close', () => { + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.send(Buffer.from('hi')); + ws.close(); + }); + }); + + describe('The returned stream', () => { + it('buffers writes if `readyState` is `CONNECTING`', (done) => { + const chunk = randomBytes(1024); + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + + const duplex = createWebSocketStream(ws); + + duplex.write(chunk); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.on('close', (code, reason) => { + assert.deepStrictEqual(message, chunk); + assert.ok(isBinary); + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + + ws.close(); + }); + }); + + it('errors if a write occurs when `readyState` is `CLOSING`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(duplex.destroyed); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + ws.on('open', () => { + ws._receiver.on('conclude', () => { + duplex.write('hi'); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('errors if a write occurs when `readyState` is `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(duplex.destroyed); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + ws.on('close', () => { + duplex.write('hi'); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('does not error if `_final()` is called while connecting', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + + const duplex = createWebSocketStream(ws); + + duplex.on('close', () => { + wss.close(done); + }); + + duplex.resume(); + duplex.end(); + }); + }); + + it('makes `_final()` a noop if no socket is assigned', (done) => { + const server = createServer(); + + server.on('upgrade', (request, socket) => { + socket.on('end', socket.end); + + const headers = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Accept: foo' + ]; + + socket.write(headers.concat('\r\n').join('\r\n')); + }); + + server.listen(() => { + const called = []; + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + const duplex = WebSocket.createWebSocketStream(ws); + const final = duplex._final; + + duplex._final = (callback) => { + called.push('final'); + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws._socket, null); + + final(callback); + }; + + duplex.on('error', (err) => { + called.push('error'); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid Sec-WebSocket-Accept header' + ); + }); + + duplex.on('finish', () => { + called.push('finish'); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(called, ['final', 'error']); + server.close(done); + }); + + ws.on('upgrade', () => { + process.nextTick(() => { + duplex.end(); + }); + }); + }); + }); + + it('reemits errors', (done) => { + let duplexCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + duplex.on('close', () => { + duplexCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (duplexCloseEventEmitted) wss.close(done); + }); + }); + }); + + it('does not swallow errors that may occur while destroying', (done) => { + const frame = Buffer.concat( + Sender.frame(Buffer.from([0x22, 0xfa, 0xec, 0x78]), { + fin: true, + rsv1: true, + opcode: 0x02, + mask: false, + readOnly: false + }) + ); + + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'Z_DATA_ERROR'); + assert.strictEqual(err.errno, -3); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + let bytesRead = 0; + + ws.on('open', () => { + ws._socket.on('data', (chunk) => { + bytesRead += chunk.length; + if (bytesRead === frame.length) duplex.destroy(); + }); + }); + } + ); + + wss.on('connection', (ws) => { + ws._socket.write(frame); + }); + }); + + it("does not suppress the throwing behavior of 'error' events", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + createWebSocketStream(ws); + }); + + wss.on('connection', (ws) => { + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + + assert.strictEqual(process.listenerCount('uncaughtException'), 1); + + const [listener] = process.listeners('uncaughtException'); + + process.removeAllListeners('uncaughtException'); + process.once('uncaughtException', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + process.on('uncaughtException', listener); + wss.close(done); + }); + }); + + it("is destroyed after 'end' and 'finish' are emitted (1/2)", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const events = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('end', () => { + events.push('end'); + assert.ok(duplex.destroyed); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(events, ['finish', 'end']); + wss.close(done); + }); + + duplex.on('finish', () => { + events.push('finish'); + assert.ok(!duplex.destroyed); + assert.ok(duplex.readable); + + duplex.resume(); + }); + + ws.on('close', () => { + duplex.end(); + }); + }); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); + }); + + it("is destroyed after 'end' and 'finish' are emitted (2/2)", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const events = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('end', () => { + events.push('end'); + assert.ok(!duplex.destroyed); + assert.ok(duplex.writable); + + duplex.end(); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(events, ['end', 'finish']); + wss.close(done); + }); + + duplex.on('finish', () => { + events.push('finish'); + }); + + duplex.resume(); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('handles backpressure (1/3)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + // eslint-disable-next-line no-unused-vars + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const duplex = createWebSocketStream(ws); + + duplex.resume(); + + duplex.on('drain', () => { + duplex.on('close', () => { + wss.close(done); + }); + + duplex.end(); + }); + + const chunk = randomBytes(1024); + let ret; + + do { + ret = duplex.write(chunk); + } while (ret !== false); + }); + }); + + it('handles backpressure (2/3)', (done) => { + const wss = new WebSocket.Server( + { port: 0, perMessageDeflate: true }, + () => { + const called = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + const read = duplex._read; + + duplex._read = () => { + duplex._read = read; + called.push('read'); + assert.ok(ws._receiver._writableState.needDrain); + read(); + assert.ok(ws._socket.isPaused()); + }; + + ws.on('open', () => { + ws._socket.on('pause', () => { + duplex.resume(); + }); + + ws._receiver.on('drain', () => { + called.push('drain'); + assert.ok(!ws._socket.isPaused()); + duplex.end(); + }); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(randomBytes(16 * 1024), { rsv1: false, ...opts }), + ...Sender.frame(Buffer.alloc(1), { rsv1: true, ...opts }) + ]; + + // This hack is used because there is no guarantee that more than + // 16 KiB will be sent as a single TCP packet. + ws._socket.push(Buffer.concat(list)); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(called, ['read', 'drain']); + wss.close(done); + }); + } + ); + }); + + it('handles backpressure (3/3)', (done) => { + const wss = new WebSocket.Server( + { port: 0, perMessageDeflate: true }, + () => { + const called = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + const read = duplex._read; + + duplex._read = () => { + called.push('read'); + assert.ok(!ws._receiver._writableState.needDrain); + read(); + assert.ok(!ws._socket.isPaused()); + duplex.end(); + }; + + ws.on('open', () => { + ws._receiver.on('drain', () => { + called.push('drain'); + assert.ok(ws._socket.isPaused()); + duplex.resume(); + }); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(randomBytes(16 * 1024), { rsv1: false, ...opts }), + ...Sender.frame(Buffer.alloc(1), { rsv1: true, ...opts }) + ]; + + ws._socket.push(Buffer.concat(list)); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(called, ['drain', 'read']); + wss.close(done); + }); + } + ); + }); + + it('can be destroyed (1/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const error = new Error('Oops'); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('error', (err) => { + assert.strictEqual(err, error); + + duplex.on('close', () => { + wss.close(done); + }); + }); + + ws.on('open', () => { + duplex.destroy(error); + }); + }); + }); + + it('can be destroyed (2/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + duplex.on('close', () => { + wss.close(done); + }); + + ws.on('open', () => { + duplex.destroy(); + }); + }); + }); + + it('converts text messages to strings in readable object mode', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const events = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws, { readableObjectMode: true }); + + duplex.on('data', (data) => { + events.push('data'); + assert.strictEqual(data, 'foo'); + }); + + duplex.on('end', () => { + events.push('end'); + duplex.end(); + }); + + duplex.on('close', () => { + assert.deepStrictEqual(events, ['data', 'end']); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); + }); + + it('resumes the socket if `readyState` is `CLOSING`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const duplex = createWebSocketStream(ws); + + ws.on('message', () => { + assert.ok(ws._socket.isPaused()); + + duplex.on('close', () => { + wss.close(done); + }); + + duplex.end(); + + process.nextTick(() => { + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + duplex.resume(); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.send(randomBytes(16 * 1024)); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/event-target.test.js b/testing/xpcshell/node-ws/test/event-target.test.js new file mode 100644 index 0000000000..5caaa5c273 --- /dev/null +++ b/testing/xpcshell/node-ws/test/event-target.test.js @@ -0,0 +1,253 @@ +'use strict'; + +const assert = require('assert'); + +const { + CloseEvent, + ErrorEvent, + Event, + MessageEvent +} = require('../lib/event-target'); + +describe('Event', () => { + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new Event('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + }); + + describe('Properties', () => { + describe('`target`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + Event.prototype, + 'target' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `null`', () => { + const event = new Event('foo'); + + assert.strictEqual(event.target, null); + }); + }); + + describe('`type`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + Event.prototype, + 'type' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + }); + }); +}); + +describe('CloseEvent', () => { + it('inherits from `Event`', () => { + assert.ok(CloseEvent.prototype instanceof Event); + }); + + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new CloseEvent('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + + it('takes an optional `options` argument', () => { + const event = new CloseEvent('close', { + code: 1000, + reason: 'foo', + wasClean: true + }); + + assert.strictEqual(event.type, 'close'); + assert.strictEqual(event.code, 1000); + assert.strictEqual(event.reason, 'foo'); + assert.strictEqual(event.wasClean, true); + }); + }); + + describe('Properties', () => { + describe('`code`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + CloseEvent.prototype, + 'code' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to 0', () => { + const event = new CloseEvent('close'); + + assert.strictEqual(event.code, 0); + }); + }); + + describe('`reason`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + CloseEvent.prototype, + 'reason' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to an empty string', () => { + const event = new CloseEvent('close'); + + assert.strictEqual(event.reason, ''); + }); + }); + + describe('`wasClean`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + CloseEvent.prototype, + 'wasClean' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to false', () => { + const event = new CloseEvent('close'); + + assert.strictEqual(event.wasClean, false); + }); + }); + }); +}); + +describe('ErrorEvent', () => { + it('inherits from `Event`', () => { + assert.ok(ErrorEvent.prototype instanceof Event); + }); + + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new ErrorEvent('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + + it('takes an optional `options` argument', () => { + const error = new Error('Oops'); + const event = new ErrorEvent('error', { error, message: error.message }); + + assert.strictEqual(event.type, 'error'); + assert.strictEqual(event.error, error); + assert.strictEqual(event.message, error.message); + }); + }); + + describe('Properties', () => { + describe('`error`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + ErrorEvent.prototype, + 'error' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `null`', () => { + const event = new ErrorEvent('error'); + + assert.strictEqual(event.error, null); + }); + }); + + describe('`message`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + ErrorEvent.prototype, + 'message' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to an empty string', () => { + const event = new ErrorEvent('error'); + + assert.strictEqual(event.message, ''); + }); + }); + }); +}); + +describe('MessageEvent', () => { + it('inherits from `Event`', () => { + assert.ok(MessageEvent.prototype instanceof Event); + }); + + describe('#ctor', () => { + it('takes a `type` argument', () => { + const event = new MessageEvent('foo'); + + assert.strictEqual(event.type, 'foo'); + }); + + it('takes an optional `options` argument', () => { + const event = new MessageEvent('message', { data: 'bar' }); + + assert.strictEqual(event.type, 'message'); + assert.strictEqual(event.data, 'bar'); + }); + }); + + describe('Properties', () => { + describe('`data`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + MessageEvent.prototype, + 'data' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `null`', () => { + const event = new MessageEvent('message'); + + assert.strictEqual(event.data, null); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/extension.test.js b/testing/xpcshell/node-ws/test/extension.test.js new file mode 100644 index 0000000000..a4b3e749d0 --- /dev/null +++ b/testing/xpcshell/node-ws/test/extension.test.js @@ -0,0 +1,190 @@ +'use strict'; + +const assert = require('assert'); + +const { format, parse } = require('../lib/extension'); + +describe('extension', () => { + describe('parse', () => { + it('parses a single extension', () => { + assert.deepStrictEqual(parse('foo'), { + foo: [{ __proto__: null }], + __proto__: null + }); + }); + + it('parses params', () => { + assert.deepStrictEqual(parse('foo;bar;baz=1;bar=2'), { + foo: [{ bar: [true, '2'], baz: ['1'], __proto__: null }], + __proto__: null + }); + }); + + it('parses multiple extensions', () => { + assert.deepStrictEqual(parse('foo,bar;baz,foo;baz'), { + foo: [{ __proto__: null }, { baz: [true], __proto__: null }], + bar: [{ baz: [true], __proto__: null }], + __proto__: null + }); + }); + + it('parses quoted params', () => { + assert.deepStrictEqual(parse('foo;bar="hi"'), { + foo: [{ bar: ['hi'], __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;bar="\\0"'), { + foo: [{ bar: ['0'], __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;bar="b\\a\\z"'), { + foo: [{ bar: ['baz'], __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;bar="b\\az";bar'), { + foo: [{ bar: ['baz', true], __proto__: null }], + __proto__: null + }); + assert.throws( + () => parse('foo;bar="baz"qux'), + /^SyntaxError: Unexpected character at index 13$/ + ); + assert.throws( + () => parse('foo;bar="baz" qux'), + /^SyntaxError: Unexpected character at index 14$/ + ); + }); + + it('works with names that match `Object.prototype` property names', () => { + assert.deepStrictEqual(parse('hasOwnProperty, toString'), { + hasOwnProperty: [{ __proto__: null }], + toString: [{ __proto__: null }], + __proto__: null + }); + assert.deepStrictEqual(parse('foo;constructor'), { + foo: [{ constructor: [true], __proto__: null }], + __proto__: null + }); + }); + + it('ignores the optional white spaces', () => { + const header = 'foo; bar\t; \tbaz=1\t ; bar="1"\t\t, \tqux\t ;norf'; + + assert.deepStrictEqual(parse(header), { + foo: [{ bar: [true, '1'], baz: ['1'], __proto__: null }], + qux: [{ norf: [true], __proto__: null }], + __proto__: null + }); + }); + + it('throws an error if a name is empty', () => { + [ + [',', 0], + ['foo,,', 4], + ['foo, ,', 6], + ['foo;=', 4], + ['foo; =', 5], + ['foo;;', 4], + ['foo; ;', 5], + ['foo;bar=,', 8], + ['foo;bar=""', 9] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a white space is misplaced', () => { + [ + [' foo', 0], + ['f oo', 2], + ['foo;ba r', 7], + ['foo;bar =', 8], + ['foo;bar= ', 8], + ['foo;bar=ba z', 11] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a token contains invalid characters', () => { + [ + ['f@o', 1], + ['f\\oo', 1], + ['"foo"', 0], + ['f"oo"', 1], + ['foo;b@r', 5], + ['foo;b\\ar', 5], + ['foo;"bar"', 4], + ['foo;b"ar"', 5], + ['foo;bar=b@z', 9], + ['foo;bar=b\\az ', 9], + ['foo;bar="b@z"', 10], + ['foo;bar="baz;"', 12], + ['foo;bar=b"az"', 9], + ['foo;bar="\\\\"', 10] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if the header value ends prematurely', () => { + [ + '', + 'foo ', + 'foo\t', + 'foo, ', + 'foo;', + 'foo;bar ', + 'foo;bar,', + 'foo;bar; ', + 'foo;bar=', + 'foo;bar="baz', + 'foo;bar="1\\', + 'foo;bar="baz" ' + ].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: Unexpected end of input$/ + ); + }); + }); + }); + + describe('format', () => { + it('formats a single extension', () => { + const extensions = format({ foo: {} }); + + assert.strictEqual(extensions, 'foo'); + }); + + it('formats params', () => { + const extensions = format({ foo: { bar: [true, 2], baz: 1 } }); + + assert.strictEqual(extensions, 'foo; bar; bar=2; baz=1'); + }); + + it('formats multiple extensions', () => { + const extensions = format({ + foo: [{}, { baz: true }], + bar: { baz: true } + }); + + assert.strictEqual(extensions, 'foo, foo; baz, bar; baz'); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem b/testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem new file mode 100644 index 0000000000..0f1658821d --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/ca-certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVoCCQCXqK2FegDgiDAKBggqhkjOPQQDAjBhMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDDAKBgNVBAMMA2NhMTAgFw0yMTA1MjYxOTA1 +MjdaGA8yMTIxMDUwMjE5MDUyN1owYTELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB1Bl +cnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMxCzAJ +BgNVBAsMAndzMQwwCgYDVQQDDANjYTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AASHE75QDQN6XNo/711YSbckaa8r4lt0hGkgtADaBFT9Qn9gcm5omapePZT76Ff9 +rwjMcS+YPXS7J7bk+QHLihJMMAoGCCqGSM49BAMCA0kAMEYCIQCUMdUih+sE0ZTu +ORlcKiM8DKyiKkGU4Ty+dslz6nVJjAIhAMcSy0SBsBDgsai1s9aCmAGJXCijNb6g +vfWaatgq+ma2 +-----END CERTIFICATE----- diff --git a/testing/xpcshell/node-ws/test/fixtures/ca-key.pem b/testing/xpcshell/node-ws/test/fixtures/ca-key.pem new file mode 100644 index 0000000000..a9352fb6a2 --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAa/Onpk27cLkqzje69Bac8yG+LTBXIPWT8yGlyjEFbboAoGCCqGSM49 +AwEHoUQDQgAEhxO+UA0DelzaP+9dWEm3JGmvK+JbdIRpILQA2gRU/UJ/YHJuaJmq +Xj2U++hX/a8IzHEvmD10uye25PkBy4oSTA== +-----END EC PRIVATE KEY----- diff --git a/testing/xpcshell/node-ws/test/fixtures/certificate.pem b/testing/xpcshell/node-ws/test/fixtures/certificate.pem new file mode 100644 index 0000000000..538553ee08 --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBujCCAWACCQDjKdAMt3mZhDAKBggqhkjOPQQDAjBkMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDzANBgNVBAMMBnNlcnZlcjAgFw0yMTA1MjYx +OTEwMjlaGA8yMTIxMDUwMjE5MTAyOVowZDELMAkGA1UEBhMCSVQxEDAOBgNVBAgM +B1BlcnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMx +CzAJBgNVBAsMAndzMQ8wDQYDVQQDDAZzZXJ2ZXIwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAQKhyRhdSVOecbJU4O5XkB/iGodbnCOqmchs4TXmE3Prv5SrNDhODDv +rOWTXwR3/HrrdNfOzPdb54amu8POwpohMAoGCCqGSM49BAMCA0gAMEUCIHMRUSPl +8FGkDLl8KF1A+SbT2ds3zUOLdYvj30Z2SKSVAiEA84U/R1ly9wf5Rzv93sTHI99o +KScsr/PHN8rT2pop5pk= +-----END CERTIFICATE----- diff --git a/testing/xpcshell/node-ws/test/fixtures/client-certificate.pem b/testing/xpcshell/node-ws/test/fixtures/client-certificate.pem new file mode 100644 index 0000000000..0e20560b8c --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/client-certificate.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtzCCAV0CCQDDIX2dKuKP0zAKBggqhkjOPQQDAjBhMQswCQYDVQQGEwJJVDEQ +MA4GA1UECAwHUGVydWdpYTEQMA4GA1UEBwwHRm9saWdubzETMBEGA1UECgwKd2Vi +c29ja2V0czELMAkGA1UECwwCd3MxDDAKBgNVBAMMA2NhMTAgFw0yMTA1MjYxOTE3 +NDJaGA8yMTIxMDUwMjE5MTc0MlowZDELMAkGA1UEBhMCSVQxEDAOBgNVBAgMB1Bl +cnVnaWExEDAOBgNVBAcMB0ZvbGlnbm8xEzARBgNVBAoMCndlYnNvY2tldHMxCzAJ +BgNVBAsMAndzMQ8wDQYDVQQDDAZhZ2VudDEwWTATBgcqhkjOPQIBBggqhkjOPQMB +BwNCAATwHlNS2b13TMhBTSWBXAn6TEPxrsvG93ZZyUlmrEMOXSMX2hI7sv660YNj ++eGyE2CV33XsQxV3TUqi51fUjIu8MAoGCCqGSM49BAMCA0gAMEUCIQCxsqBre+Do +jnfg6XmCaB0fywNzcDlvdoVNuNAWfVNrSAIgDQmbM0mXZaSAkf4sgtKdXnpE3vrb +MElb457Bi3B+rkE= +-----END CERTIFICATE----- diff --git a/testing/xpcshell/node-ws/test/fixtures/client-key.pem b/testing/xpcshell/node-ws/test/fixtures/client-key.pem new file mode 100644 index 0000000000..e034f57fc2 --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/client-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIKVGskK0UR86WwMo5H0+hNAFGRBYsEevK3ye4y1YberVoAoGCCqGSM49 +AwEHoUQDQgAE8B5TUtm9d0zIQU0lgVwJ+kxD8a7Lxvd2WclJZqxDDl0jF9oSO7L+ +utGDY/nhshNgld917EMVd01KoudX1IyLvA== +-----END EC PRIVATE KEY----- diff --git a/testing/xpcshell/node-ws/test/fixtures/key.pem b/testing/xpcshell/node-ws/test/fixtures/key.pem new file mode 100644 index 0000000000..05bfdb71ed --- /dev/null +++ b/testing/xpcshell/node-ws/test/fixtures/key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIjLz7YEWIrsGem2+YV8eJhHhetsjYIrjuqJLbdG7B3zoAoGCCqGSM49 +AwEHoUQDQgAECockYXUlTnnGyVODuV5Af4hqHW5wjqpnIbOE15hNz67+UqzQ4Tgw +76zlk18Ed/x663TXzsz3W+eGprvDzsKaIQ== +-----END EC PRIVATE KEY----- diff --git a/testing/xpcshell/node-ws/test/limiter.test.js b/testing/xpcshell/node-ws/test/limiter.test.js new file mode 100644 index 0000000000..95141f0f5c --- /dev/null +++ b/testing/xpcshell/node-ws/test/limiter.test.js @@ -0,0 +1,41 @@ +'use strict'; + +const assert = require('assert'); + +const Limiter = require('../lib/limiter'); + +describe('Limiter', () => { + describe('#ctor', () => { + it('takes a `concurrency` argument', () => { + const limiter = new Limiter(0); + + assert.strictEqual(limiter.concurrency, Infinity); + }); + }); + + describe('#kRun', () => { + it('limits the number of jobs allowed to run concurrently', (done) => { + const limiter = new Limiter(1); + + limiter.add((callback) => { + setImmediate(() => { + callback(); + + assert.strictEqual(limiter.jobs.length, 0); + assert.strictEqual(limiter.pending, 1); + }); + }); + + limiter.add((callback) => { + setImmediate(() => { + callback(); + + assert.strictEqual(limiter.pending, 0); + done(); + }); + }); + + assert.strictEqual(limiter.jobs.length, 1); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/permessage-deflate.test.js b/testing/xpcshell/node-ws/test/permessage-deflate.test.js new file mode 100644 index 0000000000..a9c9bf165c --- /dev/null +++ b/testing/xpcshell/node-ws/test/permessage-deflate.test.js @@ -0,0 +1,647 @@ +'use strict'; + +const assert = require('assert'); + +const PerMessageDeflate = require('../lib/permessage-deflate'); +const extension = require('../lib/extension'); + +describe('PerMessageDeflate', () => { + describe('#offer', () => { + it('creates an offer', () => { + const perMessageDeflate = new PerMessageDeflate(); + + assert.deepStrictEqual(perMessageDeflate.offer(), { + client_max_window_bits: true + }); + }); + + it('uses the configuration options', () => { + const perMessageDeflate = new PerMessageDeflate({ + serverNoContextTakeover: true, + clientNoContextTakeover: true, + serverMaxWindowBits: 10, + clientMaxWindowBits: 11 + }); + + assert.deepStrictEqual(perMessageDeflate.offer(), { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 10, + client_max_window_bits: 11 + }); + }); + }); + + describe('#accept', () => { + it('throws an error if a parameter has multiple values', () => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; server_no_context_takeover' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Parameter "server_no_context_takeover" must have only a single value$/ + ); + }); + + it('throws an error if a parameter has an invalid name', () => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse('permessage-deflate;foo'); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unknown parameter "foo"$/ + ); + }); + + it('throws an error if client_no_context_takeover has a value', () => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_no_context_takeover": 10$/ + ); + }); + + it('throws an error if server_no_context_takeover has a value', () => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "server_no_context_takeover": 10$/ + ); + }); + + it('throws an error if server_max_window_bits has an invalid value', () => { + const perMessageDeflate = new PerMessageDeflate(); + + let extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=7' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "server_max_window_bits": 7$/ + ); + + extensions = extension.parse( + 'permessage-deflate; server_max_window_bits' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "server_max_window_bits": true$/ + ); + }); + + describe('As server', () => { + it('accepts an offer with no parameters', () => { + const perMessageDeflate = new PerMessageDeflate({}, true); + + assert.deepStrictEqual(perMessageDeflate.accept([{}]), {}); + }); + + it('accepts an offer with parameters', () => { + const perMessageDeflate = new PerMessageDeflate({}, true); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 10, + client_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('prefers the configuration options', () => { + const perMessageDeflate = new PerMessageDeflate( + { + serverNoContextTakeover: true, + clientNoContextTakeover: true, + serverMaxWindowBits: 12, + clientMaxWindowBits: 11 + }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=14; client_max_window_bits=13' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 12, + client_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('accepts the first supported offer', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverMaxWindowBits: 11 }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=10, permessage-deflate' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('throws an error if server_no_context_takeover is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverNoContextTakeover: false }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if server_max_window_bits is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverMaxWindowBits: false }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if server_max_window_bits is less than configuration', () => { + const perMessageDeflate = new PerMessageDeflate( + { serverMaxWindowBits: 11 }, + true + ); + const extensions = extension.parse( + 'permessage-deflate; server_max_window_bits=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if client_max_window_bits is unsupported on client', () => { + const perMessageDeflate = new PerMessageDeflate( + { clientMaxWindowBits: 10 }, + true + ); + const extensions = extension.parse('permessage-deflate'); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: None of the extension offers can be accepted$/ + ); + }); + + it('throws an error if client_max_window_bits has an invalid value', () => { + const perMessageDeflate = new PerMessageDeflate({}, true); + + const extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=16' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_max_window_bits": 16$/ + ); + }); + }); + + describe('As client', () => { + it('accepts a response with no parameters', () => { + const perMessageDeflate = new PerMessageDeflate({}); + + assert.deepStrictEqual(perMessageDeflate.accept([{}]), {}); + }); + + it('accepts a response with parameters', () => { + const perMessageDeflate = new PerMessageDeflate({}); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11' + ); + + assert.deepStrictEqual( + perMessageDeflate.accept(extensions['permessage-deflate']), + { + server_no_context_takeover: true, + client_no_context_takeover: true, + server_max_window_bits: 10, + client_max_window_bits: 11, + __proto__: null + } + ); + }); + + it('throws an error if client_no_context_takeover is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientNoContextTakeover: false + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unexpected parameter "client_no_context_takeover"$/ + ); + }); + + it('throws an error if client_max_window_bits is unsupported', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientMaxWindowBits: false + }); + const extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=10' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unexpected or invalid parameter "client_max_window_bits"$/ + ); + }); + + it('throws an error if client_max_window_bits is greater than configuration', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientMaxWindowBits: 10 + }); + const extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=11' + ); + + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^Error: Unexpected or invalid parameter "client_max_window_bits"$/ + ); + }); + + it('throws an error if client_max_window_bits has an invalid value', () => { + const perMessageDeflate = new PerMessageDeflate(); + + let extensions = extension.parse( + 'permessage-deflate; client_max_window_bits=16' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_max_window_bits": 16$/ + ); + + extensions = extension.parse( + 'permessage-deflate; client_max_window_bits' + ); + assert.throws( + () => perMessageDeflate.accept(extensions['permessage-deflate']), + /^TypeError: Invalid value for parameter "client_max_window_bits": true$/ + ); + }); + + it('uses the config value if client_max_window_bits is not specified', () => { + const perMessageDeflate = new PerMessageDeflate({ + clientMaxWindowBits: 10 + }); + + assert.deepStrictEqual(perMessageDeflate.accept([{}]), { + client_max_window_bits: 10 + }); + }); + }); + }); + + describe('#compress and #decompress', () => { + it('works with unfragmented messages', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const buf = Buffer.from([1, 2, 3]); + + perMessageDeflate.accept([{}]); + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + + it('works with fragmented messages', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const buf = Buffer.from([1, 2, 3, 4]); + + perMessageDeflate.accept([{}]); + + perMessageDeflate.compress(buf.slice(0, 2), false, (err, compressed1) => { + if (err) return done(err); + + perMessageDeflate.compress(buf.slice(2), true, (err, compressed2) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed1, false, (err, data1) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed2, true, (err, data2) => { + if (err) return done(err); + + assert.ok(Buffer.concat([data1, data2]).equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('works with the negotiated parameters', (done) => { + const perMessageDeflate = new PerMessageDeflate({ + memLevel: 5, + level: 9 + }); + const extensions = extension.parse( + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11' + ); + const buf = Buffer.from("Some compressible data, it's compressible."); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + + it('honors the `level` option', (done) => { + const lev0 = new PerMessageDeflate({ + zlibDeflateOptions: { level: 0 } + }); + const lev9 = new PerMessageDeflate({ + zlibDeflateOptions: { level: 9 } + }); + const extensionStr = + 'permessage-deflate; server_no_context_takeover; ' + + 'client_no_context_takeover; server_max_window_bits=10; ' + + 'client_max_window_bits=11'; + const buf = Buffer.from("Some compressible data, it's compressible."); + + lev0.accept(extension.parse(extensionStr)['permessage-deflate']); + lev9.accept(extension.parse(extensionStr)['permessage-deflate']); + + lev0.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + lev0.decompress(compressed1, true, (err, decompressed1) => { + if (err) return done(err); + + lev9.compress(buf, true, (err, compressed2) => { + if (err) return done(err); + + lev9.decompress(compressed2, true, (err, decompressed2) => { + if (err) return done(err); + + // Level 0 compression actually adds a few bytes due to headers. + assert.ok(compressed1.length > buf.length); + // Level 9 should not, of course. + assert.ok(compressed2.length < buf.length); + // Ensure they both decompress back properly. + assert.ok(decompressed1.equals(buf)); + assert.ok(decompressed2.equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('honors the `zlib{Deflate,Inflate}Options` option', (done) => { + const lev0 = new PerMessageDeflate({ + zlibDeflateOptions: { + level: 0, + chunkSize: 256 + }, + zlibInflateOptions: { + chunkSize: 2048 + } + }); + const lev9 = new PerMessageDeflate({ + zlibDeflateOptions: { + level: 9, + chunkSize: 128 + }, + zlibInflateOptions: { + chunkSize: 1024 + } + }); + + // Note no context takeover so we can get a hold of the raw streams after + // we do the dance. + const extensionStr = + 'permessage-deflate; server_max_window_bits=10; ' + + 'client_max_window_bits=11'; + const buf = Buffer.from("Some compressible data, it's compressible."); + + lev0.accept(extension.parse(extensionStr)['permessage-deflate']); + lev9.accept(extension.parse(extensionStr)['permessage-deflate']); + + lev0.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + lev0.decompress(compressed1, true, (err, decompressed1) => { + if (err) return done(err); + + lev9.compress(buf, true, (err, compressed2) => { + if (err) return done(err); + + lev9.decompress(compressed2, true, (err, decompressed2) => { + if (err) return done(err); + // Level 0 compression actually adds a few bytes due to headers. + assert.ok(compressed1.length > buf.length); + // Level 9 should not, of course. + assert.ok(compressed2.length < buf.length); + // Ensure they both decompress back properly. + assert.ok(decompressed1.equals(buf)); + assert.ok(decompressed2.equals(buf)); + + // Assert options were set. + assert.ok(lev0._deflate._level === 0); + assert.ok(lev9._deflate._level === 9); + assert.ok(lev0._deflate._chunkSize === 256); + assert.ok(lev9._deflate._chunkSize === 128); + assert.ok(lev0._inflate._chunkSize === 2048); + assert.ok(lev9._inflate._chunkSize === 1024); + done(); + }); + }); + }); + }); + }); + + it("doesn't use contex takeover if not allowed", (done) => { + const perMessageDeflate = new PerMessageDeflate({}, true); + const extensions = extension.parse( + 'permessage-deflate;server_no_context_takeover' + ); + const buf = Buffer.from('foofoo'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed1, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + perMessageDeflate.compress(data, true, (err, compressed2) => { + if (err) return done(err); + + assert.strictEqual(compressed2.length, compressed1.length); + perMessageDeflate.decompress(compressed2, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('uses contex takeover if allowed', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, true); + const extensions = extension.parse('permessage-deflate'); + const buf = Buffer.from('foofoo'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.compress(buf, true, (err, compressed1) => { + if (err) return done(err); + + perMessageDeflate.decompress(compressed1, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + perMessageDeflate.compress(data, true, (err, compressed2) => { + if (err) return done(err); + + assert.ok(compressed2.length < compressed1.length); + perMessageDeflate.decompress(compressed2, true, (err, data) => { + if (err) return done(err); + + assert.ok(data.equals(buf)); + done(); + }); + }); + }); + }); + }); + + it('calls the callback when an error occurs (inflate)', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const data = Buffer.from('something invalid'); + + perMessageDeflate.accept([{}]); + perMessageDeflate.decompress(data, true, (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'Z_DATA_ERROR'); + assert.strictEqual(err.errno, -3); + done(); + }); + }); + + it("doesn't call the callback twice when `maxPayload` is exceeded", (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + const buf = Buffer.from('A'.repeat(50)); + + perMessageDeflate.accept([{}]); + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + perMessageDeflate.decompress(data, true, (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.message, 'Max payload size exceeded'); + done(); + }); + }); + }); + + it('calls the callback if the deflate stream is closed prematurely', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const buf = Buffer.from('A'.repeat(50)); + + perMessageDeflate.accept([{}]); + perMessageDeflate.compress(buf, true, (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The deflate stream was closed while data was being processed' + ); + done(); + }); + + process.nextTick(() => perMessageDeflate.cleanup()); + }); + + it('recreates the inflate stream if it ends', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover; ' + + 'server_no_context_takeover' + ); + const buf = Buffer.from('33343236313533b7000000', 'hex'); + const expected = Buffer.from('12345678'); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + perMessageDeflate.decompress(buf, true, (err, data) => { + assert.ok(data.equals(expected)); + + perMessageDeflate.decompress(buf, true, (err, data) => { + assert.ok(data.equals(expected)); + done(); + }); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/receiver.test.js b/testing/xpcshell/node-ws/test/receiver.test.js new file mode 100644 index 0000000000..7ee35f7402 --- /dev/null +++ b/testing/xpcshell/node-ws/test/receiver.test.js @@ -0,0 +1,1086 @@ +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); + +const PerMessageDeflate = require('../lib/permessage-deflate'); +const Receiver = require('../lib/receiver'); +const Sender = require('../lib/sender'); +const { EMPTY_BUFFER, kStatusCode } = require('../lib/constants'); + +describe('Receiver', () => { + it('parses an unmasked text message', (done) => { + const receiver = new Receiver(); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + done(); + }); + + receiver.write(Buffer.from('810548656c6c6f', 'hex')); + }); + + it('parses a close message', (done) => { + const receiver = new Receiver(); + + receiver.on('conclude', (code, data) => { + assert.strictEqual(code, 1005); + assert.strictEqual(data, EMPTY_BUFFER); + done(); + }); + + receiver.write(Buffer.from('8800', 'hex')); + }); + + it('parses a close message spanning multiple writes', (done) => { + const receiver = new Receiver(); + + receiver.on('conclude', (code, data) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(data, Buffer.from('DONE')); + done(); + }); + + receiver.write(Buffer.from('8806', 'hex')); + receiver.write(Buffer.from('03e8444F4E45', 'hex')); + }); + + it('parses a masked text message', (done) => { + const receiver = new Receiver({ isServer: true }); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('5:::{"name":"echo"}')); + assert.ok(!isBinary); + done(); + }); + + receiver.write( + Buffer.from('81933483a86801b992524fa1c60959e68a5216e6cb005ba1d5', 'hex') + ); + }); + + it('parses a masked text message longer than 125 B', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(200)); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + done(); + }); + + receiver.write(frame.slice(0, 2)); + setImmediate(() => receiver.write(frame.slice(2))); + }); + + it('parses a really long masked text message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(64 * 1024)); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 300 B fragmented masked text message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); + + const options = { rsv1: false, mask: true, readOnly: true }; + + const frame1 = Buffer.concat( + Sender.frame(fragment1, { + fin: false, + opcode: 0x01, + ...options + }) + ); + const frame2 = Buffer.concat( + Sender.frame(fragment2, { + fin: true, + opcode: 0x00, + ...options + }) + ); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + done(); + }); + + receiver.write(frame1); + receiver.write(frame2); + }); + + it('parses a ping message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('Hello'); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x09, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('ping', (data) => { + assert.deepStrictEqual(data, msg); + done(); + }); + + receiver.write(frame); + }); + + it('parses a ping message with no data', (done) => { + const receiver = new Receiver(); + + receiver.on('ping', (data) => { + assert.strictEqual(data, EMPTY_BUFFER); + done(); + }); + + receiver.write(Buffer.from('8900', 'hex')); + }); + + it('parses a 300 B fragmented masked text message with a ping in the middle (1/2)', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + const pingMessage = Buffer.from('Hello'); + + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); + + const options = { rsv1: false, mask: true, readOnly: true }; + + const frame1 = Buffer.concat( + Sender.frame(fragment1, { + fin: false, + opcode: 0x01, + ...options + }) + ); + const frame2 = Buffer.concat( + Sender.frame(pingMessage, { + fin: true, + opcode: 0x09, + ...options + }) + ); + const frame3 = Buffer.concat( + Sender.frame(fragment2, { + fin: true, + opcode: 0x00, + ...options + }) + ); + + let gotPing = false; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + assert.ok(gotPing); + done(); + }); + receiver.on('ping', (data) => { + gotPing = true; + assert.ok(data.equals(pingMessage)); + }); + + receiver.write(frame1); + receiver.write(frame2); + receiver.write(frame3); + }); + + it('parses a 300 B fragmented masked text message with a ping in the middle (2/2)', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = Buffer.from('A'.repeat(300)); + const pingMessage = Buffer.from('Hello'); + + const fragment1 = msg.slice(0, 150); + const fragment2 = msg.slice(150); + + const options = { rsv1: false, mask: true, readOnly: false }; + + const frame1 = Buffer.concat( + Sender.frame(Buffer.from(fragment1), { + fin: false, + opcode: 0x01, + ...options + }) + ); + const frame2 = Buffer.concat( + Sender.frame(Buffer.from(pingMessage), { + fin: true, + opcode: 0x09, + ...options + }) + ); + const frame3 = Buffer.concat( + Sender.frame(Buffer.from(fragment2), { + fin: true, + opcode: 0x00, + ...options + }) + ); + + let chunks = []; + const splitBuffer = (buf) => { + const i = Math.floor(buf.length / 2); + return [buf.slice(0, i), buf.slice(i)]; + }; + + chunks = chunks.concat(splitBuffer(frame1)); + chunks = chunks.concat(splitBuffer(frame2)); + chunks = chunks.concat(splitBuffer(frame3)); + + let gotPing = false; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(!isBinary); + assert.ok(gotPing); + done(); + }); + receiver.on('ping', (data) => { + gotPing = true; + assert.ok(data.equals(pingMessage)); + }); + + for (let i = 0; i < chunks.length; ++i) { + receiver.write(chunks[i]); + } + }); + + it('parses a 100 B masked binary message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = crypto.randomBytes(100); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 256 B masked binary message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = crypto.randomBytes(256); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 200 KiB masked binary message', (done) => { + const receiver = new Receiver({ isServer: true }); + const msg = crypto.randomBytes(200 * 1024); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a 200 KiB unmasked binary message', (done) => { + const receiver = new Receiver(); + const msg = crypto.randomBytes(200 * 1024); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: false, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, msg); + assert.ok(isBinary); + done(); + }); + + receiver.write(frame); + }); + + it('parses a compressed message', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const buf = Buffer.from('Hello'); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, buf); + assert.ok(!isBinary); + done(); + }); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + receiver.write(Buffer.from([0xc1, data.length])); + receiver.write(data); + }); + }); + + it('parses a compressed and fragmented message', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const buf1 = Buffer.from('foo'); + const buf2 = Buffer.from('bar'); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat([buf1, buf2])); + assert.ok(!isBinary); + done(); + }); + + perMessageDeflate.compress(buf1, false, (err, fragment1) => { + if (err) return done(err); + + receiver.write(Buffer.from([0x41, fragment1.length])); + receiver.write(fragment1); + + perMessageDeflate.compress(buf2, true, (err, fragment2) => { + if (err) return done(err); + + receiver.write(Buffer.from([0x80, fragment2.length])); + receiver.write(fragment2); + }); + }); + }); + + it('parses a buffer with thousands of frames', (done) => { + const buf = Buffer.allocUnsafe(40000); + + for (let i = 0; i < buf.length; i += 2) { + buf[i] = 0x81; + buf[i + 1] = 0x00; + } + + const receiver = new Receiver(); + let counter = 0; + + receiver.on('message', (data, isBinary) => { + assert.strictEqual(data, EMPTY_BUFFER); + assert.ok(!isBinary); + if (++counter === 20000) done(); + }); + + receiver.write(buf); + }); + + it('resets `totalPayloadLength` only on final frame (unfragmented)', (done) => { + const receiver = new Receiver({ maxPayload: 10 }); + + receiver.on('message', (data, isBinary) => { + assert.strictEqual(receiver._totalPayloadLength, 0); + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + done(); + }); + + assert.strictEqual(receiver._totalPayloadLength, 0); + receiver.write(Buffer.from('810548656c6c6f', 'hex')); + }); + + it('resets `totalPayloadLength` only on final frame (fragmented)', (done) => { + const receiver = new Receiver({ maxPayload: 10 }); + + receiver.on('message', (data, isBinary) => { + assert.strictEqual(receiver._totalPayloadLength, 0); + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + done(); + }); + + assert.strictEqual(receiver._totalPayloadLength, 0); + receiver.write(Buffer.from('01024865', 'hex')); + assert.strictEqual(receiver._totalPayloadLength, 2); + receiver.write(Buffer.from('80036c6c6f', 'hex')); + }); + + it('resets `totalPayloadLength` only on final frame (fragmented + ping)', (done) => { + const receiver = new Receiver({ maxPayload: 10 }); + let data; + + receiver.on('ping', (buf) => { + assert.strictEqual(receiver._totalPayloadLength, 2); + data = buf; + }); + receiver.on('message', (buf, isBinary) => { + assert.strictEqual(receiver._totalPayloadLength, 0); + assert.deepStrictEqual(data, EMPTY_BUFFER); + assert.deepStrictEqual(buf, Buffer.from('Hello')); + assert.ok(isBinary); + done(); + }); + + assert.strictEqual(receiver._totalPayloadLength, 0); + receiver.write(Buffer.from('02024865', 'hex')); + receiver.write(Buffer.from('8900', 'hex')); + receiver.write(Buffer.from('80036c6c6f', 'hex')); + }); + + it('ignores any data after a close frame', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const results = []; + const push = results.push.bind(results); + + receiver.on('conclude', push).on('message', push); + receiver.on('finish', () => { + assert.deepStrictEqual(results, [ + EMPTY_BUFFER, + false, + 1005, + EMPTY_BUFFER + ]); + done(); + }); + + receiver.write(Buffer.from([0xc1, 0x01, 0x00])); + receiver.write(Buffer.from([0x88, 0x00])); + receiver.write(Buffer.from([0x81, 0x00])); + }); + + it('emits an error if RSV1 is on and permessage-deflate is disabled', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV1 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0xc2, 0x80, 0x00, 0x00, 0x00, 0x00])); + }); + + it('emits an error if RSV1 is on and opcode is 0', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV1 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x40, 0x00])); + }); + + it('emits an error if RSV2 is on', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV2 and RSV3 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0xa2, 0x00])); + }); + + it('emits an error if RSV3 is on', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_2_3'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV2 and RSV3 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x92, 0x00])); + }); + + it('emits an error if the first frame in a fragmented message has opcode 0', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 0' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x00, 0x00])); + }); + + it('emits an error if a frame has opcode 1 in the middle of a fragmented message', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 1' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x01, 0x00])); + receiver.write(Buffer.from([0x01, 0x00])); + }); + + it('emits an error if a frame has opcode 2 in the middle of a fragmented message', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 2' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x01, 0x00])); + receiver.write(Buffer.from([0x02, 0x00])); + }); + + it('emits an error if a control frame has the FIN bit off', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_FIN'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: FIN must be set' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x09, 0x00])); + }); + + it('emits an error if a control frame has the RSV1 bit on', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_RSV_1'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: RSV1 must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0xc9, 0x00])); + }); + + it('emits an error if a control frame has the FIN bit off', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_FIN'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: FIN must be set' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x09, 0x00])); + }); + + it('emits an error if a frame has the MASK bit off (server mode)', (done) => { + const receiver = new Receiver({ isServer: true }); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_EXPECTED_MASK'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: MASK must be set' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x81, 0x02, 0x68, 0x69])); + }); + + it('emits an error if a frame has the MASK bit on (client mode)', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNEXPECTED_MASK'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: MASK must be clear' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write( + Buffer.from([0x81, 0x82, 0x56, 0x3a, 0xac, 0x80, 0x3e, 0x53]) + ); + }); + + it('emits an error if a control frame has a payload bigger than 125 B', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid payload length 126' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x89, 0x7e])); + }); + + it('emits an error if a data frame has a payload bigger than 2^53 - 1 B', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'); + assert.strictEqual( + err.message, + 'Unsupported WebSocket frame: payload length > 2^53 - 1' + ); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + receiver.write(Buffer.from([0x82, 0x7f])); + setImmediate(() => + receiver.write( + Buffer.from([0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + ) + ); + }); + + it('emits an error if a text frame contains invalid UTF-8 data (1/2)', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid UTF-8 sequence' + ); + assert.strictEqual(err[kStatusCode], 1007); + done(); + }); + + receiver.write(Buffer.from([0x81, 0x04, 0xce, 0xba, 0xe1, 0xbd])); + }); + + it('emits an error if a text frame contains invalid UTF-8 data (2/2)', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { + 'permessage-deflate': perMessageDeflate + } + }); + const buf = Buffer.from([0xce, 0xba, 0xe1, 0xbd]); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid UTF-8 sequence' + ); + assert.strictEqual(err[kStatusCode], 1007); + done(); + }); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + receiver.write(Buffer.from([0xc1, data.length])); + receiver.write(data); + }); + }); + + it('emits an error if a close frame has a payload of 1 B', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid payload length 1' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x88, 0x01, 0x00])); + }); + + it('emits an error if a close frame contains an invalid close code', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_CLOSE_CODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid status code 0' + ); + assert.strictEqual(err[kStatusCode], 1002); + done(); + }); + + receiver.write(Buffer.from([0x88, 0x02, 0x00, 0x00])); + }); + + it('emits an error if a close frame contains invalid UTF-8 data', (done) => { + const receiver = new Receiver(); + + receiver.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'WS_ERR_INVALID_UTF8'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid UTF-8 sequence' + ); + assert.strictEqual(err[kStatusCode], 1007); + done(); + }); + + receiver.write( + Buffer.from([0x88, 0x06, 0x03, 0xef, 0xce, 0xba, 0xe1, 0xbd]) + ); + }); + + it('emits an error if a frame payload length is bigger than `maxPayload`', (done) => { + const receiver = new Receiver({ isServer: true, maxPayload: 20 * 1024 }); + const msg = crypto.randomBytes(200 * 1024); + + const list = Sender.frame(msg, { + fin: true, + rsv1: false, + opcode: 0x02, + mask: true, + readOnly: true + }); + + const frame = Buffer.concat(list); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + receiver.write(frame); + }); + + it('emits an error if the message length exceeds `maxPayload`', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); + const buf = Buffer.from('A'.repeat(50)); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + perMessageDeflate.compress(buf, true, (err, data) => { + if (err) return done(err); + + receiver.write(Buffer.from([0xc1, data.length])); + receiver.write(data); + }); + }); + + it('emits an error if the sum of fragment lengths exceeds `maxPayload`', (done) => { + const perMessageDeflate = new PerMessageDeflate({}, false, 25); + perMessageDeflate.accept([{}]); + + const receiver = new Receiver({ + extensions: { 'permessage-deflate': perMessageDeflate }, + isServer: false, + maxPayload: 25 + }); + const buf = Buffer.from('A'.repeat(15)); + + receiver.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + assert.strictEqual(err[kStatusCode], 1009); + done(); + }); + + perMessageDeflate.compress(buf, false, (err, fragment1) => { + if (err) return done(err); + + receiver.write(Buffer.from([0x41, fragment1.length])); + receiver.write(fragment1); + + perMessageDeflate.compress(buf, true, (err, fragment2) => { + if (err) return done(err); + + receiver.write(Buffer.from([0x80, fragment2.length])); + receiver.write(fragment2); + }); + }); + }); + + it("honors the 'nodebuffer' binary type", (done) => { + const receiver = new Receiver(); + const frags = [ + crypto.randomBytes(7321), + crypto.randomBytes(137), + crypto.randomBytes(285787), + crypto.randomBytes(3) + ]; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.concat(frags)); + assert.ok(isBinary); + done(); + }); + + frags.forEach((frag, i) => { + Sender.frame(frag, { + fin: i === frags.length - 1, + opcode: i === 0 ? 2 : 0, + readOnly: true, + mask: false, + rsv1: false + }).forEach((buf) => receiver.write(buf)); + }); + }); + + it("honors the 'arraybuffer' binary type", (done) => { + const receiver = new Receiver({ binaryType: 'arraybuffer' }); + const frags = [ + crypto.randomBytes(19221), + crypto.randomBytes(954), + crypto.randomBytes(623987) + ]; + + receiver.on('message', (data, isBinary) => { + assert.ok(data instanceof ArrayBuffer); + assert.deepStrictEqual(Buffer.from(data), Buffer.concat(frags)); + assert.ok(isBinary); + done(); + }); + + frags.forEach((frag, i) => { + Sender.frame(frag, { + fin: i === frags.length - 1, + opcode: i === 0 ? 2 : 0, + readOnly: true, + mask: false, + rsv1: false + }).forEach((buf) => receiver.write(buf)); + }); + }); + + it("honors the 'fragments' binary type", (done) => { + const receiver = new Receiver({ binaryType: 'fragments' }); + const frags = [ + crypto.randomBytes(17), + crypto.randomBytes(419872), + crypto.randomBytes(83), + crypto.randomBytes(9928), + crypto.randomBytes(1) + ]; + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, frags); + assert.ok(isBinary); + done(); + }); + + frags.forEach((frag, i) => { + Sender.frame(frag, { + fin: i === frags.length - 1, + opcode: i === 0 ? 2 : 0, + readOnly: true, + mask: false, + rsv1: false + }).forEach((buf) => receiver.write(buf)); + }); + }); + + it('honors the `skipUTF8Validation` option (1/2)', (done) => { + const receiver = new Receiver({ skipUTF8Validation: true }); + + receiver.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from([0xf8])); + assert.ok(!isBinary); + done(); + }); + + receiver.write(Buffer.from([0x81, 0x01, 0xf8])); + }); + + it('honors the `skipUTF8Validation` option (2/2)', (done) => { + const receiver = new Receiver({ skipUTF8Validation: true }); + + receiver.on('conclude', (code, data) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(data, Buffer.from([0xf8])); + done(); + }); + + receiver.write(Buffer.from([0x88, 0x03, 0x03, 0xe8, 0xf8])); + }); +}); diff --git a/testing/xpcshell/node-ws/test/sender.test.js b/testing/xpcshell/node-ws/test/sender.test.js new file mode 100644 index 0000000000..532239fa1a --- /dev/null +++ b/testing/xpcshell/node-ws/test/sender.test.js @@ -0,0 +1,370 @@ +'use strict'; + +const assert = require('assert'); + +const extension = require('../lib/extension'); +const PerMessageDeflate = require('../lib/permessage-deflate'); +const Sender = require('../lib/sender'); +const { EMPTY_BUFFER } = require('../lib/constants'); + +class MockSocket { + constructor({ write } = {}) { + this.readable = true; + this.writable = true; + + if (write) this.write = write; + } + + cork() {} + write() {} + uncork() {} +} + +describe('Sender', () => { + describe('.frame', () => { + it('does not mutate the input buffer if data is `readOnly`', () => { + const buf = Buffer.from([1, 2, 3, 4, 5]); + + Sender.frame(buf, { + readOnly: true, + rsv1: false, + mask: true, + opcode: 2, + fin: true + }); + + assert.ok(buf.equals(Buffer.from([1, 2, 3, 4, 5]))); + }); + + it('honors the `rsv1` option', () => { + const list = Sender.frame(EMPTY_BUFFER, { + readOnly: false, + mask: false, + rsv1: true, + opcode: 1, + fin: true + }); + + assert.strictEqual(list[0][0] & 0x40, 0x40); + }); + + it('accepts a string as first argument', () => { + const list = Sender.frame('€', { + readOnly: false, + rsv1: false, + mask: false, + opcode: 1, + fin: true + }); + + assert.deepStrictEqual(list[0], Buffer.from('8103', 'hex')); + assert.deepStrictEqual(list[1], Buffer.from('e282ac', 'hex')); + }); + }); + + describe('#send', () => { + it('compresses data if compress option is enabled', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate(); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 6) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x40); + + assert.strictEqual(chunks[4].length, 2); + assert.strictEqual(chunks[4][0] & 0x40, 0x40); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + const options = { compress: true, fin: true }; + const array = new Uint8Array([0x68, 0x69]); + + sender.send(array.buffer, options); + sender.send(array, options); + sender.send('hi', options); + }); + + describe('when context takeover is disabled', () => { + it('honors the compression threshold', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate(); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 2) return; + + assert.strictEqual(chunks[0].length, 2); + assert.notStrictEqual(chunk[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1], 'hi'); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('hi', { compress: true, fin: true }); + }); + + it('compresses all fragments of a fragmented message', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 9); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 4); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('123', { compress: true, fin: false }); + sender.send('12', { compress: true, fin: true }); + }); + + it('does not compress any fragments of a fragmented message', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 3 }); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x00); + assert.strictEqual(chunks[1].length, 2); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 3); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('12', { compress: true, fin: false }); + sender.send('123', { compress: true, fin: true }); + }); + + it('compresses empty buffer as first fragment', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 5); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 6); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send(Buffer.alloc(0), { compress: true, fin: false }); + sender.send('data', { compress: true, fin: true }); + }); + + it('compresses empty buffer as last fragment', (done) => { + const chunks = []; + const perMessageDeflate = new PerMessageDeflate({ threshold: 0 }); + const mockSocket = new MockSocket({ + write: (chunk) => { + chunks.push(chunk); + if (chunks.length !== 4) return; + + assert.strictEqual(chunks[0].length, 2); + assert.strictEqual(chunks[0][0] & 0x40, 0x40); + assert.strictEqual(chunks[1].length, 10); + + assert.strictEqual(chunks[2].length, 2); + assert.strictEqual(chunks[2][0] & 0x40, 0x00); + assert.strictEqual(chunks[3].length, 1); + done(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + const extensions = extension.parse( + 'permessage-deflate; client_no_context_takeover' + ); + + perMessageDeflate.accept(extensions['permessage-deflate']); + + sender.send('data', { compress: true, fin: false }); + sender.send(Buffer.alloc(0), { compress: true, fin: true }); + }); + }); + }); + + describe('#ping', () => { + it('works with multiple types of data', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count < 3) return; + + if (count % 2) { + assert.ok(data.equals(Buffer.from([0x89, 0x02]))); + } else if (count < 8) { + assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } else { + assert.strictEqual(data, 'hi'); + done(); + } + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + const array = new Uint8Array([0x68, 0x69]); + + sender.send('foo', { compress: true, fin: true }); + sender.ping(array.buffer, false); + sender.ping(array, false); + sender.ping('hi', false); + }); + }); + + describe('#pong', () => { + it('works with multiple types of data', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + let count = 0; + const mockSocket = new MockSocket({ + write: (data) => { + if (++count < 3) return; + + if (count % 2) { + assert.ok(data.equals(Buffer.from([0x8a, 0x02]))); + } else if (count < 8) { + assert.ok(data.equals(Buffer.from([0x68, 0x69]))); + } else { + assert.strictEqual(data, 'hi'); + done(); + } + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + const array = new Uint8Array([0x68, 0x69]); + + sender.send('foo', { compress: true, fin: true }); + sender.pong(array.buffer, false); + sender.pong(array, false); + sender.pong('hi', false); + }); + }); + + describe('#close', () => { + it('throws an error if the first argument is invalid', () => { + const mockSocket = new MockSocket(); + const sender = new Sender(mockSocket); + + assert.throws( + () => sender.close('error'), + /^TypeError: First argument must be a valid error code number$/ + ); + + assert.throws( + () => sender.close(1004), + /^TypeError: First argument must be a valid error code number$/ + ); + }); + + it('throws an error if the message is greater than 123 bytes', () => { + const mockSocket = new MockSocket(); + const sender = new Sender(mockSocket); + + assert.throws( + () => sender.close(1000, 'a'.repeat(124)), + /^RangeError: The message must not be greater than 123 bytes$/ + ); + }); + + it('should consume all data before closing', (done) => { + const perMessageDeflate = new PerMessageDeflate(); + + let count = 0; + const mockSocket = new MockSocket({ + write: (data, cb) => { + count++; + if (cb) cb(); + } + }); + const sender = new Sender(mockSocket, { + 'permessage-deflate': perMessageDeflate + }); + + perMessageDeflate.accept([{}]); + + sender.send('foo', { compress: true, fin: true }); + sender.send('bar', { compress: true, fin: true }); + sender.send('baz', { compress: true, fin: true }); + + sender.close(1000, undefined, false, () => { + assert.strictEqual(count, 8); + done(); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/subprotocol.test.js b/testing/xpcshell/node-ws/test/subprotocol.test.js new file mode 100644 index 0000000000..91dd5d69d8 --- /dev/null +++ b/testing/xpcshell/node-ws/test/subprotocol.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const assert = require('assert'); + +const { parse } = require('../lib/subprotocol'); + +describe('subprotocol', () => { + describe('parse', () => { + it('parses a single subprotocol', () => { + assert.deepStrictEqual(parse('foo'), new Set(['foo'])); + }); + + it('parses multiple subprotocols', () => { + assert.deepStrictEqual( + parse('foo,bar,baz'), + new Set(['foo', 'bar', 'baz']) + ); + }); + + it('ignores the optional white spaces', () => { + const header = 'foo , bar\t, \tbaz\t , qux\t\t,norf'; + + assert.deepStrictEqual( + parse(header), + new Set(['foo', 'bar', 'baz', 'qux', 'norf']) + ); + }); + + it('throws an error if a subprotocol is empty', () => { + [ + [',', 0], + ['foo,,', 4], + ['foo, ,', 6] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a subprotocol is duplicated', () => { + ['foo,foo,bar', 'foo,bar,foo'].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: The "foo" subprotocol is duplicated$/ + ); + }); + }); + + it('throws an error if a white space is misplaced', () => { + [ + ['f oo', 2], + [' foo', 0] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if a subprotocol contains invalid characters', () => { + [ + ['f@o', 1], + ['f\\oo', 1], + ['foo,b@r', 5] + ].forEach((element) => { + assert.throws( + () => parse(element[0]), + new RegExp( + `^SyntaxError: Unexpected character at index ${element[1]}$` + ) + ); + }); + }); + + it('throws an error if the header value ends prematurely', () => { + ['foo ', 'foo, ', 'foo,bar ', 'foo,bar,'].forEach((header) => { + assert.throws( + () => parse(header), + /^SyntaxError: Unexpected end of input$/ + ); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/validation.test.js b/testing/xpcshell/node-ws/test/validation.test.js new file mode 100644 index 0000000000..5718b12f02 --- /dev/null +++ b/testing/xpcshell/node-ws/test/validation.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('assert'); + +const { isValidUTF8 } = require('../lib/validation'); + +describe('extension', () => { + describe('isValidUTF8', () => { + it('returns false if it finds invalid bytes', () => { + assert.strictEqual(isValidUTF8(Buffer.from([0xf8])), false); + }); + + it('returns false for overlong encodings', () => { + assert.strictEqual(isValidUTF8(Buffer.from([0xc0, 0xa0])), false); + assert.strictEqual(isValidUTF8(Buffer.from([0xe0, 0x80, 0xa0])), false); + assert.strictEqual( + isValidUTF8(Buffer.from([0xf0, 0x80, 0x80, 0xa0])), + false + ); + }); + + it('returns false for code points in the range U+D800 - U+DFFF', () => { + for (let i = 0xa0; i < 0xc0; i++) { + for (let j = 0x80; j < 0xc0; j++) { + assert.strictEqual(isValidUTF8(Buffer.from([0xed, i, j])), false); + } + } + }); + + it('returns false for code points greater than U+10FFFF', () => { + assert.strictEqual( + isValidUTF8(Buffer.from([0xf4, 0x90, 0x80, 0x80])), + false + ); + assert.strictEqual( + isValidUTF8(Buffer.from([0xf5, 0x80, 0x80, 0x80])), + false + ); + }); + + it('returns true for a well-formed UTF-8 byte sequence', () => { + // prettier-ignore + const buf = Buffer.from([ + 0xe2, 0x82, 0xAC, // € + 0xf0, 0x90, 0x8c, 0x88, // 𐍈 + 0x24 // $ + ]); + + assert.strictEqual(isValidUTF8(buf), true); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/websocket-server.test.js b/testing/xpcshell/node-ws/test/websocket-server.test.js new file mode 100644 index 0000000000..12928ff495 --- /dev/null +++ b/testing/xpcshell/node-ws/test/websocket-server.test.js @@ -0,0 +1,1284 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^ws$" }] */ + +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const https = require('https'); +const http = require('http'); +const path = require('path'); +const net = require('net'); +const fs = require('fs'); +const os = require('os'); + +const Sender = require('../lib/sender'); +const WebSocket = require('..'); +const { NOOP } = require('../lib/constants'); + +describe('WebSocketServer', () => { + describe('#ctor', () => { + it('throws an error if no option object is passed', () => { + assert.throws( + () => new WebSocket.Server(), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + }); + + describe('options', () => { + it('throws an error if required options are not specified', () => { + assert.throws( + () => new WebSocket.Server({}), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + }); + + it('throws an error if mutually exclusive options are specified', () => { + const server = http.createServer(); + const variants = [ + { port: 0, noServer: true, server }, + { port: 0, noServer: true }, + { port: 0, server }, + { noServer: true, server } + ]; + + for (const options of variants) { + assert.throws( + () => new WebSocket.Server(options), + new RegExp( + '^TypeError: One and only one of the "port", "server", or ' + + '"noServer" options must be specified$' + ) + ); + } + }); + + it('exposes options passed to constructor', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + assert.strictEqual(wss.options.port, 0); + wss.close(done); + }); + }); + + it('accepts the `maxPayload` option', (done) => { + const maxPayload = 20480; + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + maxPayload, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws._receiver._maxPayload, maxPayload); + assert.strictEqual( + ws._receiver._extensions['permessage-deflate']._maxPayload, + maxPayload + ); + wss.close(done); + }); + }); + + it('honors the `WebSocket` option', (done) => { + class CustomWebSocket extends WebSocket.WebSocket { + get foo() { + return 'foo'; + } + } + + const wss = new WebSocket.Server( + { + port: 0, + WebSocket: CustomWebSocket + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + } + ); + + wss.on('connection', (ws) => { + assert.ok(ws instanceof CustomWebSocket); + assert.strictEqual(ws.foo, 'foo'); + wss.close(done); + }); + }); + }); + + it('emits an error if http server bind fails', (done) => { + const wss1 = new WebSocket.Server({ port: 0 }, () => { + const wss2 = new WebSocket.Server({ + port: wss1.address().port + }); + + wss2.on('error', () => wss1.close(done)); + }); + }); + + it('starts a server on a given port', (done) => { + const port = 1337; + const wss = new WebSocket.Server({ port }, () => { + const ws = new WebSocket(`ws://localhost:${port}`); + + ws.on('open', ws.close); + }); + + wss.on('connection', () => wss.close(done)); + }); + + it('binds the server on any IPv6 address when available', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + assert.strictEqual(wss._server.address().address, '::'); + wss.close(done); + }); + }); + + it('uses a precreated http server', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ server }); + + wss.on('connection', () => { + server.close(done); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); + }); + }); + + it('426s for non-Upgrade requests', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + http.get(`http://localhost:${wss.address().port}`, (res) => { + let body = ''; + + assert.strictEqual(res.statusCode, 426); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + assert.strictEqual(body, http.STATUS_CODES[426]); + wss.close(done); + }); + }); + }); + }); + + it('uses a precreated http server listening on unix socket', function (done) { + // + // Skip this test on Windows. The URL parser: + // + // - Throws an error if the named pipe uses backward slashes. + // - Incorrectly parses the path if the named pipe uses forward slashes. + // + if (process.platform === 'win32') return this.skip(); + + const server = http.createServer(); + const sockPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + server.listen(sockPath, () => { + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws, req) => { + if (wss.clients.size === 1) { + assert.strictEqual(req.url, '/foo?bar=bar'); + } else { + assert.strictEqual(req.url, '/'); + + for (const client of wss.clients) { + client.close(); + } + + server.close(done); + } + }); + + const ws = new WebSocket(`ws+unix://${sockPath}:/foo?bar=bar`); + ws.on('open', () => new WebSocket(`ws+unix://${sockPath}`)); + }); + }); + }); + + describe('#address', () => { + it('returns the address of the server', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const addr = wss.address(); + + assert.deepStrictEqual(addr, wss._server.address()); + wss.close(done); + }); + }); + + it('throws an error when operating in "noServer" mode', () => { + const wss = new WebSocket.Server({ noServer: true }); + + assert.throws(() => { + wss.address(); + }, /^Error: The server is operating in "noServer" mode$/); + }); + + it('returns `null` if called after close', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss.address(), null); + done(); + }); + }); + }); + }); + + describe('#close', () => { + it('does not throw if called multiple times', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.on('close', done); + + wss.close(); + wss.close(); + wss.close(); + }); + }); + + it("doesn't close a precreated server", (done) => { + const server = http.createServer(); + const realClose = server.close; + + server.close = () => { + done(new Error('Must not close pre-created server')); + }; + + const wss = new WebSocket.Server({ server }); + + wss.on('connection', () => { + wss.close(); + server.close = realClose; + server.close(done); + }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', ws.close); + }); + }); + + it('invokes the callback in noServer mode', (done) => { + const wss = new WebSocket.Server({ noServer: true }); + + wss.close(done); + }); + + it('cleans event handlers on precreated server', (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + wss.close(() => { + assert.strictEqual(server.listenerCount('listening'), 0); + assert.strictEqual(server.listenerCount('upgrade'), 0); + assert.strictEqual(server.listenerCount('error'), 0); + + server.close(done); + }); + }); + }); + + it("emits the 'close' event after the server closes", (done) => { + let serverCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + net.createConnection({ port: wss.address().port }); + }); + + wss._server.on('connection', (socket) => { + wss.close(); + + // + // The server is closing. Ensure this does not emit a `'close'` + // event before the server is actually closed. + // + wss.close(); + + process.nextTick(() => { + socket.end(); + }); + }); + + wss._server.on('close', () => { + serverCloseEventEmitted = true; + }); + + wss.on('close', () => { + assert.ok(serverCloseEventEmitted); + done(); + }); + }); + + it("emits the 'close' event if client tracking is disabled", (done) => { + const wss = new WebSocket.Server({ + noServer: true, + clientTracking: false + }); + + wss.on('close', done); + wss.close(); + }); + + it('calls the callback if the server is already closed', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss._state, 2); + + wss.close((err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'The server is not running'); + done(); + }); + }); + }); + }); + + it("emits the 'close' event if the server is already closed", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + wss.close(() => { + assert.strictEqual(wss._state, 2); + + wss.on('close', done); + wss.close(); + }); + }); + }); + }); + + describe('#clients', () => { + it('returns a list of connected clients', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + assert.strictEqual(wss.clients.size, 0); + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + }); + + wss.on('connection', () => { + assert.strictEqual(wss.clients.size, 1); + wss.close(done); + }); + }); + + it('can be disabled', (done) => { + const wss = new WebSocket.Server( + { port: 0, clientTracking: false }, + () => { + assert.strictEqual(wss.clients, undefined); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.close()); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(wss.clients, undefined); + ws.on('close', () => wss.close(done)); + }); + }); + + it('is updated when client terminates the connection', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.terminate()); + }); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.strictEqual(wss.clients.size, 0); + wss.close(done); + }); + }); + }); + + it('is updated when client closes the connection', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.close()); + }); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.strictEqual(wss.clients.size, 0); + wss.close(done); + }); + }); + }); + }); + + describe('#shouldHandle', () => { + it('returns true when the path matches', () => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + assert.strictEqual(wss.shouldHandle({ url: '/foo' }), true); + assert.strictEqual(wss.shouldHandle({ url: '/foo?bar=baz' }), true); + }); + + it("returns false when the path doesn't match", () => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + assert.strictEqual(wss.shouldHandle({ url: '/bar' }), false); + }); + }); + + describe('#handleUpgrade', () => { + it('can be used for a pre-existing server', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.send('hello'); + ws.close(); + }); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hello')); + assert.ok(!isBinary); + server.close(done); + }); + }); + }); + + it("closes the connection when path doesn't match", (done) => { + const wss = new WebSocket.Server({ port: 0, path: '/ws' }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + wss.close(done); + }); + }); + }); + + it('closes the connection when protocol version is Hixie-76', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'WebSocket', + 'Sec-WebSocket-Key1': '4 @1 46546xW%0l 1 5', + 'Sec-WebSocket-Key2': '12998 5 Y3 1 .P00', + 'Sec-WebSocket-Protocol': 'sample' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Key header' + ); + wss.close(done); + }); + }); + }); + }); + }); + + describe('#completeUpgrade', () => { + it('throws an error if called twice with the same socket', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.close(); + }); + assert.throws( + () => wss.handleUpgrade(req, socket, head, NOOP), + (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'server.handleUpgrade() was called more than once with the ' + + 'same socket, possibly due to a misconfiguration' + ); + return true; + } + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => { + ws.on('close', () => { + server.close(done); + }); + }); + }); + }); + }); + + describe('Connection establishing', () => { + it('fails if the HTTP method is not GET', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.request({ + method: 'POST', + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 405); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid HTTP method' + ); + wss.close(done); + }); + }); + + req.end(); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Upgrade header field value is not "websocket"', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'foo' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid Upgrade header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Sec-WebSocket-Key header is invalid (1/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Key header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Sec-WebSocket-Key header is invalid (2/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'P5l8BJcZwRc=' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Key header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Sec-WebSocket-Version header is invalid (1/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Version header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Sec-WebSocket-Version header is invalid (2/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 12 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Missing or invalid Sec-WebSocket-Version header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails is the Sec-WebSocket-Protocol header is invalid', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13, + 'Sec-WebSocket-Protocol': 'foo;bar' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid Sec-WebSocket-Protocol header' + ); + wss.close(done); + }); + }); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the Sec-WebSocket-Extensions header is invalid', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13, + 'Sec-WebSocket-Extensions': + 'permessage-deflate; server_max_window_bits=foo' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual( + Buffer.concat(chunks).toString(), + 'Invalid or unacceptable Sec-WebSocket-Extensions header' + ); + wss.close(done); + }); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it("emits the 'wsClientError' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.request({ + method: 'POST', + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket' + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 400); + wss.close(done); + }); + + req.end(); + }); + + wss.on('wsClientError', (err, socket, request) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid HTTP method'); + + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.method, 'POST'); + + socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('fails if the WebSocket server is closing or closed', (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (req, socket, head) => { + wss.close(); + wss.handleUpgrade(req, socket, head, () => { + done(new Error('Unexpected callback invocation')); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 503); + res.resume(); + server.close(done); + }); + }); + }); + + it('handles unsupported extensions', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13, + 'Sec-WebSocket-Extensions': 'foo; bar' + } + }); + + req.on('upgrade', (res, socket, head) => { + if (head.length) socket.unshift(head); + + socket.once('data', (chunk) => { + assert.strictEqual(chunk[0], 0x88); + socket.destroy(); + wss.close(done); + }); + }); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, ''); + ws.close(); + }); + }); + + describe('`verifyClient`', () => { + it('can reject client synchronously', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: () => false, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 401); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('can accept client synchronously', (done) => { + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + + const wss = new WebSocket.Server({ + verifyClient: (info) => { + assert.strictEqual(info.origin, 'https://example.com'); + assert.strictEqual(info.req.headers.foo, 'bar'); + assert.ok(info.secure, true); + return true; + }, + server + }); + + wss.on('connection', () => { + server.close(done); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + headers: { Origin: 'https://example.com', foo: 'bar' }, + rejectUnauthorized: false + }); + + ws.on('open', ws.close); + }); + }); + + it('can accept client asynchronously', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (o, cb) => process.nextTick(cb, true), + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + } + ); + + wss.on('connection', () => wss.close(done)); + }); + + it('can reject client asynchronously', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => process.nextTick(cb, false), + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 401); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('can reject client asynchronously w/ status code', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => process.nextTick(cb, false, 404), + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 404); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + + it('can reject client asynchronously w/ custom headers', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => { + process.nextTick(cb, false, 503, '', { 'Retry-After': 120 }); + }, + port: 0 + }, + () => { + const req = http.get({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 8 + } + }); + + req.on('response', (res) => { + assert.strictEqual(res.statusCode, 503); + assert.strictEqual(res.headers['retry-after'], '120'); + wss.close(done); + }); + } + ); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + }); + }); + + it("doesn't emit the 'connection' event if socket is closed prematurely", (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const wss = new WebSocket.Server({ + verifyClient: ({ req: { socket } }, cb) => { + assert.strictEqual(socket.readable, true); + assert.strictEqual(socket.writable, true); + + socket.on('end', () => { + assert.strictEqual(socket.readable, false); + assert.strictEqual(socket.writable, true); + cb(true); + }); + }, + server + }); + + wss.on('connection', () => { + done(new Error("Unexpected 'connection' event")); + }); + + const socket = net.connect( + { + port: server.address().port, + allowHalfOpen: true + }, + () => { + socket.end( + [ + 'GET / HTTP/1.1', + 'Host: localhost', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version: 13', + '\r\n' + ].join('\r\n') + ); + } + ); + + socket.on('end', () => { + wss.close(); + server.close(done); + }); + }); + }); + + it('handles data passed along with the upgrade request', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const req = http.request({ + port: wss.address().port, + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version': 13 + } + }); + + const list = Sender.frame(Buffer.from('Hello'), { + fin: true, + rsv1: false, + opcode: 0x01, + mask: true, + readOnly: false + }); + + req.write(Buffer.concat(list)); + req.end(); + }); + + wss.on('connection', (ws) => { + ws.on('message', (data, isBinary) => { + assert.deepStrictEqual(data, Buffer.from('Hello')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + }); + + describe('`handleProtocols`', () => { + it('allows to select a subprotocol', (done) => { + const handleProtocols = (protocols, request) => { + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.url, '/'); + return Array.from(protocols).pop(); + }; + const wss = new WebSocket.Server({ handleProtocols, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => { + assert.strictEqual(ws.protocol, 'bar'); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + it("emits the 'headers' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + }); + + wss.on('headers', (headers, request) => { + assert.deepStrictEqual(headers.slice(0, 3), [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade' + ]); + assert.ok(request instanceof http.IncomingMessage); + assert.strictEqual(request.url, '/'); + + wss.on('connection', () => wss.close(done)); + }); + }); + }); + + describe('permessage-deflate', () => { + it('is disabled by default', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', ws.close); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers['sec-websocket-extensions'], + 'permessage-deflate; client_max_window_bits' + ); + assert.strictEqual(ws.extensions, ''); + wss.close(done); + }); + }); + + it('uses configuration options', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { clientMaxWindowBits: 8 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('upgrade', (res) => { + assert.strictEqual( + res.headers['sec-websocket-extensions'], + 'permessage-deflate; client_max_window_bits=8' + ); + + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/websocket.integration.js b/testing/xpcshell/node-ws/test/websocket.integration.js new file mode 100644 index 0000000000..abd96c61e4 --- /dev/null +++ b/testing/xpcshell/node-ws/test/websocket.integration.js @@ -0,0 +1,55 @@ +'use strict'; + +const assert = require('assert'); + +const WebSocket = require('..'); + +describe('WebSocket', () => { + it('communicates successfully with echo service (ws)', (done) => { + const ws = new WebSocket('ws://websocket-echo.com/', { + protocolVersion: 13 + }); + + let dataReceived = false; + + ws.on('open', () => { + ws.send('hello'); + }); + + ws.on('close', () => { + assert.ok(dataReceived); + done(); + }); + + ws.on('message', (message, isBinary) => { + dataReceived = true; + assert.ok(!isBinary); + assert.strictEqual(message.toString(), 'hello'); + ws.close(); + }); + }); + + it('communicates successfully with echo service (wss)', (done) => { + const ws = new WebSocket('wss://websocket-echo.com/', { + protocolVersion: 13 + }); + + let dataReceived = false; + + ws.on('open', () => { + ws.send('hello'); + }); + + ws.on('close', () => { + assert.ok(dataReceived); + done(); + }); + + ws.on('message', (message, isBinary) => { + dataReceived = true; + assert.ok(!isBinary); + assert.strictEqual(message.toString(), 'hello'); + ws.close(); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/test/websocket.test.js b/testing/xpcshell/node-ws/test/websocket.test.js new file mode 100644 index 0000000000..f5fbf16505 --- /dev/null +++ b/testing/xpcshell/node-ws/test/websocket.test.js @@ -0,0 +1,4514 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^ws$" }] */ + +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const https = require('https'); +const http = require('http'); +const path = require('path'); +const net = require('net'); +const tls = require('tls'); +const os = require('os'); +const fs = require('fs'); +const { URL } = require('url'); + +const Sender = require('../lib/sender'); +const WebSocket = require('..'); +const { + CloseEvent, + ErrorEvent, + Event, + MessageEvent +} = require('../lib/event-target'); +const { EMPTY_BUFFER, GUID, kListener, NOOP } = require('../lib/constants'); + +class CustomAgent extends http.Agent { + addRequest() {} +} + +describe('WebSocket', () => { + describe('#ctor', () => { + it('throws an error when using an invalid url', () => { + assert.throws( + () => new WebSocket('foo'), + /^SyntaxError: Invalid URL: foo$/ + ); + + assert.throws( + () => new WebSocket('https://websocket-echo.com'), + /^SyntaxError: The URL's protocol must be one of "ws:", "wss:", or "ws\+unix:"$/ + ); + + assert.throws( + () => new WebSocket('ws+unix:'), + /^SyntaxError: The URL's pathname is empty$/ + ); + + assert.throws( + () => new WebSocket('wss://websocket-echo.com#foo'), + /^SyntaxError: The URL contains a fragment identifier$/ + ); + }); + + it('throws an error if a subprotocol is invalid or duplicated', () => { + for (const subprotocol of [null, '', 'a,b', ['a', 'a']]) { + assert.throws( + () => new WebSocket('ws://localhost', subprotocol), + /^SyntaxError: An invalid or duplicated subprotocol was specified$/ + ); + } + }); + + it('accepts `url.URL` objects as url', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req, opts) => { + assert.strictEqual(opts.host, '::1'); + assert.strictEqual(req.path, '/'); + done(); + }; + + const ws = new WebSocket(new URL('ws://[::1]'), { agent }); + }); + + describe('options', () => { + it('accepts the `options` object as 3rd argument', () => { + const agent = new CustomAgent(); + let count = 0; + let ws; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-protocol'), + undefined + ); + count++; + }; + + ws = new WebSocket('ws://localhost', undefined, { agent }); + ws = new WebSocket('ws://localhost', [], { agent }); + + assert.strictEqual(count, 2); + }); + + it('accepts the `maxPayload` option', (done) => { + const maxPayload = 20480; + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: true, + maxPayload + }); + + ws.on('open', () => { + assert.strictEqual(ws._receiver._maxPayload, maxPayload); + assert.strictEqual( + ws._receiver._extensions['permessage-deflate']._maxPayload, + maxPayload + ); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('throws an error when using an invalid `protocolVersion`', () => { + const options = { agent: new CustomAgent(), protocolVersion: 1000 }; + + assert.throws( + () => new WebSocket('ws://localhost', options), + /^RangeError: Unsupported protocol version: 1000 \(supported versions: 8, 13\)$/ + ); + }); + + it('honors the `generateMask` option', (done) => { + const data = Buffer.from('foo'); + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + generateMask() {} + }); + + ws.on('open', () => { + ws.send(data); + }); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + const chunks = []; + + ws._socket.prependListener('data', (chunk) => { + chunks.push(chunk); + }); + + ws.on('message', (message) => { + assert.deepStrictEqual(message, data); + assert.deepStrictEqual( + Buffer.concat(chunks).slice(2, 6), + Buffer.alloc(4) + ); + + ws.close(); + }); + }); + }); + }); + }); + + describe('Constants', () => { + const readyStates = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 + }; + + Object.keys(readyStates).forEach((state) => { + describe(`\`${state}\``, () => { + it('is enumerable property of class', () => { + const descriptor = Object.getOwnPropertyDescriptor(WebSocket, state); + + assert.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false + }); + }); + + it('is enumerable property of prototype', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + state + ); + + assert.deepStrictEqual(descriptor, { + configurable: false, + enumerable: true, + value: readyStates[state], + writable: false + }); + }); + }); + }); + }); + + describe('Attributes', () => { + describe('`binaryType`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'binaryType' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set !== undefined); + }); + + it("defaults to 'nodebuffer'", () => { + const ws = new WebSocket('ws://localhost', { + agent: new CustomAgent() + }); + + assert.strictEqual(ws.binaryType, 'nodebuffer'); + }); + + it("can be changed to 'arraybuffer' or 'fragments'", () => { + const ws = new WebSocket('ws://localhost', { + agent: new CustomAgent() + }); + + ws.binaryType = 'arraybuffer'; + assert.strictEqual(ws.binaryType, 'arraybuffer'); + + ws.binaryType = 'foo'; + assert.strictEqual(ws.binaryType, 'arraybuffer'); + + ws.binaryType = 'fragments'; + assert.strictEqual(ws.binaryType, 'fragments'); + + ws.binaryType = ''; + assert.strictEqual(ws.binaryType, 'fragments'); + + ws.binaryType = 'nodebuffer'; + assert.strictEqual(ws.binaryType, 'nodebuffer'); + }); + }); + + describe('`bufferedAmount`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'bufferedAmount' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to zero', () => { + const ws = new WebSocket('ws://localhost', { + agent: new CustomAgent() + }); + + assert.strictEqual(ws.bufferedAmount, 0); + }); + + it('defaults to zero upon "open"', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.onopen = () => { + assert.strictEqual(ws.bufferedAmount, 0); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('takes into account the data in the sender queue', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('foo'); + + assert.strictEqual(ws.bufferedAmount, 3); + + ws.send('bar', (err) => { + assert.ifError(err); + assert.strictEqual(ws.bufferedAmount, 0); + wss.close(done); + }); + + assert.strictEqual(ws.bufferedAmount, 6); + }); + } + ); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('takes into account the data in the socket queue', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const data = Buffer.alloc(1024, 61); + + while (ws.bufferedAmount === 0) { + ws.send(data); + } + + assert.ok(ws.bufferedAmount > 0); + assert.strictEqual( + ws.bufferedAmount, + ws._socket._writableState.length + ); + + ws.on('close', () => wss.close(done)); + ws.close(); + }); + }); + }); + + describe('`extensions`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'bufferedAmount' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('exposes the negotiated extensions names (1/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.extensions, ''); + + ws.on('open', () => { + assert.strictEqual(ws.extensions, ''); + ws.on('close', () => wss.close(done)); + }); + }); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, ''); + ws.close(); + }); + }); + + it('exposes the negotiated extensions names (2/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.extensions, ''); + + ws.on('open', () => { + assert.strictEqual(ws.extensions, 'permessage-deflate'); + ws.on('close', () => wss.close(done)); + }); + } + ); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.extensions, 'permessage-deflate'); + ws.close(); + }); + }); + }); + + describe('`isPaused`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'isPaused' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('indicates whether the websocket is paused', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.pause(); + assert.ok(ws.isPaused); + + ws.resume(); + assert.ok(!ws.isPaused); + + ws.close(); + wss.close(done); + }); + + assert.ok(!ws.isPaused); + }); + }); + }); + + describe('`protocol`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'protocol' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('exposes the subprotocol selected by the server', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, 'foo'); + + assert.strictEqual(ws.extensions, ''); + + ws.on('open', () => { + assert.strictEqual(ws.protocol, 'foo'); + ws.on('close', () => wss.close(done)); + }); + }); + + wss.on('connection', (ws) => { + assert.strictEqual(ws.protocol, 'foo'); + ws.close(); + }); + }); + }); + + describe('`readyState`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'readyState' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('defaults to `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + agent: new CustomAgent() + }); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + }); + + it('is set to `OPEN` once connection is established', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + assert.strictEqual(ws.readyState, WebSocket.OPEN); + ws.close(); + }); + + ws.on('close', () => wss.close(done)); + }); + }); + + it('is set to `CLOSED` once connection is closed', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + wss.close(done); + }); + + ws.on('open', () => ws.close(1001)); + }); + }); + + it('is set to `CLOSED` once connection is terminated', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + wss.close(done); + }); + + ws.on('open', () => ws.terminate()); + }); + }); + }); + + describe('`url`', () => { + it('is enumerable and configurable', () => { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + 'url' + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set === undefined); + }); + + it('exposes the server url', () => { + const url = 'ws://localhost'; + const ws = new WebSocket(url, { agent: new CustomAgent() }); + + assert.strictEqual(ws.url, url); + }); + }); + }); + + describe('Events', () => { + it("emits an 'error' event if an error occurs", (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + + ws._socket.write(Buffer.from([0x85, 0x00])); + }); + }); + + it('does not re-emit `net.Socket` errors', (done) => { + const codes = ['EPIPE', 'ECONNABORTED', 'ECANCELED', 'ECONNRESET']; + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.on('error', (err) => { + assert.ok(err instanceof Error); + assert.ok(codes.includes(err.code), `Unexpected code: ${err.code}`); + ws.on('close', (code, message) => { + assert.strictEqual(code, 1006); + assert.strictEqual(message, EMPTY_BUFFER); + wss.close(done); + }); + }); + + for (const client of wss.clients) client.terminate(); + ws.send('foo'); + ws.send('bar'); + }); + }); + }); + + it("emits an 'upgrade' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws.on('upgrade', (res) => { + assert.ok(res instanceof http.IncomingMessage); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it("emits a 'ping' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws.on('ping', () => wss.close(done)); + }); + + wss.on('connection', (ws) => { + ws.ping(); + ws.close(); + }); + }); + + it("emits a 'pong' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + ws.on('pong', () => wss.close(done)); + }); + + wss.on('connection', (ws) => { + ws.pong(); + ws.close(); + }); + }); + + it("emits a 'redirect' event", (done) => { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.close(); + }); + }); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/foo`); + assert.ok(req instanceof http.ClientRequest); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + }); + }); + + describe('Connection establishing', () => { + const server = http.createServer(); + + beforeEach((done) => server.listen(0, done)); + afterEach((done) => server.close(done)); + + it('fails if the Upgrade header field value is not "websocket"', (done) => { + server.once('upgrade', (req, socket) => { + socket.on('end', socket.end); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Connection: Upgrade\r\n' + + 'Upgrade: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid Upgrade header'); + done(); + }); + }); + + it('fails if the Sec-WebSocket-Accept header is invalid', (done) => { + server.once('upgrade', (req, socket) => { + socket.on('end', socket.end); + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + 'Sec-WebSocket-Accept: CxYS6+NgJSBG74mdgLvGscRvpns=\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Invalid Sec-WebSocket-Accept header'); + done(); + }); + }); + + it('close event is raised when server closes connection', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + done(); + }); + }); + + it('error is emitted if server aborts connection', (done) => { + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 401 ${http.STATUS_CODES[401]}\r\n` + + 'Connection: close\r\n' + + 'Content-type: text/html\r\n' + + `Content-Length: ${http.STATUS_CODES[401].length}\r\n` + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Unexpected server response: 401'); + done(); + }); + }); + + it('unexpected response can be read when sent by server', (done) => { + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 401 ${http.STATUS_CODES[401]}\r\n` + + 'Connection: close\r\n' + + 'Content-type: text/html\r\n' + + 'Content-Length: 3\r\n' + + '\r\n' + + 'foo' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', () => done(new Error("Unexpected 'error' event"))); + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 401); + + let data = ''; + + res.on('data', (v) => { + data += v; + }); + + res.on('end', () => { + assert.strictEqual(data, 'foo'); + done(); + }); + }); + }); + + it('request can be aborted when unexpected response is sent by server', (done) => { + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 401 ${http.STATUS_CODES[401]}\r\n` + + 'Connection: close\r\n' + + 'Content-type: text/html\r\n' + + 'Content-Length: 3\r\n' + + '\r\n' + + 'foo' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', () => done(new Error("Unexpected 'error' event"))); + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 401); + + res.on('end', done); + req.abort(); + }); + }); + + it('fails if the opening handshake timeout expires', (done) => { + server.once('upgrade', (req, socket) => socket.on('end', socket.end)); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + handshakeTimeout: 100 + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Opening handshake has timed out'); + done(); + }); + }); + + it('fails if an unexpected Sec-WebSocket-Extensions header is received', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + perMessageDeflate: false + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if the Sec-WebSocket-Extensions header is invalid (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo;=\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid Sec-WebSocket-Extensions header' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if the Sec-WebSocket-Extensions header is invalid (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: ' + + 'permessage-deflate; client_max_window_bits=7\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Invalid Sec-WebSocket-Extensions header' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if an unexpected extension is received (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server indicated an extension that was not requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if an unexpected extension is received (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Extensions: permessage-deflate,foo\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server indicated an extension that was not requested' + ); + ws.on('close', () => done()); + }); + }); + + it('fails if server sends a subprotocol when none was requested', (done) => { + const wss = new WebSocket.Server({ server }); + + wss.on('headers', (headers) => { + headers.push('Sec-WebSocket-Protocol: foo'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'Server sent a subprotocol but none was requested' + ); + ws.on('close', () => wss.close(done)); + }); + }); + + it('fails if server sends an invalid subprotocol (1/2)', (done) => { + const wss = new WebSocket.Server({ + handleProtocols: () => 'baz', + server + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => wss.close(done)); + }); + }); + + it('fails if server sends an invalid subprotocol (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + const key = crypto + .createHash('sha1') + .update(req.headers['sec-websocket-key'] + GUID) + .digest('base64'); + + socket.end( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${key}\r\n` + + 'Sec-WebSocket-Protocol:\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent an invalid subprotocol'); + ws.on('close', () => done()); + }); + }); + + it('fails if server sends no subprotocol', (done) => { + const wss = new WebSocket.Server({ + handleProtocols() {}, + server + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, [ + 'foo', + 'bar' + ]); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Server sent no subprotocol'); + ws.on('close', () => wss.close(done)); + }); + }); + + it('does not follow redirects by default', (done) => { + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 301 Moved Permanently\r\n' + + 'Location: ws://localhost:8080\r\n' + + '\r\n' + ); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Unexpected server response: 301'); + assert.strictEqual(ws._redirects, 0); + ws.on('close', () => done()); + }); + }); + + it('honors the `followRedirects` option', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, NOOP); + }); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + assert.strictEqual(ws.url, `ws://localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + ws.on('close', () => done()); + ws.close(); + }); + }); + + it('honors the `maxRedirects` option', (done) => { + const onUpgrade = (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /\r\n\r\n'); + }; + + server.on('upgrade', onUpgrade); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true, + maxRedirects: 1 + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Maximum redirects exceeded'); + assert.strictEqual(ws._redirects, 2); + + server.removeListener('upgrade', onUpgrade); + ws.on('close', () => done()); + }); + }); + + it('emits an error if the redirect URL is invalid (1/2)', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: ws://\r\n\r\n'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual(err.message, 'Invalid URL: ws://'); + assert.strictEqual(ws._redirects, 1); + + ws.on('close', () => done()); + }); + }); + + it('emits an error if the redirect URL is invalid (2/2)', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: http://localhost\r\n\r\n'); + }); + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true + }); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof SyntaxError); + assert.strictEqual( + err.message, + 'The URL\'s protocol must be one of "ws:", "wss:", or "ws+unix:"' + ); + assert.strictEqual(ws._redirects, 1); + + ws.on('close', () => done()); + }); + }); + + it('uses the first url userinfo when following redirects', (done) => { + const wss = new WebSocket.Server({ noServer: true, path: '/foo' }); + const authorization = 'Basic Zm9vOmJhcg=='; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://baz:qux@localhost:${port}/foo\r\n\r\n` + ); + server.once('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws, req) => { + assert.strictEqual(req.headers.authorization, authorization); + ws.close(); + }); + }); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://foo:bar@localhost:${port}`, { + followRedirects: true + }); + + assert.strictEqual(ws._req.getHeader('Authorization'), authorization); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://baz:qux@localhost:${port}/foo`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + describe('When moving away from a secure context', () => { + function proxy(httpServer, httpsServer) { + const server = net.createServer({ allowHalfOpen: true }); + + server.on('connection', (socket) => { + socket.on('readable', function read() { + socket.removeListener('readable', read); + + const buf = socket.read(1); + const target = buf[0] === 22 ? httpsServer : httpServer; + + socket.unshift(buf); + target.emit('connection', socket); + }); + }); + + return server; + } + + describe("If there is no 'redirect' event listener", () => { + it('drops the `auth` option', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + auth: 'foo:bar', + followRedirects: true, + rejectUnauthorized: false + }); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.close(done); + }); + }); + }); + + it('drops the Authorization and Cookie headers', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + server.close(done); + }); + }); + }); + }); + + describe("If there is at least one 'redirect' event listener", () => { + it('does not drop any headers by default', (done) => { + const httpServer = http.createServer(); + const httpsServer = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const server = proxy(httpServer, httpsServer); + + server.listen(() => { + const port = server.address().port; + + httpsServer.on('upgrade', (req, socket) => { + socket.on('error', NOOP); + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ server: httpServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers.authorization, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + + ws.close(); + }); + + const ws = new WebSocket(`wss://localhost:${port}`, { + followRedirects: true, + headers, + rejectUnauthorized: false + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.notStrictEqual(firstRequest, req); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + assert.strictEqual(req.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + }); + }); + }); + + describe('When the redirect host is different', () => { + describe("If there is no 'redirect' event listener", () => { + it('drops the `auth` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { + auth: 'foo:bar', + followRedirects: true + } + ); + + assert.strictEqual( + ws._req.getHeader('Authorization'), + 'Basic Zm9vOmJhcg==' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + ws.close(); + }); + }); + + it('drops the Authorization, Cookie and Host headers (1/4)', (done) => { + // Test the `ws:` to `ws:` case. + + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + wss.close(done); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual( + req.headers.host, + `localhost:${wss.address().port}` + ); + + ws.close(); + }); + }); + + it('drops the Authorization, Cookie and Host headers (2/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws:` to `ws+unix:` case. + + const socketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + server.once('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws+unix://${socketPath}\r\n\r\n` + ); + }); + + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'localhost'); + + ws.close(); + }); + + redirectedServer.listen(socketPath, () => { + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws+unix://${socketPath}`); + assert.strictEqual(ws._redirects, 1); + + redirectedServer.close(done); + }); + }); + }); + + it('drops the Authorization, Cookie and Host headers (3/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws+unix:` to `ws+unix:` case. + + const redirectingServerSocketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + const redirectedServerSocketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + const redirectingServer = http.createServer(); + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws+unix://${redirectedServerSocketPath}\r\n\r\n` + ); + }); + + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual(req.headers.host, 'localhost'); + + ws.close(); + }); + + redirectingServer.listen(redirectingServerSocketPath, listening); + redirectedServer.listen(redirectedServerSocketPath, listening); + + let callCount = 0; + + function listening() { + if (++callCount !== 2) return; + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket( + `ws+unix://${redirectingServerSocketPath}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual( + ws.url, + `ws+unix://${redirectedServerSocketPath}` + ); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); + + it('drops the Authorization, Cookie and Host headers (4/4)', function (done) { + if (process.platform === 'win32') return this.skip(); + + // Test the `ws+unix:` to `ws:` case. + + const redirectingServer = http.createServer(); + const redirectedServer = http.createServer(); + const wss = new WebSocket.Server({ server: redirectedServer }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + assert.strictEqual( + req.headers.host, + `localhost:${redirectedServer.address().port}` + ); + + ws.close(); + }); + + const socketPath = path.join( + os.tmpdir(), + `ws.${crypto.randomBytes(16).toString('hex')}.sock` + ); + + redirectingServer.listen(socketPath, listening); + redirectedServer.listen(0, listening); + + let callCount = 0; + + function listening() { + if (++callCount !== 2) return; + + const port = redirectedServer.address().port; + + redirectingServer.on('upgrade', (req, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: ws://localhost:${port}\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const ws = new WebSocket(`ws+unix://${socketPath}`, { + followRedirects: true, + headers + }); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.url, `ws://localhost:${port}/`); + assert.strictEqual(ws._redirects, 1); + + redirectingServer.close(); + redirectedServer.close(done); + }); + } + }); + }); + + describe("If there is at least one 'redirect' event listener", () => { + it('does not drop any headers by default', (done) => { + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar', + host: 'foo' + }; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const ws = new WebSocket( + `ws://localhost:${server.address().port}`, + { followRedirects: true, headers } + ); + + const firstRequest = ws._req; + + assert.strictEqual( + firstRequest.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual( + firstRequest.getHeader('Cookie'), + headers.cookie + ); + assert.strictEqual(firstRequest.getHeader('Host'), headers.host); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.notStrictEqual(firstRequest, req); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + assert.strictEqual(req.getHeader('Host'), headers.host); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual( + req.headers.authorization, + headers.authorization + ); + assert.strictEqual(req.headers.cookie, headers.cookie); + assert.strictEqual(req.headers.host, headers.host); + ws.close(); + }); + }); + }); + }); + + describe("In a listener of the 'redirect' event", () => { + it('allows to abort the request without swallowing errors', (done) => { + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/foo`); + + req.on('socket', () => { + req.abort(); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'socket hang up'); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + done(); + }); + }); + }); + }); + + it('allows to remove headers', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + + server.once('upgrade', (req, socket) => { + socket.end( + 'HTTP/1.1 302 Found\r\n' + + `Location: ws://localhost:${port}/\r\n\r\n` + ); + }); + + const headers = { + authorization: 'Basic Zm9vOmJhcg==', + cookie: 'foo=bar' + }; + + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + followRedirects: true, + headers + }); + + ws.on('redirect', (url, req) => { + assert.strictEqual(ws._redirects, 1); + assert.strictEqual(url, `ws://localhost:${port}/`); + assert.strictEqual( + req.getHeader('Authorization'), + headers.authorization + ); + assert.strictEqual(req.getHeader('Cookie'), headers.cookie); + + req.removeHeader('authorization'); + req.removeHeader('cookie'); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws, req) => { + assert.strictEqual(req.headers.authorization, undefined); + assert.strictEqual(req.headers.cookie, undefined); + ws.close(); + }); + }); + }); + }); + + describe('Connection with query string', () => { + it('connects when pathname is not null', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + const ws = new WebSocket(`ws://localhost:${port}/?token=qwerty`); + + ws.on('open', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('connects when pathname is null', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const port = wss.address().port; + const ws = new WebSocket(`ws://localhost:${port}?token=qwerty`); + + ws.on('open', () => { + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + describe('#pause', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + assert.ok(!ws.isPaused); + + ws.pause(); + assert.ok(!ws.isPaused); + + ws.on('open', () => { + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.pause(); + assert.ok(!ws.isPaused); + + wss.close(done); + }); + + ws.close(); + }); + }); + }); + + it('pauses the socket', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.terminate(); + wss.close(done); + }); + }); + }); + + describe('#ping', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + assert.throws( + () => ws.ping(), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + + assert.throws( + () => ws.ping(NOOP), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + }); + + it('increases `bufferedAmount` if `readyState` is 2 or 3', (done) => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws.bufferedAmount, 0); + + ws.ping('hi'); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.ping(); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.ping('hi'); + assert.strictEqual(ws.bufferedAmount, 4); + + ws.ping(); + assert.strictEqual(ws.bufferedAmount, 4); + + done(); + }); + }); + + ws.close(); + }); + + it('calls the callback w/ an error if `readyState` is 2 or 3', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.close(); + + assert.strictEqual(ws.bufferedAmount, 0); + + ws.ping('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + ws.ping((err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + wss.close(done); + }); + }); + }); + }); + }); + + it('can send a ping with no data', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.ping(() => { + ws.ping(); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pings = 0; + ws.on('ping', (data) => { + assert.ok(Buffer.isBuffer(data)); + assert.strictEqual(data.length, 0); + if (++pings === 2) wss.close(done); + }); + }); + }); + + it('can send a ping with data', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.ping('hi', () => { + ws.ping('hi', true); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pings = 0; + ws.on('ping', (message) => { + assert.strictEqual(message.toString(), 'hi'); + if (++pings === 2) wss.close(done); + }); + }); + }); + + it('can send numbers as ping payload', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.ping(0); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('ping', (message) => { + assert.strictEqual(message.toString(), '0'); + wss.close(done); + }); + }); + }); + + it('throws an error if the data size is greater than 125 bytes', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + assert.throws( + () => ws.ping(Buffer.alloc(126)), + /^RangeError: The data size must not be greater than 125 bytes$/ + ); + + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + describe('#pong', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + assert.throws( + () => ws.pong(), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + + assert.throws( + () => ws.pong(NOOP), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + }); + + it('increases `bufferedAmount` if `readyState` is 2 or 3', (done) => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws.bufferedAmount, 0); + + ws.pong('hi'); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.pong(); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.pong('hi'); + assert.strictEqual(ws.bufferedAmount, 4); + + ws.pong(); + assert.strictEqual(ws.bufferedAmount, 4); + + done(); + }); + }); + + ws.close(); + }); + + it('calls the callback w/ an error if `readyState` is 2 or 3', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.close(); + + assert.strictEqual(ws.bufferedAmount, 0); + + ws.pong('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + ws.pong((err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + wss.close(done); + }); + }); + }); + }); + }); + + it('can send a pong with no data', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.pong(() => { + ws.pong(); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pongs = 0; + ws.on('pong', (data) => { + assert.ok(Buffer.isBuffer(data)); + assert.strictEqual(data.length, 0); + if (++pongs === 2) wss.close(done); + }); + }); + }); + + it('can send a pong with data', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.pong('hi', () => { + ws.pong('hi', true); + ws.close(); + }); + }); + }); + + wss.on('connection', (ws) => { + let pongs = 0; + ws.on('pong', (message) => { + assert.strictEqual(message.toString(), 'hi'); + if (++pongs === 2) wss.close(done); + }); + }); + }); + + it('can send numbers as pong payload', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.pong(0); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('pong', (message) => { + assert.strictEqual(message.toString(), '0'); + wss.close(done); + }); + }); + }); + + it('throws an error if the data size is greater than 125 bytes', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + assert.throws( + () => ws.pong(Buffer.alloc(126)), + /^RangeError: The data size must not be greater than 125 bytes$/ + ); + + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + }); + + describe('#resume', () => { + it('does nothing if `readyState` is `CONNECTING` or `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + assert.strictEqual(ws.readyState, WebSocket.CONNECTING); + assert.ok(!ws.isPaused); + + // Verify that no exception is thrown. + ws.resume(); + + ws.on('open', () => { + ws.pause(); + assert.ok(ws.isPaused); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.resume(); + assert.ok(ws.isPaused); + + wss.close(done); + }); + + ws.terminate(); + }); + }); + }); + + it('resumes the socket', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.pause(); + assert.ok(ws.isPaused); + assert.ok(ws._socket.isPaused()); + + ws.resume(); + assert.ok(!ws.isPaused); + assert.ok(!ws._socket.isPaused()); + + ws.close(); + wss.close(done); + }); + }); + }); + + describe('#send', () => { + it('throws an error if `readyState` is `CONNECTING`', () => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + assert.throws( + () => ws.send('hi'), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + + assert.throws( + () => ws.send('hi', NOOP), + /^Error: WebSocket is not open: readyState 0 \(CONNECTING\)$/ + ); + }); + + it('increases `bufferedAmount` if `readyState` is 2 or 3', (done) => { + const ws = new WebSocket('ws://localhost', { + lookup() {} + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.strictEqual(ws.bufferedAmount, 0); + + ws.send('hi'); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.send(); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + + ws.send('hi'); + assert.strictEqual(ws.bufferedAmount, 4); + + ws.send(); + assert.strictEqual(ws.bufferedAmount, 4); + + done(); + }); + }); + + ws.close(); + }); + + it('calls the callback w/ an error if `readyState` is 2 or 3', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + ws.close(); + + assert.strictEqual(ws.bufferedAmount, 0); + + ws.send('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 2 (CLOSING)' + ); + assert.strictEqual(ws.bufferedAmount, 2); + + ws.on('close', () => { + ws.send('hi', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket is not open: readyState 3 (CLOSED)' + ); + assert.strictEqual(ws.bufferedAmount, 4); + + wss.close(done); + }); + }); + }); + }); + }); + + it('can send a big binary message', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const array = new Float32Array(5 * 1024 * 1024); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 5; + } + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.send(array)); + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + ws.close(); + }); + }); + }); + + it('can send text data', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.send('hi')); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + ws.close(); + }); + }); + }); + + it('does not override the `fin` option', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send('fragment', { fin: false }); + ws.send('fragment', { fin: true }); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from('fragmentfragment')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + }); + + it('sends numbers as strings', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(0); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.deepStrictEqual(msg, Buffer.from('0')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + }); + + it('can send a `TypedArray`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const array = new Float32Array(6); + + for (let i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + const partial = array.subarray(2, 5); + const buf = Buffer.from( + partial.buffer, + partial.byteOffset, + partial.byteLength + ); + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(partial); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, buf); + assert.ok(isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + it('can send an `ArrayBuffer`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(array.buffer); + ws.close(); + }); + + ws.onmessage = (event) => { + assert.ok(event.data.equals(Buffer.from(array.buffer))); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + it('can send a `Buffer`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const buf = Buffer.from('foobar'); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(buf); + ws.close(); + }); + + ws.onmessage = (event) => { + assert.deepStrictEqual(event.data, buf); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + + it('calls the callback when data is written out', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send('hi', (err) => { + assert.ifError(err); + wss.close(done); + }); + }); + }); + + wss.on('connection', (ws) => { + ws.close(); + }); + }); + + it('works when the `data` argument is falsy', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws.send(); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.strictEqual(message, EMPTY_BUFFER); + assert.ok(isBinary); + wss.close(done); + }); + }); + }); + + it('honors the `mask` option', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.send('hi', { mask: false })); + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1002); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + const chunks = []; + + ws._socket.prependListener('data', (chunk) => { + chunks.push(chunk); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: MASK must be set' + ); + assert.ok( + Buffer.concat(chunks).slice(0, 2).equals(Buffer.from('8102', 'hex')) + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + }); + + describe('#close', () => { + it('closes the connection if called while connecting (1/3)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => wss.close(done)); + }); + ws.close(1001); + }); + }); + + it('closes the connection if called while connecting (2/3)', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => setTimeout(cb, 300, true), + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => wss.close(done)); + }); + setTimeout(() => ws.close(1001), 150); + } + ); + }); + + it('closes the connection if called while connecting (3/3)', (done) => { + const server = http.createServer(); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => { + server.close(done); + }); + }); + + ws.on('unexpected-response', (req, res) => { + assert.strictEqual(res.statusCode, 502); + + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(chunk); + }); + + res.on('end', () => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'foo'); + ws.close(); + }); + }); + }); + + server.on('upgrade', (req, socket) => { + socket.on('end', socket.end); + + socket.write( + `HTTP/1.1 502 ${http.STATUS_CODES[502]}\r\n` + + 'Connection: keep-alive\r\n' + + 'Content-type: text/html\r\n' + + 'Content-Length: 3\r\n' + + '\r\n' + + 'foo' + ); + }); + }); + + it('can be called from an error listener while connecting', (done) => { + const ws = new WebSocket('ws://localhost:1337'); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ECONNREFUSED'); + ws.close(); + ws.on('close', () => done()); + }); + }).timeout(4000); + + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + done(new Error("Unexpected 'open' event")); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.close(); + }); + }); + }); + + it("can be called from a listener of the 'upgrade' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => wss.close(done)); + }); + ws.on('upgrade', () => ws.close()); + }); + }); + + it('sends the close status code only when necessary', (done) => { + let sent; + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.once('data', (data) => { + sent = data; + }); + }); + }); + + wss.on('connection', (ws) => { + ws._socket.once('data', (received) => { + assert.deepStrictEqual( + received.slice(0, 2), + Buffer.from([0x88, 0x80]) + ); + assert.deepStrictEqual(sent, Buffer.from([0x88, 0x00])); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + ws.close(); + }); + }); + + it('works when close reason is not specified', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.close(1000)); + }); + + wss.on('connection', (ws) => { + ws.on('close', (code, message) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(message, EMPTY_BUFFER); + wss.close(done); + }); + }); + }); + + it('works when close reason is specified', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => ws.close(1000, 'some reason')); + }); + + wss.on('connection', (ws) => { + ws.on('close', (code, message) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(message, Buffer.from('some reason')); + wss.close(done); + }); + }); + }); + + it('permits all buffered data to be delivered', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz']); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const callback = (err) => assert.ifError(err); + + ws.send('foo', callback); + ws.send('bar', callback); + ws.send('baz', callback); + ws.close(); + ws.close(); + }); + }); + + it('allows close code 1013', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1013); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(1013)); + }); + + it('allows close code 1014', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1014); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(1014)); + }); + + it('does nothing if `readyState` is `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + ws.close(); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close()); + }); + + it('sets a timer for the closing handshake to complete', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1000); + assert.deepStrictEqual(reason, Buffer.from('some reason')); + wss.close(done); + }); + + ws.on('open', () => { + let callbackCalled = false; + + assert.strictEqual(ws._closeTimer, null); + + ws.send('foo', () => { + callbackCalled = true; + }); + + ws.close(1000, 'some reason'); + + // + // Check that the close timer is set even if the `Sender.close()` + // callback is not called. + // + assert.strictEqual(callbackCalled, false); + assert.strictEqual(ws._closeTimer._idleTimeout, 30000); + }); + }); + }); + }); + + describe('#terminate', () => { + it('closes the connection if called while connecting (1/2)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => wss.close(done)); + }); + ws.terminate(); + }); + }); + + it('closes the connection if called while connecting (2/2)', (done) => { + const wss = new WebSocket.Server( + { + verifyClient: (info, cb) => setTimeout(cb, 300, true), + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => wss.close(done)); + }); + setTimeout(() => ws.terminate(), 150); + } + ); + }); + + it('can be called from an error listener while connecting', (done) => { + const ws = new WebSocket('ws://localhost:1337'); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ECONNREFUSED'); + ws.terminate(); + ws.on('close', () => done()); + }); + }).timeout(4000); + + it("can be called from a listener of the 'redirect' event", (done) => { + const server = http.createServer(); + + server.once('upgrade', (req, socket) => { + socket.end('HTTP/1.1 302 Found\r\nLocation: /foo\r\n\r\n'); + }); + + server.listen(() => { + const port = server.address().port; + const ws = new WebSocket(`ws://localhost:${port}`, { + followRedirects: true + }); + + ws.on('open', () => { + done(new Error("Unexpected 'open' event")); + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + server.close(done); + }); + }); + + ws.on('redirect', () => { + ws.terminate(); + }); + }); + }); + + it("can be called from a listener of the 'upgrade' event", (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => done(new Error("Unexpected 'open' event"))); + ws.on('error', (err) => { + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'WebSocket was closed before the connection was established' + ); + ws.on('close', () => wss.close(done)); + }); + ws.on('upgrade', () => ws.terminate()); + }); + }); + + it('does nothing if `readyState` is `CLOSED`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.strictEqual(ws.readyState, WebSocket.CLOSED); + ws.terminate(); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.terminate()); + }); + }); + + describe('WHATWG API emulation', () => { + it('supports the `on{close,error,message,open}` attributes', () => { + for (const property of ['onclose', 'onerror', 'onmessage', 'onopen']) { + const descriptor = Object.getOwnPropertyDescriptor( + WebSocket.prototype, + property + ); + + assert.strictEqual(descriptor.configurable, true); + assert.strictEqual(descriptor.enumerable, true); + assert.ok(descriptor.get !== undefined); + assert.ok(descriptor.set !== undefined); + } + + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + assert.strictEqual(ws.onmessage, null); + assert.strictEqual(ws.onclose, null); + assert.strictEqual(ws.onerror, null); + assert.strictEqual(ws.onopen, null); + + ws.onmessage = NOOP; + ws.onerror = NOOP; + ws.onclose = NOOP; + ws.onopen = NOOP; + + assert.strictEqual(ws.onmessage, NOOP); + assert.strictEqual(ws.onclose, NOOP); + assert.strictEqual(ws.onerror, NOOP); + assert.strictEqual(ws.onopen, NOOP); + + ws.onmessage = 'foo'; + + assert.strictEqual(ws.onmessage, null); + assert.strictEqual(ws.listenerCount('message'), 0); + }); + + it('works like the `EventEmitter` interface', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.onmessage = (messageEvent) => { + assert.strictEqual(messageEvent.data, 'foo'); + ws.onclose = (closeEvent) => { + assert.strictEqual(closeEvent.wasClean, true); + assert.strictEqual(closeEvent.code, 1005); + assert.strictEqual(closeEvent.reason, ''); + wss.close(done); + }; + ws.close(); + }; + + ws.onopen = () => ws.send('foo'); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); + }); + }); + + it("doesn't return listeners added with `on`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.on('open', NOOP); + + assert.deepStrictEqual(ws.listeners('open'), [NOOP]); + assert.strictEqual(ws.onopen, null); + }); + + it("doesn't remove listeners added with `on`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.on('close', NOOP); + ws.onclose = NOOP; + + let listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + + ws.onclose = NOOP; + + listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + }); + + it('supports the `addEventListener` method', () => { + const events = []; + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('foo', () => {}); + assert.strictEqual(ws.listenerCount('foo'), 0); + + ws.addEventListener('open', () => { + events.push('open'); + assert.strictEqual(ws.listenerCount('open'), 1); + }); + + assert.strictEqual(ws.listenerCount('open'), 1); + + ws.addEventListener( + 'message', + () => { + events.push('message'); + assert.strictEqual(ws.listenerCount('message'), 0); + }, + { once: true } + ); + + assert.strictEqual(ws.listenerCount('message'), 1); + + ws.emit('open'); + ws.emit('message', EMPTY_BUFFER, false); + + assert.deepStrictEqual(events, ['open', 'message']); + }); + + it("doesn't return listeners added with `addEventListener`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('open', NOOP); + + const listeners = ws.listeners('open'); + + assert.strictEqual(listeners.length, 1); + assert.strictEqual(listeners[0][kListener], NOOP); + + assert.strictEqual(ws.onopen, null); + }); + + it("doesn't remove listeners added with `addEventListener`", () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('close', NOOP); + ws.onclose = NOOP; + + let listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + + ws.onclose = NOOP; + + listeners = ws.listeners('close'); + + assert.strictEqual(listeners.length, 2); + assert.strictEqual(listeners[0][kListener], NOOP); + assert.strictEqual(listeners[1][kListener], NOOP); + }); + + it('supports the `removeEventListener` method', () => { + const ws = new WebSocket('ws://localhost', { agent: new CustomAgent() }); + + ws.addEventListener('message', NOOP); + ws.addEventListener('open', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('open')[0][kListener], NOOP); + + ws.removeEventListener('message', () => {}); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + ws.removeEventListener('open', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + assert.strictEqual(ws.listenerCount('open'), 0); + + ws.addEventListener('message', NOOP, { once: true }); + ws.addEventListener('open', NOOP, { once: true }); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('open')[0][kListener], NOOP); + + ws.removeEventListener('message', () => {}); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + ws.removeEventListener('open', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + assert.strictEqual(ws.listenerCount('open'), 0); + + // Multiple listeners. + ws.addEventListener('message', NOOP); + ws.addEventListener('message', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + assert.strictEqual(ws.listeners('message')[1][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + + assert.strictEqual(ws.listeners('message')[0][kListener], NOOP); + + ws.removeEventListener('message', NOOP); + + assert.strictEqual(ws.listenerCount('message'), 0); + + // Listeners not added with `websocket.addEventListener()`. + ws.on('message', NOOP); + + assert.deepStrictEqual(ws.listeners('message'), [NOOP]); + + ws.removeEventListener('message', NOOP); + + assert.deepStrictEqual(ws.listeners('message'), [NOOP]); + + ws.onclose = NOOP; + + assert.strictEqual(ws.listeners('close')[0][kListener], NOOP); + + ws.removeEventListener('close', NOOP); + + assert.strictEqual(ws.listeners('close')[0][kListener], NOOP); + }); + + it('wraps text data in a `MessageEvent`', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.addEventListener('open', () => { + ws.send('hi'); + ws.close(); + }); + + ws.addEventListener('message', (event) => { + assert.ok(event instanceof MessageEvent); + assert.strictEqual(event.data, 'hi'); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + ws.send(msg, { binary: isBinary }); + }); + }); + }); + + it('receives a `CloseEvent` when server closes (1000)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.addEventListener('close', (event) => { + assert.ok(event instanceof CloseEvent); + assert.ok(event.wasClean); + assert.strictEqual(event.reason, ''); + assert.strictEqual(event.code, 1000); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(1000)); + }); + + it('receives a `CloseEvent` when server closes (4000)', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.addEventListener('close', (event) => { + assert.ok(event instanceof CloseEvent); + assert.ok(event.wasClean); + assert.strictEqual(event.reason, 'some daft reason'); + assert.strictEqual(event.code, 4000); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => ws.close(4000, 'some daft reason')); + }); + + it('sets `target` and `type` on events', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const err = new Error('forced'); + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.addEventListener('open', (event) => { + assert.ok(event instanceof Event); + assert.strictEqual(event.type, 'open'); + assert.strictEqual(event.target, ws); + }); + ws.addEventListener('message', (event) => { + assert.ok(event instanceof MessageEvent); + assert.strictEqual(event.type, 'message'); + assert.strictEqual(event.target, ws); + ws.close(); + }); + ws.addEventListener('close', (event) => { + assert.ok(event instanceof CloseEvent); + assert.strictEqual(event.type, 'close'); + assert.strictEqual(event.target, ws); + ws.emit('error', err); + }); + ws.addEventListener('error', (event) => { + assert.ok(event instanceof ErrorEvent); + assert.strictEqual(event.message, 'forced'); + assert.strictEqual(event.type, 'error'); + assert.strictEqual(event.target, ws); + assert.strictEqual(event.error, err); + + wss.close(done); + }); + }); + + wss.on('connection', (client) => client.send('hi')); + }); + + it('passes binary data as a Node.js `Buffer` by default', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.onmessage = (evt) => { + assert.ok(Buffer.isBuffer(evt.data)); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.send(new Uint8Array(4096)); + ws.close(); + }); + }); + + it('ignores `binaryType` for text messages', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.binaryType = 'arraybuffer'; + + ws.onmessage = (evt) => { + assert.strictEqual(evt.data, 'foo'); + wss.close(done); + }; + }); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.close(); + }); + }); + + it('allows to update `binaryType` on the fly', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + function testType(binaryType, next) { + const buf = Buffer.from(binaryType); + ws.binaryType = binaryType; + + ws.onmessage = (evt) => { + if (binaryType === 'nodebuffer') { + assert.ok(Buffer.isBuffer(evt.data)); + assert.ok(evt.data.equals(buf)); + } else if (binaryType === 'arraybuffer') { + assert.ok(evt.data instanceof ArrayBuffer); + assert.ok(Buffer.from(evt.data).equals(buf)); + } else if (binaryType === 'fragments') { + assert.deepStrictEqual(evt.data, [buf]); + } + next(); + }; + + ws.send(buf); + } + + ws.onopen = () => { + testType('nodebuffer', () => { + testType('arraybuffer', () => { + testType('fragments', () => { + ws.close(); + wss.close(done); + }); + }); + }); + }; + }); + + wss.on('connection', (ws) => { + ws.on('message', (msg, isBinary) => { + assert.ok(isBinary); + ws.send(msg); + }); + }); + }); + }); + + describe('SSL', () => { + it('connects to secure websocket server', (done) => { + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const wss = new WebSocket.Server({ server }); + + wss.on('connection', () => { + server.close(done); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://127.0.0.1:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', ws.close); + }); + }); + + it('connects to secure websocket server with client side certificate', (done) => { + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + ca: [fs.readFileSync('test/fixtures/ca-certificate.pem')], + key: fs.readFileSync('test/fixtures/key.pem'), + requestCert: true + }); + + const wss = new WebSocket.Server({ noServer: true }); + + server.on('upgrade', (request, socket, head) => { + assert.ok(socket.authorized); + + wss.handleUpgrade(request, socket, head, (ws) => { + ws.on('close', (code) => { + assert.strictEqual(code, 1005); + server.close(done); + }); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + cert: fs.readFileSync('test/fixtures/client-certificate.pem'), + key: fs.readFileSync('test/fixtures/client-key.pem'), + rejectUnauthorized: false + }); + + ws.on('open', ws.close); + }); + }); + + it('cannot connect to secure websocket server via ws://', (done) => { + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + const ws = new WebSocket(`ws://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('error', () => { + server.close(done); + wss.close(); + }); + }); + }); + + it('can send and receive text data', (done) => { + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('foobar')); + assert.ok(!isBinary); + server.close(done); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', () => { + ws.send('foobar'); + ws.close(); + }); + }); + }); + + it('can send a big binary message', (done) => { + const buf = crypto.randomBytes(5 * 1024 * 1024); + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem') + }); + const wss = new WebSocket.Server({ server }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message); + ws.close(); + }); + }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + rejectUnauthorized: false + }); + + ws.on('open', () => ws.send(buf)); + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, buf); + assert.ok(isBinary); + + server.close(done); + }); + }); + }).timeout(4000); + + it('allows to disable sending the SNI extension', (done) => { + const original = tls.connect; + + tls.connect = (options) => { + assert.strictEqual(options.servername, ''); + tls.connect = original; + done(); + }; + + const ws = new WebSocket('wss://127.0.0.1', { servername: '' }); + }); + + it("works around a double 'error' event bug in Node.js", function (done) { + // + // The `minVersion` and `maxVersion` options are not supported in + // Node.js < 10.16.0. + // + if (process.versions.modules < 64) return this.skip(); + + // + // The `'error'` event can be emitted multiple times by the + // `http.ClientRequest` object in Node.js < 13. This test reproduces the + // issue in Node.js 12. + // + const server = https.createServer({ + cert: fs.readFileSync('test/fixtures/certificate.pem'), + key: fs.readFileSync('test/fixtures/key.pem'), + minVersion: 'TLSv1.2' + }); + const wss = new WebSocket.Server({ server }); + + server.listen(0, () => { + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { + maxVersion: 'TLSv1.1', + rejectUnauthorized: false + }); + + ws.on('error', (err) => { + assert.ok(err instanceof Error); + server.close(done); + wss.close(); + }); + }); + }); + }); + + describe('Request headers', () => { + it('adds the authorization header if the url has userinfo', (done) => { + const agent = new CustomAgent(); + const userinfo = 'test:testpass'; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('authorization'), + `Basic ${Buffer.from(userinfo).toString('base64')}` + ); + done(); + }; + + const ws = new WebSocket(`ws://${userinfo}@localhost`, { agent }); + }); + + it('honors the `auth` option', (done) => { + const agent = new CustomAgent(); + const auth = 'user:pass'; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('authorization'), + `Basic ${Buffer.from(auth).toString('base64')}` + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { agent, auth }); + }); + + it('favors the url userinfo over the `auth` option', (done) => { + const agent = new CustomAgent(); + const auth = 'foo:bar'; + const userinfo = 'baz:qux'; + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('authorization'), + `Basic ${Buffer.from(userinfo).toString('base64')}` + ); + done(); + }; + + const ws = new WebSocket(`ws://${userinfo}@localhost`, { agent, auth }); + }); + + it('adds custom headers', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('cookie'), 'foo=bar'); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + headers: { Cookie: 'foo=bar' }, + agent + }); + }); + + it('excludes default ports from host header', () => { + const options = { lookup() {} }; + const variants = [ + ['wss://localhost:8443', 'localhost:8443'], + ['wss://localhost:443', 'localhost'], + ['ws://localhost:88', 'localhost:88'], + ['ws://localhost:80', 'localhost'] + ]; + + for (const [url, host] of variants) { + const ws = new WebSocket(url, options); + assert.strictEqual(ws._req.getHeader('host'), host); + } + }); + + it("doesn't add the origin header by default", (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('origin'), undefined); + done(); + }; + + const ws = new WebSocket('ws://localhost', { agent }); + }); + + it('honors the `origin` option (1/2)', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('origin'), 'https://example.com:8000'); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + origin: 'https://example.com:8000', + agent + }); + }); + + it('honors the `origin` option (2/2)', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-origin'), + 'https://example.com:8000' + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + origin: 'https://example.com:8000', + protocolVersion: 8, + agent + }); + }); + }); + + describe('permessage-deflate', () => { + it('is enabled by default', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-extensions'), + 'permessage-deflate; client_max_window_bits' + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { agent }); + }); + + it('can be disabled', (done) => { + const agent = new CustomAgent(); + + agent.addRequest = (req) => { + assert.strictEqual( + req.getHeader('sec-websocket-extensions'), + undefined + ); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + perMessageDeflate: false, + agent + }); + }); + + it('can send extension parameters', (done) => { + const agent = new CustomAgent(); + + const value = + 'permessage-deflate; server_no_context_takeover;' + + ' client_no_context_takeover; server_max_window_bits=10;' + + ' client_max_window_bits'; + + agent.addRequest = (req) => { + assert.strictEqual(req.getHeader('sec-websocket-extensions'), value); + done(); + }; + + const ws = new WebSocket('ws://localhost', { + perMessageDeflate: { + clientNoContextTakeover: true, + serverNoContextTakeover: true, + clientMaxWindowBits: true, + serverMaxWindowBits: 10 + }, + agent + }); + }); + + it('consumes all received data when connection is closed (1/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const messages = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.on('close', () => { + assert.strictEqual(ws._receiver._state, 5); + }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux', () => ws._socket.end()); + }); + }); + + it('consumes all received data when connection is closed (2/2)', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const messageLengths = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.prependListener('close', () => { + assert.strictEqual(ws._receiver._state, 5); + assert.strictEqual(ws._socket._readableState.length, 3); + }); + + const push = ws._socket.push; + + // Override `ws._socket.push()` to know exactly when data is + // received and call `ws.terminate()` immediately after that without + // relying on a timer. + ws._socket.push = (data) => { + ws._socket.push = push; + ws._socket.push(data); + ws.terminate(); + }; + + const payload1 = Buffer.alloc(15 * 1024); + const payload2 = Buffer.alloc(1); + + const opts = { + fin: true, + opcode: 0x02, + mask: false, + readOnly: false + }; + + const list = [ + ...Sender.frame(payload1, { rsv1: false, ...opts }), + ...Sender.frame(payload2, { rsv1: true, ...opts }) + ]; + + for (let i = 0; i < 399; i++) { + list.push(list[list.length - 2], list[list.length - 1]); + } + + // This hack is used because there is no guarantee that more than + // 16 KiB will be sent as a single TCP packet. + push.call(ws._socket, Buffer.concat(list)); + + wss.clients + .values() + .next() + .value.send(payload2, { compress: false }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + messageLengths.push(message.length); + }); + + ws.on('close', (code) => { + assert.strictEqual(code, 1006); + assert.strictEqual(messageLengths.length, 402); + assert.strictEqual(messageLengths[0], 15360); + assert.strictEqual(messageLengths[messageLengths.length - 1], 1); + wss.close(done); + }); + } + ); + }); + + it('handles a close frame received while compressing data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws._receiver.on('conclude', () => { + assert.ok(ws._sender._deflating); + }); + + ws.send('foo'); + ws.send('bar'); + ws.send('baz'); + ws.send('qux'); + }); + } + ); + + wss.on('connection', (ws) => { + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + messages.push(message.toString()); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['foo', 'bar', 'baz', 'qux']); + assert.strictEqual(code, 1000); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws.close(1000); + }); + }); + + describe('#close', () => { + it('can be used while data is being decompressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const messages = []; + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('open', () => { + ws._socket.on('end', () => { + assert.strictEqual(ws._receiver._state, 5); + }); + }); + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; + + ws.close(1000); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['', '', '', '']); + assert.strictEqual(code, 1000); + assert.deepStrictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100c10100c10100c10100', 'hex'); + ws._socket.write(buf); + }); + }); + }); + + describe('#send', () => { + it('can send text data', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); + }); + }); + + it('can send a `TypedArray`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; + } + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send(array, { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); + }); + }); + + it('can send an `ArrayBuffer`', (done) => { + const array = new Float32Array(5); + + for (let i = 0; i < array.length; i++) { + array[i] = i / 2; + } + + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send(array.buffer, { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from(array.buffer)); + assert.ok(isBinary); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + assert.ok(isBinary); + ws.send(message, { compress: true }); + }); + }); + }); + + it('ignores the `compress` option if the extension is disabled', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: false + }); + + ws.on('open', () => { + ws.send('hi', { compress: true }); + ws.close(); + }); + + ws.on('message', (message, isBinary) => { + assert.deepStrictEqual(message, Buffer.from('hi')); + assert.ok(!isBinary); + wss.close(done); + }); + }); + + wss.on('connection', (ws) => { + ws.on('message', (message, isBinary) => { + ws.send(message, { binary: isBinary, compress: true }); + }); + }); + }); + + it('calls the callback if the socket is closed prematurely', (done) => { + const called = []; + const wss = new WebSocket.Server( + { perMessageDeflate: true, port: 0 }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('foo'); + ws.send('bar', (err) => { + called.push(1); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while data was being compressed' + ); + }); + ws.send('baz'); + ws.send('qux', (err) => { + called.push(2); + + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while data was being compressed' + ); + }); + }); + } + ); + + wss.on('connection', (ws) => { + ws.on('close', () => { + assert.deepStrictEqual(called, [1, 2]); + wss.close(done); + }); + + ws._socket.end(); + }); + }); + }); + + describe('#terminate', () => { + it('can be used while data is being compressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: { threshold: 0 }, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`, { + perMessageDeflate: { threshold: 0 } + }); + + ws.on('open', () => { + ws.send('hi', (err) => { + assert.strictEqual(ws.readyState, WebSocket.CLOSING); + assert.ok(err instanceof Error); + assert.strictEqual( + err.message, + 'The socket was closed while data was being compressed' + ); + + ws.on('close', () => { + wss.close(done); + }); + }); + ws.terminate(); + }); + } + ); + }); + + it('can be used while data is being decompressed', (done) => { + const wss = new WebSocket.Server( + { + perMessageDeflate: true, + port: 0 + }, + () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + const messages = []; + + ws.on('message', (message, isBinary) => { + assert.ok(!isBinary); + + if (messages.push(message.toString()) > 1) return; + + process.nextTick(() => { + assert.strictEqual(ws._receiver._state, 5); + ws.terminate(); + }); + }); + + ws.on('close', (code, reason) => { + assert.deepStrictEqual(messages, ['', '', '', '']); + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + } + ); + + wss.on('connection', (ws) => { + const buf = Buffer.from('c10100c10100c10100c10100', 'hex'); + ws._socket.write(buf); + }); + }); + }); + }); + + describe('Connection close', () => { + it('closes cleanly after simultaneous errors (1/2)', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + ws.on('open', () => { + // Write an invalid frame in both directions to trigger simultaneous + // failure. + const chunk = Buffer.from([0x85, 0x00]); + + wss.clients.values().next().value._socket.write(chunk); + ws._socket.write(chunk); + }); + }); + + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + + it('closes cleanly after simultaneous errors (2/2)', (done) => { + let clientCloseEventEmitted = false; + let serverClientCloseEventEmitted = false; + + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + clientCloseEventEmitted = true; + if (serverClientCloseEventEmitted) wss.close(done); + }); + }); + + ws.on('open', () => { + // Write an invalid frame in both directions and change the + // `readyState` to `WebSocket.CLOSING`. + const chunk = Buffer.from([0x85, 0x00]); + const serverWs = wss.clients.values().next().value; + + serverWs._socket.write(chunk); + serverWs.close(); + + ws._socket.write(chunk); + ws.close(); + }); + }); + + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_INVALID_OPCODE'); + assert.strictEqual( + err.message, + 'Invalid WebSocket frame: invalid opcode 5' + ); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + + serverClientCloseEventEmitted = true; + if (clientCloseEventEmitted) wss.close(done); + }); + }); + }); + }); + + it('resumes the socket when an error occurs', (done) => { + const maxPayload = 16 * 1024; + const wss = new WebSocket.Server({ maxPayload, port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const list = [ + ...Sender.frame(Buffer.alloc(maxPayload + 1), { + fin: true, + opcode: 0x02, + mask: true, + readOnly: false + }) + ]; + + ws.on('error', (err) => { + assert.ok(err instanceof RangeError); + assert.strictEqual(err.code, 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'); + assert.strictEqual(err.message, 'Max payload size exceeded'); + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1006); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + + it('resumes the socket when the close frame is received', (done) => { + const wss = new WebSocket.Server({ port: 0 }, () => { + const ws = new WebSocket(`ws://localhost:${wss.address().port}`); + }); + + wss.on('connection', (ws) => { + const opts = { fin: true, mask: true, readOnly: false }; + const list = [ + ...Sender.frame(Buffer.alloc(16 * 1024), { opcode: 0x02, ...opts }), + ...Sender.frame(EMPTY_BUFFER, { opcode: 0x08, ...opts }) + ]; + + ws.on('close', (code, reason) => { + assert.strictEqual(code, 1005); + assert.strictEqual(reason, EMPTY_BUFFER); + wss.close(done); + }); + + ws._socket.push(Buffer.concat(list)); + }); + }); + }); +}); diff --git a/testing/xpcshell/node-ws/wrapper.mjs b/testing/xpcshell/node-ws/wrapper.mjs new file mode 100644 index 0000000000..7245ad15d0 --- /dev/null +++ b/testing/xpcshell/node-ws/wrapper.mjs @@ -0,0 +1,8 @@ +import createWebSocketStream from './lib/stream.js'; +import Receiver from './lib/receiver.js'; +import Sender from './lib/sender.js'; +import WebSocket from './lib/websocket.js'; +import WebSocketServer from './lib/websocket-server.js'; + +export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer }; +export default WebSocket; diff --git a/testing/xpcshell/node_ip/.gitignore b/testing/xpcshell/node_ip/.gitignore new file mode 100644 index 0000000000..1ca957177f --- /dev/null +++ b/testing/xpcshell/node_ip/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +npm-debug.log diff --git a/testing/xpcshell/node_ip/.jscsrc b/testing/xpcshell/node_ip/.jscsrc new file mode 100644 index 0000000000..dbaae20574 --- /dev/null +++ b/testing/xpcshell/node_ip/.jscsrc @@ -0,0 +1,46 @@ +{ + "disallowKeywordsOnNewLine": [ "else" ], + "disallowMixedSpacesAndTabs": true, + "disallowMultipleLineStrings": true, + "disallowMultipleVarDecl": true, + "disallowNewlineBeforeBlockStatements": true, + "disallowQuotedKeysInObjects": true, + "disallowSpaceAfterObjectKeys": true, + "disallowSpaceAfterPrefixUnaryOperators": true, + "disallowSpaceBeforePostfixUnaryOperators": true, + "disallowSpacesInCallExpression": true, + "disallowTrailingComma": true, + "disallowTrailingWhitespace": true, + "disallowYodaConditions": true, + + "requireCommaBeforeLineBreak": true, + "requireOperatorBeforeLineBreak": true, + "requireSpaceAfterBinaryOperators": true, + "requireSpaceAfterKeywords": [ "if", "for", "while", "else", "try", "catch" ], + "requireSpaceAfterLineComment": true, + "requireSpaceBeforeBinaryOperators": true, + "requireSpaceBeforeBlockStatements": true, + "requireSpaceBeforeKeywords": [ "else", "catch" ], + "requireSpaceBeforeObjectValues": true, + "requireSpaceBetweenArguments": true, + "requireSpacesInAnonymousFunctionExpression": { + "beforeOpeningCurlyBrace": true + }, + "requireSpacesInFunctionDeclaration": { + "beforeOpeningCurlyBrace": true + }, + "requireSpacesInFunctionExpression": { + "beforeOpeningCurlyBrace": true + }, + "requireSpacesInConditionalExpression": true, + "requireSpacesInForStatement": true, + "requireSpacesInsideArrayBrackets": "all", + "requireSpacesInsideObjectBrackets": "all", + "requireDotNotation": true, + + "maximumLineLength": 80, + "validateIndentation": 2, + "validateLineBreaks": "LF", + "validateParameterSeparator": ", ", + "validateQuoteMarks": "'" +} diff --git a/testing/xpcshell/node_ip/.jshintrc b/testing/xpcshell/node_ip/.jshintrc new file mode 100644 index 0000000000..7e97390295 --- /dev/null +++ b/testing/xpcshell/node_ip/.jshintrc @@ -0,0 +1,89 @@ +{ + // JSHint Default Configuration File (as on JSHint website) + // See http://jshint.com/docs/ for more details + + "maxerr" : 50, // {int} Maximum error before stopping + + // Enforcing + "bitwise" : false, // true: Prohibit bitwise operators (&, |, ^, etc.) + "camelcase" : false, // true: Identifiers must be in camelCase + "curly" : false, // true: Require {} for every new block or scope + "eqeqeq" : true, // true: Require triple equals (===) for comparison + "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() + "freeze" : true, // true: prohibits overwriting prototypes of native objects such as Array, Date etc. + "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` + "indent" : 2, // {int} Number of spaces to use for indentation + "latedef" : true, // true: Require variables/functions to be defined before being used + "newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()` + "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` + "noempty" : false, // true: Prohibit use of empty blocks + "nonbsp" : true, // true: Prohibit "non-breaking whitespace" characters. + "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) + "plusplus" : false, // true: Prohibit use of `++` & `--` + "quotmark" : "single", // Quotation mark consistency: + // false : do nothing (default) + // true : ensure whatever is used is consistent + // "single" : require single quotes + // "double" : require double quotes + "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) + "unused" : true, // true: Require all defined variables be used + "strict" : true, // true: Requires all functions run in ES5 Strict Mode + "maxparams" : false, // {int} Max number of formal params allowed per function + "maxdepth" : 3, // {int} Max depth of nested blocks (within functions) + "maxstatements" : false, // {int} Max number statements per function + "maxcomplexity" : false, // {int} Max cyclomatic complexity per function + "maxlen" : false, // {int} Max number of characters per line + + // Relaxing + "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) + "boss" : false, // true: Tolerate assignments where comparisons would be expected + "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. + "eqnull" : false, // true: Tolerate use of `== null` + "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) + "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) + "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) + // (ex: `for each`, multiple try/catch, function expression…) + "evil" : false, // true: Tolerate use of `eval` and `new Function()` + "expr" : false, // true: Tolerate `ExpressionStatement` as Programs + "funcscope" : false, // true: Tolerate defining variables inside control statements + "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') + "iterator" : false, // true: Tolerate using the `__iterator__` property + "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block + "laxbreak" : false, // true: Tolerate possibly unsafe line breakings + "laxcomma" : false, // true: Tolerate comma-first style coding + "loopfunc" : false, // true: Tolerate functions being defined in loops + "multistr" : false, // true: Tolerate multi-line strings + "noyield" : false, // true: Tolerate generator functions with no yield statement in them. + "notypeof" : false, // true: Tolerate invalid typeof operator values + "proto" : false, // true: Tolerate using the `__proto__` property + "scripturl" : false, // true: Tolerate script-targeted URLs + "shadow" : true, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` + "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation + "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` + "validthis" : false, // true: Tolerate using this in a non-constructor function + + // Environments + "browser" : true, // Web Browser (window, document, etc) + "browserify" : true, // Browserify (node.js code in the browser) + "couch" : false, // CouchDB + "devel" : true, // Development/debugging (alert, confirm, etc) + "dojo" : false, // Dojo Toolkit + "jasmine" : false, // Jasmine + "jquery" : false, // jQuery + "mocha" : true, // Mocha + "mootools" : false, // MooTools + "node" : true, // Node.js + "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) + "prototypejs" : false, // Prototype and Scriptaculous + "qunit" : false, // QUnit + "rhino" : false, // Rhino + "shelljs" : false, // ShellJS + "worker" : false, // Web Workers + "wsh" : false, // Windows Scripting Host + "yui" : false, // Yahoo User Interface + + // Custom Globals + "globals" : { + "module": true + } // additional predefined global variables +} diff --git a/testing/xpcshell/node_ip/.travis.yml b/testing/xpcshell/node_ip/.travis.yml new file mode 100644 index 0000000000..a3a8fad6b6 --- /dev/null +++ b/testing/xpcshell/node_ip/.travis.yml @@ -0,0 +1,15 @@ +sudo: false +language: node_js +node_js: + - "0.8" + - "0.10" + - "0.12" + - "4" + - "6" + +before_install: + - travis_retry npm install -g npm@2.14.5 + - travis_retry npm install + +script: + - npm test diff --git a/testing/xpcshell/node_ip/README.md b/testing/xpcshell/node_ip/README.md new file mode 100644 index 0000000000..22e5819ffa --- /dev/null +++ b/testing/xpcshell/node_ip/README.md @@ -0,0 +1,90 @@ +# IP +[![](https://badge.fury.io/js/ip.svg)](https://www.npmjs.com/package/ip) + +IP address utilities for node.js + +## Installation + +### npm +```shell +npm install ip +``` + +### git + +```shell +git clone https://github.com/indutny/node-ip.git +``` + +## Usage +Get your ip address, compare ip addresses, validate ip addresses, etc. + +```js +var ip = require('ip'); + +ip.address() // my ip address +ip.isEqual('::1', '::0:1'); // true +ip.toBuffer('127.0.0.1') // Buffer([127, 0, 0, 1]) +ip.toString(new Buffer([127, 0, 0, 1])) // 127.0.0.1 +ip.fromPrefixLen(24) // 255.255.255.0 +ip.mask('192.168.1.134', '255.255.255.0') // 192.168.1.0 +ip.cidr('192.168.1.134/26') // 192.168.1.128 +ip.not('255.255.255.0') // 0.0.0.255 +ip.or('192.168.1.134', '0.0.0.255') // 192.168.1.255 +ip.isPrivate('127.0.0.1') // true +ip.isV4Format('127.0.0.1'); // true +ip.isV6Format('::ffff:127.0.0.1'); // true + +// operate on buffers in-place +var buf = new Buffer(128); +var offset = 64; +ip.toBuffer('127.0.0.1', buf, offset); // [127, 0, 0, 1] at offset 64 +ip.toString(buf, offset, 4); // '127.0.0.1' + +// subnet information +ip.subnet('192.168.1.134', '255.255.255.192') +// { networkAddress: '192.168.1.128', +// firstAddress: '192.168.1.129', +// lastAddress: '192.168.1.190', +// broadcastAddress: '192.168.1.191', +// subnetMask: '255.255.255.192', +// subnetMaskLength: 26, +// numHosts: 62, +// length: 64, +// contains: function(addr){...} } +ip.cidrSubnet('192.168.1.134/26') +// Same as previous. + +// range checking +ip.cidrSubnet('192.168.1.134/26').contains('192.168.1.190') // true + + +// ipv4 long conversion +ip.toLong('127.0.0.1'); // 2130706433 +ip.fromLong(2130706433); // '127.0.0.1' +``` + +### License + +This software is licensed under the MIT License. + +Copyright Fedor Indutny, 2012. + +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/testing/xpcshell/node_ip/lib/ip.js b/testing/xpcshell/node_ip/lib/ip.js new file mode 100644 index 0000000000..a66a2555c6 --- /dev/null +++ b/testing/xpcshell/node_ip/lib/ip.js @@ -0,0 +1,416 @@ +'use strict'; + +var ip = exports; +var Buffer = require('buffer').Buffer; +var os = require('os'); + +ip.toBuffer = function(ip, buff, offset) { + offset = ~~offset; + + var result; + + if (this.isV4Format(ip)) { + result = buff || Buffer.alloc(offset + 4); + ip.split(/\./g).map(function(byte) { + result[offset++] = parseInt(byte, 10) & 0xff; + }); + } else if (this.isV6Format(ip)) { + var sections = ip.split(':', 8); + + var i; + for (i = 0; i < sections.length; i++) { + var isv4 = this.isV4Format(sections[i]); + var v4Buffer; + + if (isv4) { + v4Buffer = this.toBuffer(sections[i]); + sections[i] = v4Buffer.slice(0, 2).toString('hex'); + } + + if (v4Buffer && ++i < 8) { + sections.splice(i, 0, v4Buffer.slice(2, 4).toString('hex')); + } + } + + if (sections[0] === '') { + while (sections.length < 8) sections.unshift('0'); + } else if (sections[sections.length - 1] === '') { + while (sections.length < 8) sections.push('0'); + } else if (sections.length < 8) { + for (i = 0; i < sections.length && sections[i] !== ''; i++); + var argv = [ i, 1 ]; + for (i = 9 - sections.length; i > 0; i--) { + argv.push('0'); + } + sections.splice.apply(sections, argv); + } + + result = buff || Buffer.alloc(offset + 16); + for (i = 0; i < sections.length; i++) { + var word = parseInt(sections[i], 16); + result[offset++] = (word >> 8) & 0xff; + result[offset++] = word & 0xff; + } + } + + if (!result) { + throw Error('Invalid ip address: ' + ip); + } + + return result; +}; + +ip.toString = function(buff, offset, length) { + offset = ~~offset; + length = length || (buff.length - offset); + + var result = []; + if (length === 4) { + // IPv4 + for (var i = 0; i < length; i++) { + result.push(buff[offset + i]); + } + result = result.join('.'); + } else if (length === 16) { + // IPv6 + for (var i = 0; i < length; i += 2) { + result.push(buff.readUInt16BE(offset + i).toString(16)); + } + result = result.join(':'); + result = result.replace(/(^|:)0(:0)*:0(:|$)/, '$1::$3'); + result = result.replace(/:{3,4}/, '::'); + } + + return result; +}; + +var ipv4Regex = /^(\d{1,3}\.){3,3}\d{1,3}$/; +var ipv6Regex = + /^(::)?(((\d{1,3}\.){3}(\d{1,3}){1})?([0-9a-f]){0,4}:{0,2}){1,8}(::)?$/i; + +ip.isV4Format = function(ip) { + return ipv4Regex.test(ip); +}; + +ip.isV6Format = function(ip) { + return ipv6Regex.test(ip); +}; +function _normalizeFamily(family) { + return family ? family.toLowerCase() : 'ipv4'; +} + +ip.fromPrefixLen = function(prefixlen, family) { + if (prefixlen > 32) { + family = 'ipv6'; + } else { + family = _normalizeFamily(family); + } + + var len = 4; + if (family === 'ipv6') { + len = 16; + } + var buff = Buffer.alloc(len); + + for (var i = 0, n = buff.length; i < n; ++i) { + var bits = 8; + if (prefixlen < 8) { + bits = prefixlen; + } + prefixlen -= bits; + + buff[i] = ~(0xff >> bits) & 0xff; + } + + return ip.toString(buff); +}; + +ip.mask = function(addr, mask) { + addr = ip.toBuffer(addr); + mask = ip.toBuffer(mask); + + var result = Buffer.alloc(Math.max(addr.length, mask.length)); + + var i = 0; + // Same protocol - do bitwise and + if (addr.length === mask.length) { + for (i = 0; i < addr.length; i++) { + result[i] = addr[i] & mask[i]; + } + } else if (mask.length === 4) { + // IPv6 address and IPv4 mask + // (Mask low bits) + for (i = 0; i < mask.length; i++) { + result[i] = addr[addr.length - 4 + i] & mask[i]; + } + } else { + // IPv6 mask and IPv4 addr + for (var i = 0; i < result.length - 6; i++) { + result[i] = 0; + } + + // ::ffff:ipv4 + result[10] = 0xff; + result[11] = 0xff; + for (i = 0; i < addr.length; i++) { + result[i + 12] = addr[i] & mask[i + 12]; + } + i = i + 12; + } + for (; i < result.length; i++) + result[i] = 0; + + return ip.toString(result); +}; + +ip.cidr = function(cidrString) { + var cidrParts = cidrString.split('/'); + + var addr = cidrParts[0]; + if (cidrParts.length !== 2) + throw new Error('invalid CIDR subnet: ' + addr); + + var mask = ip.fromPrefixLen(parseInt(cidrParts[1], 10)); + + return ip.mask(addr, mask); +}; + +ip.subnet = function(addr, mask) { + var networkAddress = ip.toLong(ip.mask(addr, mask)); + + // Calculate the mask's length. + var maskBuffer = ip.toBuffer(mask); + var maskLength = 0; + + for (var i = 0; i < maskBuffer.length; i++) { + if (maskBuffer[i] === 0xff) { + maskLength += 8; + } else { + var octet = maskBuffer[i] & 0xff; + while (octet) { + octet = (octet << 1) & 0xff; + maskLength++; + } + } + } + + var numberOfAddresses = Math.pow(2, 32 - maskLength); + + return { + networkAddress: ip.fromLong(networkAddress), + firstAddress: numberOfAddresses <= 2 ? + ip.fromLong(networkAddress) : + ip.fromLong(networkAddress + 1), + lastAddress: numberOfAddresses <= 2 ? + ip.fromLong(networkAddress + numberOfAddresses - 1) : + ip.fromLong(networkAddress + numberOfAddresses - 2), + broadcastAddress: ip.fromLong(networkAddress + numberOfAddresses - 1), + subnetMask: mask, + subnetMaskLength: maskLength, + numHosts: numberOfAddresses <= 2 ? + numberOfAddresses : numberOfAddresses - 2, + length: numberOfAddresses, + contains: function(other) { + return networkAddress === ip.toLong(ip.mask(other, mask)); + } + }; +}; + +ip.cidrSubnet = function(cidrString) { + var cidrParts = cidrString.split('/'); + + var addr = cidrParts[0]; + if (cidrParts.length !== 2) + throw new Error('invalid CIDR subnet: ' + addr); + + var mask = ip.fromPrefixLen(parseInt(cidrParts[1], 10)); + + return ip.subnet(addr, mask); +}; + +ip.not = function(addr) { + var buff = ip.toBuffer(addr); + for (var i = 0; i < buff.length; i++) { + buff[i] = 0xff ^ buff[i]; + } + return ip.toString(buff); +}; + +ip.or = function(a, b) { + a = ip.toBuffer(a); + b = ip.toBuffer(b); + + // same protocol + if (a.length === b.length) { + for (var i = 0; i < a.length; ++i) { + a[i] |= b[i]; + } + return ip.toString(a); + + // mixed protocols + } else { + var buff = a; + var other = b; + if (b.length > a.length) { + buff = b; + other = a; + } + + var offset = buff.length - other.length; + for (var i = offset; i < buff.length; ++i) { + buff[i] |= other[i - offset]; + } + + return ip.toString(buff); + } +}; + +ip.isEqual = function(a, b) { + a = ip.toBuffer(a); + b = ip.toBuffer(b); + + // Same protocol + if (a.length === b.length) { + for (var i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + // Swap + if (b.length === 4) { + var t = b; + b = a; + a = t; + } + + // a - IPv4, b - IPv6 + for (var i = 0; i < 10; i++) { + if (b[i] !== 0) return false; + } + + var word = b.readUInt16BE(10); + if (word !== 0 && word !== 0xffff) return false; + + for (var i = 0; i < 4; i++) { + if (a[i] !== b[i + 12]) return false; + } + + return true; +}; + +ip.isPrivate = function(addr) { + return /^(::f{4}:)?10\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i + .test(addr) || + /^(::f{4}:)?192\.168\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^(::f{4}:)?172\.(1[6-9]|2\d|30|31)\.([0-9]{1,3})\.([0-9]{1,3})$/i + .test(addr) || + /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^(::f{4}:)?169\.254\.([0-9]{1,3})\.([0-9]{1,3})$/i.test(addr) || + /^f[cd][0-9a-f]{2}:/i.test(addr) || + /^fe80:/i.test(addr) || + /^::1$/.test(addr) || + /^::$/.test(addr); +}; + +ip.isPublic = function(addr) { + return !ip.isPrivate(addr); +}; + +ip.isLoopback = function(addr) { + return /^(::f{4}:)?127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})/ + .test(addr) || + /^fe80::1$/.test(addr) || + /^::1$/.test(addr) || + /^::$/.test(addr); +}; + +ip.loopback = function(family) { + // + // Default to `ipv4` + // + family = _normalizeFamily(family); + + if (family !== 'ipv4' && family !== 'ipv6') { + throw new Error('family must be ipv4 or ipv6'); + } + + return family === 'ipv4' ? '127.0.0.1' : 'fe80::1'; +}; + +// +// ### function address (name, family) +// #### @name {string|'public'|'private'} **Optional** Name or security +// of the network interface. +// #### @family {ipv4|ipv6} **Optional** IP family of the address (defaults +// to ipv4). +// +// Returns the address for the network interface on the current system with +// the specified `name`: +// * String: First `family` address of the interface. +// If not found see `undefined`. +// * 'public': the first public ip address of family. +// * 'private': the first private ip address of family. +// * undefined: First address with `ipv4` or loopback address `127.0.0.1`. +// +ip.address = function(name, family) { + var interfaces = os.networkInterfaces(); + var all; + + // + // Default to `ipv4` + // + family = _normalizeFamily(family); + + // + // If a specific network interface has been named, + // return the address. + // + if (name && name !== 'private' && name !== 'public') { + var res = interfaces[name].filter(function(details) { + var itemFamily = details.family.toLowerCase(); + return itemFamily === family; + }); + if (res.length === 0) + return undefined; + return res[0].address; + } + + var all = Object.keys(interfaces).map(function (nic) { + // + // Note: name will only be `public` or `private` + // when this is called. + // + var addresses = interfaces[nic].filter(function (details) { + details.family = details.family.toLowerCase(); + if (details.family !== family || ip.isLoopback(details.address)) { + return false; + } else if (!name) { + return true; + } + + return name === 'public' ? ip.isPrivate(details.address) : + ip.isPublic(details.address); + }); + + return addresses.length ? addresses[0].address : undefined; + }).filter(Boolean); + + return !all.length ? ip.loopback(family) : all[0]; +}; + +ip.toLong = function(ip) { + var ipl = 0; + ip.split('.').forEach(function(octet) { + ipl <<= 8; + ipl += parseInt(octet); + }); + return(ipl >>> 0); +}; + +ip.fromLong = function(ipl) { + return ((ipl >>> 24) + '.' + + (ipl >> 16 & 255) + '.' + + (ipl >> 8 & 255) + '.' + + (ipl & 255) ); +}; diff --git a/testing/xpcshell/node_ip/package.json b/testing/xpcshell/node_ip/package.json new file mode 100644 index 0000000000..c783fdd437 --- /dev/null +++ b/testing/xpcshell/node_ip/package.json @@ -0,0 +1,21 @@ +{ + "name": "ip", + "version": "1.1.5", + "author": "Fedor Indutny ", + "homepage": "https://github.com/indutny/node-ip", + "repository": { + "type": "git", + "url": "http://github.com/indutny/node-ip.git" + }, + "main": "lib/ip", + "devDependencies": { + "jscs": "^2.1.1", + "jshint": "^2.8.0", + "mocha": "~1.3.2" + }, + "scripts": { + "test": "jscs lib/*.js test/*.js && jshint lib/*.js && mocha --reporter spec test/*-test.js", + "fix": "jscs lib/*.js test/*.js --fix" + }, + "license": "MIT" +} diff --git a/testing/xpcshell/node_ip/test/api-test.js b/testing/xpcshell/node_ip/test/api-test.js new file mode 100644 index 0000000000..1af09a4f6f --- /dev/null +++ b/testing/xpcshell/node_ip/test/api-test.js @@ -0,0 +1,407 @@ +'use strict'; + +var ip = require('..'); +var assert = require('assert'); +var net = require('net'); +var os = require('os'); + +describe('IP library for node.js', function() { + describe('toBuffer()/toString() methods', function() { + it('should convert to buffer IPv4 address', function() { + var buf = ip.toBuffer('127.0.0.1'); + assert.equal(buf.toString('hex'), '7f000001'); + assert.equal(ip.toString(buf), '127.0.0.1'); + }); + + it('should convert to buffer IPv4 address in-place', function() { + var buf = Buffer.alloc(128); + var offset = 64; + ip.toBuffer('127.0.0.1', buf, offset); + assert.equal(buf.toString('hex', offset, offset + 4), '7f000001'); + assert.equal(ip.toString(buf, offset, 4), '127.0.0.1'); + }); + + it('should convert to buffer IPv6 address', function() { + var buf = ip.toBuffer('::1'); + assert(/(00){15,15}01/.test(buf.toString('hex'))); + assert.equal(ip.toString(buf), '::1'); + assert.equal(ip.toString(ip.toBuffer('1::')), '1::'); + assert.equal(ip.toString(ip.toBuffer('abcd::dcba')), 'abcd::dcba'); + }); + + it('should convert to buffer IPv6 address in-place', function() { + var buf = Buffer.alloc(128); + var offset = 64; + ip.toBuffer('::1', buf, offset); + assert(/(00){15,15}01/.test(buf.toString('hex', offset, offset + 16))); + assert.equal(ip.toString(buf, offset, 16), '::1'); + assert.equal(ip.toString(ip.toBuffer('1::', buf, offset), + offset, 16), '1::'); + assert.equal(ip.toString(ip.toBuffer('abcd::dcba', buf, offset), + offset, 16), 'abcd::dcba'); + }); + + it('should convert to buffer IPv6 mapped IPv4 address', function() { + var buf = ip.toBuffer('::ffff:127.0.0.1'); + assert.equal(buf.toString('hex'), '00000000000000000000ffff7f000001'); + assert.equal(ip.toString(buf), '::ffff:7f00:1'); + + buf = ip.toBuffer('ffff::127.0.0.1'); + assert.equal(buf.toString('hex'), 'ffff000000000000000000007f000001'); + assert.equal(ip.toString(buf), 'ffff::7f00:1'); + + buf = ip.toBuffer('0:0:0:0:0:ffff:127.0.0.1'); + assert.equal(buf.toString('hex'), '00000000000000000000ffff7f000001'); + assert.equal(ip.toString(buf), '::ffff:7f00:1'); + }); + }); + + describe('fromPrefixLen() method', function() { + it('should create IPv4 mask', function() { + assert.equal(ip.fromPrefixLen(24), '255.255.255.0'); + }); + it('should create IPv6 mask', function() { + assert.equal(ip.fromPrefixLen(64), 'ffff:ffff:ffff:ffff::'); + }); + it('should create IPv6 mask explicitly', function() { + assert.equal(ip.fromPrefixLen(24, 'IPV6'), 'ffff:ff00::'); + }); + }); + + describe('not() method', function() { + it('should reverse bits in address', function() { + assert.equal(ip.not('255.255.255.0'), '0.0.0.255'); + }); + }); + + describe('or() method', function() { + it('should or bits in ipv4 addresses', function() { + assert.equal(ip.or('0.0.0.255', '192.168.1.10'), '192.168.1.255'); + }); + it('should or bits in ipv6 addresses', function() { + assert.equal(ip.or('::ff', '::abcd:dcba:abcd:dcba'), + '::abcd:dcba:abcd:dcff'); + }); + it('should or bits in mixed addresses', function() { + assert.equal(ip.or('0.0.0.255', '::abcd:dcba:abcd:dcba'), + '::abcd:dcba:abcd:dcff'); + }); + }); + + describe('mask() method', function() { + it('should mask bits in address', function() { + assert.equal(ip.mask('192.168.1.134', '255.255.255.0'), '192.168.1.0'); + assert.equal(ip.mask('192.168.1.134', '::ffff:ff00'), '::ffff:c0a8:100'); + }); + + it('should not leak data', function() { + for (var i = 0; i < 10; i++) + assert.equal(ip.mask('::1', '0.0.0.0'), '::'); + }); + }); + + describe('subnet() method', function() { + // Test cases calculated with http://www.subnet-calculator.com/ + var ipv4Subnet = ip.subnet('192.168.1.134', '255.255.255.192'); + + it('should compute ipv4 network address', function() { + assert.equal(ipv4Subnet.networkAddress, '192.168.1.128'); + }); + + it('should compute ipv4 network\'s first address', function() { + assert.equal(ipv4Subnet.firstAddress, '192.168.1.129'); + }); + + it('should compute ipv4 network\'s last address', function() { + assert.equal(ipv4Subnet.lastAddress, '192.168.1.190'); + }); + + it('should compute ipv4 broadcast address', function() { + assert.equal(ipv4Subnet.broadcastAddress, '192.168.1.191'); + }); + + it('should compute ipv4 subnet number of addresses', function() { + assert.equal(ipv4Subnet.length, 64); + }); + + it('should compute ipv4 subnet number of addressable hosts', function() { + assert.equal(ipv4Subnet.numHosts, 62); + }); + + it('should compute ipv4 subnet mask', function() { + assert.equal(ipv4Subnet.subnetMask, '255.255.255.192'); + }); + + it('should compute ipv4 subnet mask\'s length', function() { + assert.equal(ipv4Subnet.subnetMaskLength, 26); + }); + + it('should know whether a subnet contains an address', function() { + assert.equal(ipv4Subnet.contains('192.168.1.180'), true); + }); + + it('should know whether a subnet does not contain an address', function() { + assert.equal(ipv4Subnet.contains('192.168.1.195'), false); + }); + }); + + describe('subnet() method with mask length 32', function() { + // Test cases calculated with http://www.subnet-calculator.com/ + var ipv4Subnet = ip.subnet('192.168.1.134', '255.255.255.255'); + it('should compute ipv4 network\'s first address', function() { + assert.equal(ipv4Subnet.firstAddress, '192.168.1.134'); + }); + + it('should compute ipv4 network\'s last address', function() { + assert.equal(ipv4Subnet.lastAddress, '192.168.1.134'); + }); + + it('should compute ipv4 subnet number of addressable hosts', function() { + assert.equal(ipv4Subnet.numHosts, 1); + }); + }); + + describe('subnet() method with mask length 31', function() { + // Test cases calculated with http://www.subnet-calculator.com/ + var ipv4Subnet = ip.subnet('192.168.1.134', '255.255.255.254'); + it('should compute ipv4 network\'s first address', function() { + assert.equal(ipv4Subnet.firstAddress, '192.168.1.134'); + }); + + it('should compute ipv4 network\'s last address', function() { + assert.equal(ipv4Subnet.lastAddress, '192.168.1.135'); + }); + + it('should compute ipv4 subnet number of addressable hosts', function() { + assert.equal(ipv4Subnet.numHosts, 2); + }); + }); + + describe('cidrSubnet() method', function() { + // Test cases calculated with http://www.subnet-calculator.com/ + var ipv4Subnet = ip.cidrSubnet('192.168.1.134/26'); + + it('should compute an ipv4 network address', function() { + assert.equal(ipv4Subnet.networkAddress, '192.168.1.128'); + }); + + it('should compute an ipv4 network\'s first address', function() { + assert.equal(ipv4Subnet.firstAddress, '192.168.1.129'); + }); + + it('should compute an ipv4 network\'s last address', function() { + assert.equal(ipv4Subnet.lastAddress, '192.168.1.190'); + }); + + it('should compute an ipv4 broadcast address', function() { + assert.equal(ipv4Subnet.broadcastAddress, '192.168.1.191'); + }); + + it('should compute an ipv4 subnet number of addresses', function() { + assert.equal(ipv4Subnet.length, 64); + }); + + it('should compute an ipv4 subnet number of addressable hosts', function() { + assert.equal(ipv4Subnet.numHosts, 62); + }); + + it('should compute an ipv4 subnet mask', function() { + assert.equal(ipv4Subnet.subnetMask, '255.255.255.192'); + }); + + it('should compute an ipv4 subnet mask\'s length', function() { + assert.equal(ipv4Subnet.subnetMaskLength, 26); + }); + + it('should know whether a subnet contains an address', function() { + assert.equal(ipv4Subnet.contains('192.168.1.180'), true); + }); + + it('should know whether a subnet contains an address', function() { + assert.equal(ipv4Subnet.contains('192.168.1.195'), false); + }); + + }); + + describe('cidr() method', function() { + it('should mask address in CIDR notation', function() { + assert.equal(ip.cidr('192.168.1.134/26'), '192.168.1.128'); + assert.equal(ip.cidr('2607:f0d0:1002:51::4/56'), '2607:f0d0:1002::'); + }); + }); + + describe('isEqual() method', function() { + it('should check if addresses are equal', function() { + assert(ip.isEqual('127.0.0.1', '::7f00:1')); + assert(!ip.isEqual('127.0.0.1', '::7f00:2')); + assert(ip.isEqual('127.0.0.1', '::ffff:7f00:1')); + assert(!ip.isEqual('127.0.0.1', '::ffaf:7f00:1')); + assert(ip.isEqual('::ffff:127.0.0.1', '::ffff:127.0.0.1')); + assert(ip.isEqual('::ffff:127.0.0.1', '127.0.0.1')); + }); + }); + + + describe('isPrivate() method', function() { + it('should check if an address is localhost', function() { + assert.equal(ip.isPrivate('127.0.0.1'), true); + }); + + it('should check if an address is from a 192.168.x.x network', function() { + assert.equal(ip.isPrivate('192.168.0.123'), true); + assert.equal(ip.isPrivate('192.168.122.123'), true); + assert.equal(ip.isPrivate('192.162.1.2'), false); + }); + + it('should check if an address is from a 172.16.x.x network', function() { + assert.equal(ip.isPrivate('172.16.0.5'), true); + assert.equal(ip.isPrivate('172.16.123.254'), true); + assert.equal(ip.isPrivate('171.16.0.5'), false); + assert.equal(ip.isPrivate('172.25.232.15'), true); + assert.equal(ip.isPrivate('172.15.0.5'), false); + assert.equal(ip.isPrivate('172.32.0.5'), false); + }); + + it('should check if an address is from a 169.254.x.x network', function() { + assert.equal(ip.isPrivate('169.254.2.3'), true); + assert.equal(ip.isPrivate('169.254.221.9'), true); + assert.equal(ip.isPrivate('168.254.2.3'), false); + }); + + it('should check if an address is from a 10.x.x.x network', function() { + assert.equal(ip.isPrivate('10.0.2.3'), true); + assert.equal(ip.isPrivate('10.1.23.45'), true); + assert.equal(ip.isPrivate('12.1.2.3'), false); + }); + + it('should check if an address is from a private IPv6 network', function() { + assert.equal(ip.isPrivate('fd12:3456:789a:1::1'), true); + assert.equal(ip.isPrivate('fe80::f2de:f1ff:fe3f:307e'), true); + assert.equal(ip.isPrivate('::ffff:10.100.1.42'), true); + assert.equal(ip.isPrivate('::FFFF:172.16.200.1'), true); + assert.equal(ip.isPrivate('::ffff:192.168.0.1'), true); + }); + + it('should check if an address is from the internet', function() { + assert.equal(ip.isPrivate('165.225.132.33'), false); // joyent.com + }); + + it('should check if an address is a loopback IPv6 address', function() { + assert.equal(ip.isPrivate('::'), true); + assert.equal(ip.isPrivate('::1'), true); + assert.equal(ip.isPrivate('fe80::1'), true); + }); + }); + + describe('loopback() method', function() { + describe('undefined', function() { + it('should respond with 127.0.0.1', function() { + assert.equal(ip.loopback(), '127.0.0.1') + }); + }); + + describe('ipv4', function() { + it('should respond with 127.0.0.1', function() { + assert.equal(ip.loopback('ipv4'), '127.0.0.1') + }); + }); + + describe('ipv6', function() { + it('should respond with fe80::1', function() { + assert.equal(ip.loopback('ipv6'), 'fe80::1') + }); + }); + }); + + describe('isLoopback() method', function() { + describe('127.0.0.1', function() { + it('should respond with true', function() { + assert.ok(ip.isLoopback('127.0.0.1')) + }); + }); + + describe('127.8.8.8', function () { + it('should respond with true', function () { + assert.ok(ip.isLoopback('127.8.8.8')) + }); + }); + + describe('8.8.8.8', function () { + it('should respond with false', function () { + assert.equal(ip.isLoopback('8.8.8.8'), false); + }); + }); + + describe('fe80::1', function() { + it('should respond with true', function() { + assert.ok(ip.isLoopback('fe80::1')) + }); + }); + + describe('::1', function() { + it('should respond with true', function() { + assert.ok(ip.isLoopback('::1')) + }); + }); + + describe('::', function() { + it('should respond with true', function() { + assert.ok(ip.isLoopback('::')) + }); + }); + }); + + describe('address() method', function() { + describe('undefined', function() { + it('should respond with a private ip', function() { + assert.ok(ip.isPrivate(ip.address())); + }); + }); + + describe('private', function() { + [ undefined, 'ipv4', 'ipv6' ].forEach(function(family) { + describe(family, function() { + it('should respond with a private ip', function() { + assert.ok(ip.isPrivate(ip.address('private', family))); + }); + }); + }); + }); + + var interfaces = os.networkInterfaces(); + + Object.keys(interfaces).forEach(function(nic) { + describe(nic, function() { + [ undefined, 'ipv4' ].forEach(function(family) { + describe(family, function() { + it('should respond with an ipv4 address', function() { + var addr = ip.address(nic, family); + assert.ok(!addr || net.isIPv4(addr)); + }); + }); + }); + + describe('ipv6', function() { + it('should respond with an ipv6 address', function() { + var addr = ip.address(nic, 'ipv6'); + assert.ok(!addr || net.isIPv6(addr)); + }); + }) + }); + }); + }); + + describe('toLong() method', function() { + it('should respond with a int', function() { + assert.equal(ip.toLong('127.0.0.1'), 2130706433); + assert.equal(ip.toLong('255.255.255.255'), 4294967295); + }); + }); + + describe('fromLong() method', function() { + it('should repond with ipv4 address', function() { + assert.equal(ip.fromLong(2130706433), '127.0.0.1'); + assert.equal(ip.fromLong(4294967295), '255.255.255.255'); + }); + }) +}); diff --git a/testing/xpcshell/remotexpcshelltests.py b/testing/xpcshell/remotexpcshelltests.py new file mode 100644 index 0000000000..6dd40f15f9 --- /dev/null +++ b/testing/xpcshell/remotexpcshelltests.py @@ -0,0 +1,791 @@ +#!/usr/bin/env python +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import datetime +import os +import posixpath +import shutil +import sys +import tempfile +import time +import uuid +from argparse import Namespace +from zipfile import ZipFile + +import mozcrash +import mozdevice +import mozfile +import mozinfo +import runxpcshelltests as xpcshell +import six +from mozdevice import ADBDevice, ADBDeviceFactory, ADBTimeoutError +from mozlog import commandline +from xpcshellcommandline import parser_remote + +here = os.path.dirname(os.path.abspath(__file__)) + + +class RemoteProcessMonitor(object): + processStatus = [] + + def __init__(self, package, device, log, remoteLogFile): + self.package = package + self.device = device + self.log = log + self.remoteLogFile = remoteLogFile + self.selectedProcess = -1 + + @classmethod + def pickUnusedProcess(cls): + for i in range(len(cls.processStatus)): + if not cls.processStatus[i]: + cls.processStatus[i] = True + return i + # No more free processes :( + return -1 + + @classmethod + def freeProcess(cls, processId): + cls.processStatus[processId] = False + + def kill(self): + self.device.pkill(self.process_name, sig=9, attempts=1) + + def launch_service(self, extra_args, env, selectedProcess, test_name=None): + if not self.device.process_exist(self.package): + # Make sure the main app is running, this should help making the + # tests get foreground priority scheduling. + self.device.launch_activity( + self.package, + intent="org.mozilla.geckoview.test_runner.XPCSHELL_TEST_MAIN", + activity_name="TestRunnerActivity", + e10s=True, + ) + # Newer Androids require that background services originate from + # active apps, so wait here until the test runner is the top + # activity. + retries = 20 + top = self.device.get_top_activity(timeout=60) + while top != self.package and retries > 0: + self.log.info( + "%s | Checking that %s is the top activity." + % (test_name, self.package) + ) + top = self.device.get_top_activity(timeout=60) + time.sleep(1) + retries -= 1 + + self.process_name = self.package + (":xpcshell%d" % selectedProcess) + + retries = 20 + while retries > 0 and self.device.process_exist(self.process_name): + self.log.info( + "%s | %s | Killing left-over process %s" + % (test_name, self.pid, self.process_name) + ) + self.kill() + time.sleep(1) + retries -= 1 + + if self.device.process_exist(self.process_name): + raise Exception( + "%s | %s | Could not kill left-over process" % (test_name, self.pid) + ) + + self.device.launch_service( + self.package, + activity_name=("XpcshellTestRunnerService$i%d" % selectedProcess), + e10s=True, + moz_env=env, + grant_runtime_permissions=False, + extra_args=extra_args, + out_file=self.remoteLogFile, + ) + return self.pid + + def wait(self, timeout, interval=0.1, test_name=None): + timer = 0 + status = True + + # wait for log creation on startup + retries = 0 + while retries < 20 / interval and not self.device.is_file(self.remoteLogFile): + retries += 1 + time.sleep(interval) + if not self.device.is_file(self.remoteLogFile): + self.log.warning( + "%s | Failed wait for remote log: %s missing?" + % (test_name, self.remoteLogFile) + ) + + while self.device.process_exist(self.process_name): + time.sleep(interval) + timer += interval + interval *= 1.5 + if timeout and timer > timeout: + status = False + self.log.info( + "remotexpcshelltests.py | %s | %s | Timing out" + % (test_name, str(self.pid)) + ) + self.kill() + break + return status + + @property + def pid(self): + """ + Determine the pid of the remote process (or the first process with + the same name). + """ + procs = self.device.get_process_list() + # limit the comparison to the first 75 characters due to a + # limitation in processname length in android. + pids = [proc[0] for proc in procs if proc[1] == self.process_name[:75]] + if pids is None or len(pids) < 1: + return 0 + return pids[0] + + +class RemoteXPCShellTestThread(xpcshell.XPCShellTestThread): + def __init__(self, *args, **kwargs): + xpcshell.XPCShellTestThread.__init__(self, *args, **kwargs) + + self.shellReturnCode = None + # embed the mobile params from the harness into the TestThread + mobileArgs = kwargs.get("mobileArgs") + for key in mobileArgs: + setattr(self, key, mobileArgs[key]) + self.remoteLogFile = posixpath.join( + mobileArgs["remoteLogFolder"], "xpcshell-%s.log" % str(uuid.uuid4()) + ) + + def initDir(self, path, mask="777", timeout=None): + """Initialize a directory by removing it if it exists, creating it + and changing the permissions.""" + self.device.rm(path, recursive=True, force=True, timeout=timeout) + self.device.mkdir(path, parents=True, timeout=timeout) + + def updateTestPrefsFile(self): + # The base method will either be no-op (and return the existing + # remote path), or return a path to a new local file. + testPrefsFile = xpcshell.XPCShellTestThread.updateTestPrefsFile(self) + if testPrefsFile == self.rootPrefsFile: + # The pref file is the shared one, which has been already pushed on the + # device, and so there is nothing more to do here. + return self.rootPrefsFile + + # Push the per-test prefs file in the remote temp dir. + remoteTestPrefsFile = posixpath.join(self.remoteTmpDir, "user.js") + self.device.push(testPrefsFile, remoteTestPrefsFile) + self.device.chmod(remoteTestPrefsFile) + os.remove(testPrefsFile) + return remoteTestPrefsFile + + def buildCmdTestFile(self, name): + remoteDir = self.remoteForLocal(os.path.dirname(name)) + if remoteDir == self.remoteHere: + remoteName = os.path.basename(name) + else: + remoteName = posixpath.join(remoteDir, os.path.basename(name)) + return [ + "-e", + 'const _TEST_CWD = "%s";' % self.remoteHere, + "-e", + 'const _TEST_FILE = ["%s"];' % remoteName.replace("\\", "/"), + ] + + def remoteForLocal(self, local): + for mapping in self.pathMapping: + if os.path.abspath(mapping.local) == os.path.abspath(local): + return mapping.remote + return local + + def setupTempDir(self): + self.remoteTmpDir = posixpath.join(self.remoteTmpDir, str(uuid.uuid4())) + # make sure the temp dir exists + self.initDir(self.remoteTmpDir) + # env var is set in buildEnvironment + self.env["XPCSHELL_TEST_TEMP_DIR"] = self.remoteTmpDir + return self.remoteTmpDir + + def setupProfileDir(self): + profileId = str(uuid.uuid4()) + self.profileDir = posixpath.join(self.profileDir, profileId) + self.initDir(self.profileDir) + if self.interactive or self.singleFile: + self.log.info("profile dir is %s" % self.profileDir) + self.env["XPCSHELL_TEST_PROFILE_DIR"] = self.profileDir + self.env["TMPDIR"] = self.profileDir + self.remoteMinidumpDir = posixpath.join(self.remoteMinidumpRootDir, profileId) + self.initDir(self.remoteMinidumpDir) + self.env["XPCSHELL_MINIDUMP_DIR"] = self.remoteMinidumpDir + return self.profileDir + + def clean_temp_dirs(self, name): + self.log.info("Cleaning up profile for %s folder: %s" % (name, self.profileDir)) + self.device.rm(self.profileDir, force=True, recursive=True) + self.device.rm(self.remoteTmpDir, force=True, recursive=True) + self.device.rm(self.remoteMinidumpDir, force=True, recursive=True) + + def setupMozinfoJS(self): + local = tempfile.mktemp() + mozinfo.output_to_file(local) + mozInfoJSPath = posixpath.join(self.profileDir, "mozinfo.json") + self.device.push(local, mozInfoJSPath) + self.device.chmod(mozInfoJSPath) + os.remove(local) + return mozInfoJSPath + + def logCommand(self, name, completeCmd, testdir): + self.log.info("%s | full command: %r" % (name, completeCmd)) + self.log.info("%s | current directory: %r" % (name, self.remoteHere)) + self.log.info("%s | environment: %s" % (name, self.env)) + + def getHeadFiles(self, test): + """Override parent method to find files on remote device. + + Obtains lists of head- files. Returns a list of head files. + """ + + def sanitize_list(s, kind): + for f in s.strip().split(" "): + f = f.strip() + if len(f) < 1: + continue + + path = posixpath.join(self.remoteHere, f) + + # skip check for file existence: the convenience of discovering + # a missing file does not justify the time cost of the round trip + # to the device + yield path + + self.remoteHere = self.remoteForLocal(test["here"]) + + headlist = test.get("head", "") + return list(sanitize_list(headlist, "head")) + + def buildXpcsCmd(self): + # change base class' paths to remote paths and use base class to build command + self.xpcshell = posixpath.join(self.remoteBinDir, "xpcw") + self.headJSPath = posixpath.join(self.remoteScriptsDir, "head.js") + self.httpdJSPath = posixpath.join(self.remoteComponentsDir, "httpd.js") + self.testingModulesDir = self.remoteModulesDir + self.testharnessdir = self.remoteScriptsDir + xpcsCmd = xpcshell.XPCShellTestThread.buildXpcsCmd(self) + # remove "-g -a " and replace with remote alternatives + del xpcsCmd[1:5] + if self.options["localAPK"]: + xpcsCmd.insert(1, "--greomni") + xpcsCmd.insert(2, self.remoteAPK) + xpcsCmd.insert(1, "-g") + xpcsCmd.insert(2, self.remoteBinDir) + + if self.remoteDebugger: + # for example, "/data/local/gdbserver" "localhost:12345" + xpcsCmd = [self.remoteDebugger, self.remoteDebuggerArgs] + xpcsCmd + return xpcsCmd + + def killTimeout(self, proc): + self.kill(proc) + + def launchProcess( + self, cmd, stdout, stderr, env, cwd, timeout=None, test_name=None + ): + rpm = RemoteProcessMonitor( + "org.mozilla.geckoview.test_runner", + self.device, + self.log, + self.remoteLogFile, + ) + + startTime = datetime.datetime.now() + + try: + pid = rpm.launch_service( + cmd[1:], self.env, self.selectedProcess, test_name=test_name + ) + except Exception as e: + self.log.info( + "remotexpcshelltests.py | Failed to start process: %s" % str(e) + ) + self.shellReturnCode = 1 + return "" + + self.log.info( + "remotexpcshelltests.py | %s | %s | Launched Test App" + % (test_name, str(pid)) + ) + + if rpm.wait(timeout, test_name=test_name): + self.shellReturnCode = 0 + else: + self.shellReturnCode = 1 + self.log.info( + "remotexpcshelltests.py | %s | %s | Application ran for: %s" + % (test_name, str(pid), str(datetime.datetime.now() - startTime)) + ) + + try: + return self.device.get_file(self.remoteLogFile) + except mozdevice.ADBTimeoutError: + raise + except Exception as e: + self.log.info( + "remotexpcshelltests.py | %s | %s | Could not read log file: %s" + % (test_name, str(pid), str(e)) + ) + self.shellReturnCode = 1 + return "" + + def checkForCrashes(self, dump_directory, symbols_path, test_name=None): + with mozfile.TemporaryDirectory() as dumpDir: + self.device.pull(self.remoteMinidumpDir, dumpDir) + crashed = mozcrash.log_crashes( + self.log, dumpDir, symbols_path, test=test_name + ) + return crashed + + def communicate(self, proc): + return proc, "" + + def poll(self, proc): + if not self.device.process_exist("xpcshell"): + return self.getReturnCode(proc) + # Process is still running + return None + + def kill(self, proc): + return self.device.pkill("xpcshell") + + def getReturnCode(self, proc): + if self.shellReturnCode is not None: + return self.shellReturnCode + else: + return -1 + + def removeDir(self, dirname): + try: + self.device.rm(dirname, recursive=True) + except ADBTimeoutError: + raise + except Exception as e: + self.log.warning(str(e)) + + def createLogFile(self, test, stdout): + filename = test.replace("\\", "/").split("/")[-1] + ".log" + with open(filename, "wb") as f: + f.write(stdout) + + +# A specialization of XPCShellTests that runs tests on an Android device. +class XPCShellRemote(xpcshell.XPCShellTests, object): + def __init__(self, options, log): + xpcshell.XPCShellTests.__init__(self, log) + + options["threadCount"] = min(options["threadCount"] or 4, 4) + + self.options = options + verbose = False + if options["log_tbpl_level"] == "debug" or options["log_mach_level"] == "debug": + verbose = True + self.device = ADBDeviceFactory( + adb=options["adbPath"] or "adb", + device=options["deviceSerial"], + test_root=options["remoteTestRoot"], + verbose=verbose, + ) + self.remoteTestRoot = posixpath.join(self.device.test_root, "xpc") + self.remoteLogFolder = posixpath.join(self.remoteTestRoot, "logs") + # Add Android version (SDK level) to mozinfo so that manifest entries + # can be conditional on android_version. + mozinfo.info["android_version"] = str(self.device.version) + mozinfo.info["is_emulator"] = self.device._device_serial.startswith("emulator-") + + self.localBin = options["localBin"] + self.pathMapping = [] + # remoteBinDir contains xpcshell and its wrapper script, both of which must + # be executable. Since +x permissions cannot usually be set on /mnt/sdcard, + # and the test root may be on /mnt/sdcard, remoteBinDir is set to be on + # /data/local, always. + self.remoteBinDir = posixpath.join(self.device.test_root, "xpcb") + # Terse directory names are used here ("c" for the components directory) + # to minimize the length of the command line used to execute + # xpcshell on the remote device. adb has a limit to the number + # of characters used in a shell command, and the xpcshell command + # line can be quite complex. + self.remoteTmpDir = posixpath.join(self.remoteTestRoot, "tmp") + self.remoteScriptsDir = self.remoteTestRoot + self.remoteComponentsDir = posixpath.join(self.remoteTestRoot, "c") + self.remoteModulesDir = posixpath.join(self.remoteTestRoot, "m") + self.remoteMinidumpRootDir = posixpath.join(self.remoteTestRoot, "minidumps") + self.profileDir = posixpath.join(self.remoteTestRoot, "p") + self.remoteDebugger = options["debugger"] + self.remoteDebuggerArgs = options["debuggerArgs"] + self.testingModulesDir = options["testingModulesDir"] + + self.initDir(self.remoteTmpDir) + self.initDir(self.profileDir) + + # Make sure we get a fresh start + self.device.stop_application("org.mozilla.geckoview.test_runner") + + for i in range(options["threadCount"]): + RemoteProcessMonitor.processStatus += [False] + + self.env = {} + + if options["objdir"]: + self.xpcDir = os.path.join(options["objdir"], "_tests/xpcshell") + elif os.path.isdir(os.path.join(here, "tests")): + self.xpcDir = os.path.join(here, "tests") + else: + print("Couldn't find local xpcshell test directory", file=sys.stderr) + sys.exit(1) + + self.remoteAPK = None + if options["localAPK"]: + self.localAPKContents = ZipFile(options["localAPK"]) + self.remoteAPK = posixpath.join( + self.remoteBinDir, os.path.basename(options["localAPK"]) + ) + else: + self.localAPKContents = None + if options["setup"]: + self.setupTestDir() + self.setupUtilities() + self.setupModules() + self.initDir(self.remoteMinidumpRootDir) + self.initDir(self.remoteLogFolder) + + eprefs = options.get("extraPrefs") or [] + if options.get("disableFission"): + eprefs.append("fission.autostart=false") + else: + # should be by default, just in case + eprefs.append("fission.autostart=true") + options["extraPrefs"] = eprefs + + # data that needs to be passed to the RemoteXPCShellTestThread + self.mobileArgs = { + "device": self.device, + "remoteBinDir": self.remoteBinDir, + "remoteScriptsDir": self.remoteScriptsDir, + "remoteComponentsDir": self.remoteComponentsDir, + "remoteModulesDir": self.remoteModulesDir, + "options": self.options, + "remoteDebugger": self.remoteDebugger, + "remoteDebuggerArgs": self.remoteDebuggerArgs, + "pathMapping": self.pathMapping, + "profileDir": self.profileDir, + "remoteLogFolder": self.remoteLogFolder, + "remoteTmpDir": self.remoteTmpDir, + "remoteMinidumpRootDir": self.remoteMinidumpRootDir, + } + if self.remoteAPK: + self.mobileArgs["remoteAPK"] = self.remoteAPK + + def initDir(self, path, mask="777", timeout=None): + """Initialize a directory by removing it if it exists, creating it + and changing the permissions.""" + self.device.rm(path, recursive=True, force=True, timeout=timeout) + self.device.mkdir(path, parents=True, timeout=timeout) + + def setLD_LIBRARY_PATH(self): + self.env["LD_LIBRARY_PATH"] = self.remoteBinDir + + def pushWrapper(self): + # Rather than executing xpcshell directly, this wrapper script is + # used. By setting environment variables and the cwd in the script, + # the length of the per-test command line is shortened. This is + # often important when using ADB, as there is a limit to the length + # of the ADB command line. + localWrapper = tempfile.mktemp() + with open(localWrapper, "w") as f: + f.write("#!/system/bin/sh\n") + for envkey, envval in six.iteritems(self.env): + f.write("export %s=%s\n" % (envkey, envval)) + f.writelines( + [ + "cd $1\n", + "echo xpcw: cd $1\n", + "shift\n", + 'echo xpcw: xpcshell "$@"\n', + '%s/xpcshell "$@"\n' % self.remoteBinDir, + ] + ) + remoteWrapper = posixpath.join(self.remoteBinDir, "xpcw") + self.device.push(localWrapper, remoteWrapper) + self.device.chmod(remoteWrapper) + os.remove(localWrapper) + + def start_test(self, test): + test.selectedProcess = RemoteProcessMonitor.pickUnusedProcess() + if test.selectedProcess == -1: + self.log.error( + "TEST-UNEXPECTED-FAIL | remotexpcshelltests.py | " + "no more free processes" + ) + test.start() + + def test_ended(self, test): + RemoteProcessMonitor.freeProcess(test.selectedProcess) + + def buildPrefsFile(self, extraPrefs): + prefs = super(XPCShellRemote, self).buildPrefsFile(extraPrefs) + remotePrefsFile = posixpath.join(self.remoteTestRoot, "user.js") + self.device.push(self.prefsFile, remotePrefsFile) + self.device.chmod(remotePrefsFile) + # os.remove(self.prefsFile) is not called despite having pushed the + # file to the device, because the local file is relied upon by the + # updateTestPrefsFile method + self.prefsFile = remotePrefsFile + return prefs + + def buildEnvironment(self): + self.buildCoreEnvironment() + self.setLD_LIBRARY_PATH() + self.env["MOZ_LINKER_CACHE"] = self.remoteBinDir + self.env["GRE_HOME"] = self.remoteBinDir + self.env["XPCSHELL_TEST_PROFILE_DIR"] = self.profileDir + self.env["HOME"] = self.profileDir + self.env["XPCSHELL_TEST_TEMP_DIR"] = self.remoteTmpDir + self.env["MOZ_ANDROID_DATA_DIR"] = self.remoteBinDir + self.env["MOZ_IN_AUTOMATION"] = "1" + + # Guard against intermittent failures to retrieve abi property; + # without an abi, xpcshell cannot find greprefs.js and crashes. + abilistprop = None + abi = None + retries = 0 + while not abi and retries < 3: + abi = self.device.get_prop("ro.product.cpu.abi") + retries += 1 + if not abi: + raise Exception("failed to get ro.product.cpu.abi from device") + self.log.info("ro.product.cpu.abi %s" % abi) + if self.localAPKContents: + abilist = [abi] + retries = 0 + while not abilistprop and retries < 3: + abilistprop = self.device.get_prop("ro.product.cpu.abilist") + retries += 1 + self.log.info("ro.product.cpu.abilist %s" % abilistprop) + abi_found = False + names = [ + n for n in self.localAPKContents.namelist() if n.startswith("lib/") + ] + self.log.debug("apk names: %s" % names) + if abilistprop and len(abilistprop) > 0: + abilist.extend(abilistprop.split(",")) + for abicand in abilist: + abi_found = ( + len([n for n in names if n.startswith("lib/%s" % abicand)]) > 0 + ) + if abi_found: + abi = abicand + break + if not abi_found: + self.log.info("failed to get matching abi from apk.") + if len(names) > 0: + self.log.info( + "device cpu abi not found in apk. Using abi from apk." + ) + abi = names[0].split("/")[1] + self.log.info("Using abi %s." % abi) + self.env["MOZ_ANDROID_CPU_ABI"] = abi + self.log.info("Using env %r" % (self.env,)) + + def setupUtilities(self): + self.initDir(self.remoteTmpDir) + self.initDir(self.remoteBinDir) + remotePrefDir = posixpath.join(self.remoteBinDir, "defaults", "pref") + self.initDir(posixpath.join(remotePrefDir, "extra")) + self.initDir(self.remoteComponentsDir) + + local = os.path.join(os.path.dirname(os.path.abspath(__file__)), "head.js") + remoteFile = posixpath.join(self.remoteScriptsDir, "head.js") + self.device.push(local, remoteFile) + self.device.chmod(remoteFile) + + # Additional binaries are required for some tests. This list should be + # similar to TEST_HARNESS_BINS in testing/mochitest/Makefile.in. + binaries = [ + "ssltunnel", + "certutil", + "pk12util", + "BadCertAndPinningServer", + "DelegatedCredentialsServer", + "EncryptedClientHelloServer", + "FaultyServer", + "OCSPStaplingServer", + "GenerateOCSPResponse", + "SanctionsTestServer", + ] + for fname in binaries: + local = os.path.join(self.localBin, fname) + if os.path.isfile(local): + print("Pushing %s.." % fname, file=sys.stderr) + remoteFile = posixpath.join(self.remoteBinDir, fname) + self.device.push(local, remoteFile) + self.device.chmod(remoteFile) + else: + print( + "*** Expected binary %s not found in %s!" % (fname, self.localBin), + file=sys.stderr, + ) + + local = os.path.join(self.localBin, "components/httpd.js") + remoteFile = posixpath.join(self.remoteComponentsDir, "httpd.js") + self.device.push(local, remoteFile) + self.device.chmod(remoteFile) + + if self.options["localAPK"]: + remoteFile = posixpath.join( + self.remoteBinDir, os.path.basename(self.options["localAPK"]) + ) + self.device.push(self.options["localAPK"], remoteFile) + self.device.chmod(remoteFile) + + self.pushLibs() + else: + localB2G = os.path.join(self.options["objdir"], "dist", "b2g") + if os.path.exists(localB2G): + self.device.push(localB2G, self.remoteBinDir) + self.device.chmod(self.remoteBinDir) + else: + raise Exception("unable to install gre: no APK and not b2g") + + def pushLibs(self): + pushed_libs_count = 0 + try: + dir = tempfile.mkdtemp() + for info in self.localAPKContents.infolist(): + if info.filename.endswith(".so"): + print("Pushing %s.." % info.filename, file=sys.stderr) + remoteFile = posixpath.join( + self.remoteBinDir, os.path.basename(info.filename) + ) + self.localAPKContents.extract(info, dir) + localFile = os.path.join(dir, info.filename) + self.device.push(localFile, remoteFile) + pushed_libs_count += 1 + self.device.chmod(remoteFile) + finally: + shutil.rmtree(dir) + return pushed_libs_count + + def setupModules(self): + if self.testingModulesDir: + self.device.push(self.testingModulesDir, self.remoteModulesDir) + self.device.chmod(self.remoteModulesDir) + + def setupTestDir(self): + print("pushing %s" % self.xpcDir) + # The tests directory can be quite large: 5000 files and growing! + # Sometimes - like on a low-end aws instance running an emulator - the push + # may exceed the default 5 minute timeout, so we increase it here to 10 minutes. + self.device.rm(self.remoteScriptsDir, recursive=True, force=True, timeout=None) + self.device.push(self.xpcDir, self.remoteScriptsDir, timeout=600) + self.device.chmod(self.remoteScriptsDir, recursive=True) + + def setupSocketConnections(self): + # make node host ports visible to device + if "MOZHTTP2_PORT" in self.env: + port = "tcp:{}".format(self.env["MOZHTTP2_PORT"]) + self.device.create_socket_connection( + ADBDevice.SOCKET_DIRECTION_REVERSE, port, port + ) + self.log.info("reversed MOZHTTP2_PORT connection for port " + port) + if "MOZNODE_EXEC_PORT" in self.env: + port = "tcp:{}".format(self.env["MOZNODE_EXEC_PORT"]) + self.device.create_socket_connection( + ADBDevice.SOCKET_DIRECTION_REVERSE, port, port + ) + self.log.info("reversed MOZNODE_EXEC_PORT connection for port " + port) + + def buildTestList(self, test_tags=None, test_paths=None, verify=False): + xpcshell.XPCShellTests.buildTestList( + self, test_tags=test_tags, test_paths=test_paths, verify=verify + ) + uniqueTestPaths = set([]) + for test in self.alltests: + uniqueTestPaths.add(test["here"]) + for testdir in uniqueTestPaths: + abbrevTestDir = os.path.relpath(testdir, self.xpcDir) + remoteScriptDir = posixpath.join(self.remoteScriptsDir, abbrevTestDir) + self.pathMapping.append(PathMapping(testdir, remoteScriptDir)) + # This is not related to building the test list, but since this is called late + # in the test suite run, this is a convenient place to finalize preparations; + # in particular, these operations cannot be executed much earlier because + # self.env may not be finalized. + self.setupSocketConnections() + if self.options["setup"]: + self.pushWrapper() + + +def verifyRemoteOptions(parser, options): + if isinstance(options, Namespace): + options = vars(options) + + if options["localBin"] is None: + if options["objdir"]: + options["localBin"] = os.path.join(options["objdir"], "dist", "bin") + if not os.path.isdir(options["localBin"]): + parser.error("Couldn't find local binary dir, specify --local-bin-dir") + elif os.path.isfile(os.path.join(here, "..", "bin", "xpcshell")): + # assume tests are being run from a tests archive + options["localBin"] = os.path.abspath(os.path.join(here, "..", "bin")) + else: + parser.error("Couldn't find local binary dir, specify --local-bin-dir") + return options + + +class PathMapping: + def __init__(self, localDir, remoteDir): + self.local = localDir + self.remote = remoteDir + + +def main(): + if sys.version_info < (2, 7): + print( + "Error: You must use python version 2.7 or newer but less than 3.0", + file=sys.stderr, + ) + sys.exit(1) + + parser = parser_remote() + options = parser.parse_args() + + options = verifyRemoteOptions(parser, options) + log = commandline.setup_logging("Remote XPCShell", options, {"tbpl": sys.stdout}) + + if options["interactive"] and not options["testPath"]: + print( + "Error: You must specify a test filename in interactive mode!", + file=sys.stderr, + ) + sys.exit(1) + + if options["xpcshell"] is None: + options["xpcshell"] = "xpcshell" + + # The threadCount depends on the emulator rather than the host machine and + # empirically 10 seems to yield the best performance. + options["threadCount"] = min(options["threadCount"], 10) + + xpcsh = XPCShellRemote(options, log) + + if not xpcsh.runTests( + options, testClass=RemoteXPCShellTestThread, mobileArgs=xpcsh.mobileArgs + ): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/testing/xpcshell/runxpcshelltests.py b/testing/xpcshell/runxpcshelltests.py new file mode 100755 index 0000000000..8ef8aed65f --- /dev/null +++ b/testing/xpcshell/runxpcshelltests.py @@ -0,0 +1,2226 @@ +#!/usr/bin/env python +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import copy +import json +import os +import pipes +import random +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time +import traceback +from argparse import Namespace +from collections import defaultdict, deque, namedtuple +from contextlib import contextmanager +from datetime import datetime, timedelta +from functools import partial +from multiprocessing import cpu_count +from subprocess import PIPE, STDOUT, Popen +from tempfile import gettempdir, mkdtemp +from threading import Event, Thread, Timer, current_thread + +import mozdebug +import six +from mozserve import Http3Server + +try: + import psutil + + HAVE_PSUTIL = True +except Exception: + HAVE_PSUTIL = False + +from xpcshellcommandline import parser_desktop + +SCRIPT_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__))) + +try: + from mozbuild.base import MozbuildObject + + build = MozbuildObject.from_environment(cwd=SCRIPT_DIR) +except ImportError: + build = None + +HARNESS_TIMEOUT = 5 * 60 +TBPL_RETRY = 4 # defined in mozharness + +# benchmarking on tbpl revealed that this works best for now +# TODO: This has been evaluated/set many years ago and we might want to +# benchmark this again. +# These days with e10s/fission the number of real processes/threads running +# can be significantly higher, with both consequences on runtime and memory +# consumption. So be aware that NUM_THREADS is just saying how many tests will +# be started maximum in parallel and that depending on the tests there is +# only a weak correlation to the effective number of processes or threads. +# Be also aware that we can override this value with the threadCount option +# on the command line to tweak it for a concrete CPU/memory combination. +NUM_THREADS = int(cpu_count() * 4) +if sys.platform == "win32": + NUM_THREADS = NUM_THREADS / 2 + +EXPECTED_LOG_ACTIONS = set( + [ + "test_status", + "log", + ] +) + +# -------------------------------------------------------------- +# TODO: this is a hack for mozbase without virtualenv, remove with bug 849900 +# +here = os.path.dirname(__file__) +mozbase = os.path.realpath(os.path.join(os.path.dirname(here), "mozbase")) + +if os.path.isdir(mozbase): + for package in os.listdir(mozbase): + sys.path.append(os.path.join(mozbase, package)) + +import mozcrash +import mozfile +import mozinfo +from manifestparser import TestManifest +from manifestparser.filters import chunk_by_slice, failures, pathprefix, tags +from manifestparser.util import normsep +from mozlog import commandline +from mozprofile import Profile +from mozprofile.cli import parse_preferences +from mozrunner.utils import get_stack_fixer_function + +# -------------------------------------------------------------- + +# TODO: perhaps this should be in a more generally shared location? +# This regex matches all of the C0 and C1 control characters +# (U+0000 through U+001F; U+007F; U+0080 through U+009F), +# except TAB (U+0009), CR (U+000D), LF (U+000A) and backslash (U+005C). +# A raw string is deliberately not used. +_cleanup_encoding_re = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\\\\]") + + +def _cleanup_encoding_repl(m): + c = m.group(0) + return "\\\\" if c == "\\" else "\\x{0:02X}".format(ord(c)) + + +def cleanup_encoding(s): + """S is either a byte or unicode string. Either way it may + contain control characters, unpaired surrogates, reserved code + points, etc. If it is a byte string, it is assumed to be + UTF-8, but it may not be *correct* UTF-8. Return a + sanitized unicode object.""" + if not isinstance(s, six.string_types): + if isinstance(s, six.binary_type): + return six.ensure_str(s) + else: + return six.text_type(s) + if isinstance(s, six.binary_type): + s = s.decode("utf-8", "replace") + # Replace all C0 and C1 control characters with \xNN escapes. + return _cleanup_encoding_re.sub(_cleanup_encoding_repl, s) + + +@contextmanager +def popenCleanupHack(): + """ + Hack to work around https://bugs.python.org/issue37380 + The basic idea is that on old versions of Python on Windows, + we need to clear subprocess._cleanup before we call Popen(), + then restore it afterwards. + """ + savedCleanup = None + if mozinfo.isWin and sys.version_info[0] == 3 and sys.version_info < (3, 7, 5): + savedCleanup = subprocess._cleanup + subprocess._cleanup = lambda: None + try: + yield + finally: + if savedCleanup: + subprocess._cleanup = savedCleanup + + +""" Control-C handling """ +gotSIGINT = False + + +def markGotSIGINT(signum, stackFrame): + global gotSIGINT + gotSIGINT = True + + +class XPCShellTestThread(Thread): + def __init__( + self, test_object, retry=True, verbose=False, usingTSan=False, **kwargs + ): + Thread.__init__(self) + self.daemon = True + + self.test_object = test_object + self.retry = retry + self.verbose = verbose + self.usingTSan = usingTSan + + self.appPath = kwargs.get("appPath") + self.xrePath = kwargs.get("xrePath") + self.utility_path = kwargs.get("utility_path") + self.testingModulesDir = kwargs.get("testingModulesDir") + self.debuggerInfo = kwargs.get("debuggerInfo") + self.jsDebuggerInfo = kwargs.get("jsDebuggerInfo") + self.httpdJSPath = kwargs.get("httpdJSPath") + self.headJSPath = kwargs.get("headJSPath") + self.testharnessdir = kwargs.get("testharnessdir") + self.profileName = kwargs.get("profileName") + self.singleFile = kwargs.get("singleFile") + self.env = copy.deepcopy(kwargs.get("env")) + self.symbolsPath = kwargs.get("symbolsPath") + self.logfiles = kwargs.get("logfiles") + self.app_binary = kwargs.get("app_binary") + self.xpcshell = kwargs.get("xpcshell") + self.xpcsRunArgs = kwargs.get("xpcsRunArgs") + self.failureManifest = kwargs.get("failureManifest") + self.jscovdir = kwargs.get("jscovdir") + self.stack_fixer_function = kwargs.get("stack_fixer_function") + self._rootTempDir = kwargs.get("tempDir") + self.cleanup_dir_list = kwargs.get("cleanup_dir_list") + self.pStdout = kwargs.get("pStdout") + self.pStderr = kwargs.get("pStderr") + self.keep_going = kwargs.get("keep_going") + self.log = kwargs.get("log") + self.app_dir_key = kwargs.get("app_dir_key") + self.interactive = kwargs.get("interactive") + self.rootPrefsFile = kwargs.get("rootPrefsFile") + self.extraPrefs = kwargs.get("extraPrefs") + self.verboseIfFails = kwargs.get("verboseIfFails") + self.headless = kwargs.get("headless") + self.runFailures = kwargs.get("runFailures") + self.timeoutAsPass = kwargs.get("timeoutAsPass") + self.crashAsPass = kwargs.get("crashAsPass") + self.conditionedProfileDir = kwargs.get("conditionedProfileDir") + if self.runFailures: + self.retry = False + + # Default the test prefsFile to the rootPrefsFile. + self.prefsFile = self.rootPrefsFile + + # only one of these will be set to 1. adding them to the totals in + # the harness + self.passCount = 0 + self.todoCount = 0 + self.failCount = 0 + + # Context for output processing + self.output_lines = [] + self.has_failure_output = False + self.saw_proc_start = False + self.saw_proc_end = False + self.command = None + self.harness_timeout = kwargs.get("harness_timeout") + self.timedout = False + self.infra = False + + # event from main thread to signal work done + self.event = kwargs.get("event") + self.done = False # explicitly set flag so we don't rely on thread.isAlive + + def run(self): + try: + self.run_test() + except PermissionError as e: + self.infra = True + self.exception = e + self.traceback = traceback.format_exc() + except Exception as e: + self.exception = e + self.traceback = traceback.format_exc() + else: + self.exception = None + self.traceback = None + if self.retry: + self.log.info( + "%s failed or timed out, will retry." % self.test_object["id"] + ) + self.done = True + self.event.set() + + def kill(self, proc): + """ + Simple wrapper to kill a process. + On a remote system, this is overloaded to handle remote process communication. + """ + return proc.kill() + + def removeDir(self, dirname): + """ + Simple wrapper to remove (recursively) a given directory. + On a remote system, we need to overload this to work on the remote filesystem. + """ + mozfile.remove(dirname) + + def poll(self, proc): + """ + Simple wrapper to check if a process has terminated. + On a remote system, this is overloaded to handle remote process communication. + """ + return proc.poll() + + def createLogFile(self, test_file, stdout): + """ + For a given test file and stdout buffer, create a log file. + On a remote system we have to fix the test name since it can contain directories. + """ + with open(test_file + ".log", "w") as f: + f.write(stdout) + + def getReturnCode(self, proc): + """ + Simple wrapper to get the return code for a given process. + On a remote system we overload this to work with the remote process management. + """ + if proc is not None and hasattr(proc, "returncode"): + return proc.returncode + return -1 + + def communicate(self, proc): + """ + Simple wrapper to communicate with a process. + On a remote system, this is overloaded to handle remote process communication. + """ + # Processing of incremental output put here to + # sidestep issues on remote platforms, where what we know + # as proc is a file pulled off of a device. + if proc.stdout: + while True: + line = proc.stdout.readline() + if not line: + break + self.process_line(line) + + if self.saw_proc_start and not self.saw_proc_end: + self.has_failure_output = True + + return proc.communicate() + + def launchProcess( + self, cmd, stdout, stderr, env, cwd, timeout=None, test_name=None + ): + """ + Simple wrapper to launch a process. + On a remote system, this is more complex and we need to overload this function. + """ + # timeout is needed by remote xpcshell to extend the + # remote device timeout. It is not used in this function. + if six.PY3: + cwd = six.ensure_str(cwd) + for i in range(len(cmd)): + cmd[i] = six.ensure_str(cmd[i]) + + if HAVE_PSUTIL: + popen_func = psutil.Popen + else: + popen_func = Popen + + with popenCleanupHack(): + proc = popen_func(cmd, stdout=stdout, stderr=stderr, env=env, cwd=cwd) + + return proc + + def checkForCrashes(self, dump_directory, symbols_path, test_name=None): + """ + Simple wrapper to check for crashes. + On a remote system, this is more complex and we need to overload this function. + """ + quiet = False + if self.crashAsPass: + quiet = True + + return mozcrash.log_crashes( + self.log, dump_directory, symbols_path, test=test_name, quiet=quiet + ) + + def logCommand(self, name, completeCmd, testdir): + self.log.info("%s | full command: %r" % (name, completeCmd)) + self.log.info("%s | current directory: %r" % (name, testdir)) + # Show only those environment variables that are changed from + # the ambient environment. + changedEnv = set("%s=%s" % i for i in six.iteritems(self.env)) - set( + "%s=%s" % i for i in six.iteritems(os.environ) + ) + self.log.info("%s | environment: %s" % (name, list(changedEnv))) + shell_command_tokens = [ + pipes.quote(tok) for tok in list(changedEnv) + completeCmd + ] + self.log.info( + "%s | as shell command: (cd %s; %s)" + % (name, pipes.quote(testdir), " ".join(shell_command_tokens)) + ) + + def killTimeout(self, proc): + if proc is not None and hasattr(proc, "pid"): + mozcrash.kill_and_get_minidump( + proc.pid, self.tempDir, utility_path=self.utility_path + ) + else: + self.log.info("not killing -- proc or pid unknown") + + def postCheck(self, proc): + """Checks for a still-running test process, kills it and fails the test if found. + We can sometimes get here before the process has terminated, which would + cause removeDir() to fail - so check for the process and kill it if needed. + """ + if proc and self.poll(proc) is None: + if HAVE_PSUTIL: + try: + self.kill(proc) + except psutil.NoSuchProcess: + pass + else: + self.kill(proc) + message = "%s | Process still running after test!" % self.test_object["id"] + if self.retry: + self.log.info(message) + return + + self.log.error(message) + self.log_full_output() + self.failCount = 1 + + def testTimeout(self, proc): + if self.test_object["expected"] == "pass": + expected = "PASS" + else: + expected = "FAIL" + + if self.retry: + self.log.test_end( + self.test_object["id"], + "TIMEOUT", + expected="TIMEOUT", + message="Test timed out", + ) + else: + result = "TIMEOUT" + if self.timeoutAsPass: + expected = "FAIL" + result = "FAIL" + self.failCount = 1 + self.log.test_end( + self.test_object["id"], + result, + expected=expected, + message="Test timed out", + ) + self.log_full_output() + + self.done = True + self.timedout = True + self.killTimeout(proc) + self.log.info("xpcshell return code: %s" % self.getReturnCode(proc)) + self.postCheck(proc) + self.clean_temp_dirs(self.test_object["path"]) + + def updateTestPrefsFile(self): + # If the Manifest file has some additional prefs, merge the + # prefs set in the user.js file stored in the _rootTempdir + # with the prefs from the manifest and the prefs specified + # in the extraPrefs option. + if "prefs" in self.test_object: + # Merge the user preferences in a fake profile dir in a + # local temporary dir (self.tempDir is the remoteTmpDir + # for the RemoteXPCShellTestThread subclass and so we + # can't use that tempDir here). + localTempDir = mkdtemp(prefix="xpc-other-", dir=self._rootTempDir) + + filename = "user.js" + interpolation = {"server": "dummyserver"} + profile = Profile(profile=localTempDir, restore=False) + # _rootTempDir contains a user.js file, generated by buildPrefsFile + profile.merge(self._rootTempDir, interpolation=interpolation) + + prefs = self.test_object["prefs"].strip().split() + name = self.test_object["id"] + if self.verbose: + self.log.info( + "%s: Per-test extra prefs will be set:\n {}".format( + "\n ".join(prefs) + ) + % name + ) + + profile.set_preferences(parse_preferences(prefs), filename=filename) + # Make sure that the extra prefs form the command line are overriding + # any prefs inherited from the shared profile data or the manifest prefs. + profile.set_preferences( + parse_preferences(self.extraPrefs), filename=filename + ) + return os.path.join(profile.profile, filename) + + # Return the root prefsFile if there is no other prefs to merge. + # This is the path set by buildPrefsFile. + return self.rootPrefsFile + + @property + def conditioned_profile_copy(self): + """Returns a copy of the original conditioned profile that was created.""" + + condprof_copy = os.path.join(tempfile.mkdtemp(), "profile") + shutil.copytree( + self.conditionedProfileDir, + condprof_copy, + ignore=shutil.ignore_patterns("lock"), + ) + self.log.info("Created a conditioned-profile copy: %s" % condprof_copy) + return condprof_copy + + def buildCmdTestFile(self, name): + """ + Build the command line arguments for the test file. + On a remote system, this may be overloaded to use a remote path structure. + """ + return ["-e", 'const _TEST_FILE = ["%s"];' % name.replace("\\", "/")] + + def setupTempDir(self): + tempDir = mkdtemp(prefix="xpc-other-", dir=self._rootTempDir) + self.env["XPCSHELL_TEST_TEMP_DIR"] = tempDir + if self.interactive: + self.log.info("temp dir is %s" % tempDir) + return tempDir + + def setupProfileDir(self): + """ + Create a temporary folder for the profile and set appropriate environment variables. + When running check-interactive and check-one, the directory is well-defined and + retained for inspection once the tests complete. + + On a remote system, this may be overloaded to use a remote path structure. + """ + if self.conditionedProfileDir: + profileDir = self.conditioned_profile_copy + elif self.interactive or self.singleFile: + profileDir = os.path.join(gettempdir(), self.profileName, "xpcshellprofile") + try: + # This could be left over from previous runs + self.removeDir(profileDir) + except Exception: + pass + os.makedirs(profileDir) + else: + profileDir = mkdtemp(prefix="xpc-profile-", dir=self._rootTempDir) + self.env["XPCSHELL_TEST_PROFILE_DIR"] = profileDir + if self.interactive or self.singleFile: + self.log.info("profile dir is %s" % profileDir) + return profileDir + + def setupMozinfoJS(self): + mozInfoJSPath = os.path.join(self.profileDir, "mozinfo.json") + mozInfoJSPath = mozInfoJSPath.replace("\\", "\\\\") + mozinfo.output_to_file(mozInfoJSPath) + return mozInfoJSPath + + def buildCmdHead(self): + """ + Build the command line arguments for the head files, + along with the address of the webserver which some tests require. + + On a remote system, this is overloaded to resolve quoting issues over a + secondary command line. + """ + headfiles = self.getHeadFiles(self.test_object) + cmdH = ", ".join(['"' + f.replace("\\", "/") + '"' for f in headfiles]) + + dbgport = 0 if self.jsDebuggerInfo is None else self.jsDebuggerInfo.port + + return [ + "-e", + "const _HEAD_FILES = [%s];" % cmdH, + "-e", + "const _JSDEBUGGER_PORT = %d;" % dbgport, + ] + + def getHeadFiles(self, test): + """Obtain lists of head- files. Returns a list of head files.""" + + def sanitize_list(s, kind): + for f in s.strip().split(" "): + f = f.strip() + if len(f) < 1: + continue + + path = os.path.normpath(os.path.join(test["here"], f)) + if not os.path.exists(path): + raise Exception("%s file does not exist: %s" % (kind, path)) + + if not os.path.isfile(path): + raise Exception("%s file is not a file: %s" % (kind, path)) + + yield path + + headlist = test.get("head", "") + return list(sanitize_list(headlist, "head")) + + def buildXpcsCmd(self): + """ + Load the root head.js file as the first file in our test path, before other head, + and test files. On a remote system, we overload this to add additional command + line arguments, so this gets overloaded. + """ + # - NOTE: if you rename/add any of the constants set here, update + # do_load_child_test_harness() in head.js + if not self.appPath: + self.appPath = self.xrePath + + if self.app_binary: + xpcsCmd = [ + self.app_binary, + "--xpcshell", + ] + else: + xpcsCmd = [ + self.xpcshell, + ] + + xpcsCmd += [ + "-g", + self.xrePath, + "-a", + self.appPath, + "-m", + "-e", + 'const _HEAD_JS_PATH = "%s";' % self.headJSPath, + "-e", + 'const _MOZINFO_JS_PATH = "%s";' % self.mozInfoJSPath, + "-e", + 'const _PREFS_FILE = "%s";' % self.prefsFile.replace("\\", "\\\\"), + ] + + if self.testingModulesDir: + # Escape backslashes in string literal. + sanitized = self.testingModulesDir.replace("\\", "\\\\") + xpcsCmd.extend(["-e", 'const _TESTING_MODULES_DIR = "%s";' % sanitized]) + + xpcsCmd.extend(["-f", os.path.join(self.testharnessdir, "head.js")]) + + if self.debuggerInfo: + xpcsCmd = [self.debuggerInfo.path] + self.debuggerInfo.args + xpcsCmd + + return xpcsCmd + + def cleanupDir(self, directory, name): + if not os.path.exists(directory): + return + + # up to TRY_LIMIT attempts (one every second), because + # the Windows filesystem is slow to react to the changes + TRY_LIMIT = 25 + try_count = 0 + while try_count < TRY_LIMIT: + try: + self.removeDir(directory) + except OSError: + self.log.info("Failed to remove directory: %s. Waiting." % directory) + # We suspect the filesystem may still be making changes. Wait a + # little bit and try again. + time.sleep(1) + try_count += 1 + else: + # removed fine + return + + # we try cleaning up again later at the end of the run + self.cleanup_dir_list.append(directory) + + def clean_temp_dirs(self, name): + # We don't want to delete the profile when running check-interactive + # or check-one. + if self.profileDir and not self.interactive and not self.singleFile: + self.cleanupDir(self.profileDir, name) + + self.cleanupDir(self.tempDir, name) + + def parse_output(self, output): + """Parses process output for structured messages and saves output as it is + read. Sets self.has_failure_output in case of evidence of a failure""" + for line_string in output.splitlines(): + self.process_line(line_string) + + if self.saw_proc_start and not self.saw_proc_end: + self.has_failure_output = True + + def fix_text_output(self, line): + line = cleanup_encoding(line) + if self.stack_fixer_function is not None: + line = self.stack_fixer_function(line) + + if isinstance(line, bytes): + line = line.decode("utf-8") + return line + + def log_line(self, line): + """Log a line of output (either a parser json object or text output from + the test process""" + if isinstance(line, six.string_types) or isinstance(line, bytes): + line = self.fix_text_output(line).rstrip("\r\n") + self.log.process_output(self.proc_ident, line, command=self.command) + else: + if "message" in line: + line["message"] = self.fix_text_output(line["message"]) + if "xpcshell_process" in line: + line["thread"] = " ".join( + [current_thread().name, line["xpcshell_process"]] + ) + else: + line["thread"] = current_thread().name + self.log.log_raw(line) + + def log_full_output(self): + """Logs any buffered output from the test process, and clears the buffer.""" + if not self.output_lines: + return + self.log.info(">>>>>>>") + for line in self.output_lines: + self.log_line(line) + self.log.info("<<<<<<<") + self.output_lines = [] + + def report_message(self, message): + """Stores or logs a json log message in mozlog format.""" + if self.verbose: + self.log_line(message) + else: + self.output_lines.append(message) + + def process_line(self, line_string): + """Parses a single line of output, determining its significance and + reporting a message. + """ + if isinstance(line_string, bytes): + # Transform binary to string representation + line_string = line_string.decode(sys.stdout.encoding, errors="replace") + + if not line_string.strip(): + return + + try: + line_object = json.loads(line_string) + if not isinstance(line_object, dict): + self.report_message(line_string) + return + except ValueError: + self.report_message(line_string) + return + + if ( + "action" not in line_object + or line_object["action"] not in EXPECTED_LOG_ACTIONS + ): + # The test process output JSON. + self.report_message(line_string) + return + + action = line_object["action"] + + self.has_failure_output = ( + self.has_failure_output + or "expected" in line_object + or action == "log" + and line_object["level"] == "ERROR" + ) + + self.report_message(line_object) + + if action == "log" and line_object["message"] == "CHILD-TEST-STARTED": + self.saw_proc_start = True + elif action == "log" and line_object["message"] == "CHILD-TEST-COMPLETED": + self.saw_proc_end = True + + def run_test(self): + """Run an individual xpcshell test.""" + global gotSIGINT + + name = self.test_object["id"] + path = self.test_object["path"] + + # Check for skipped tests + if "disabled" in self.test_object: + message = self.test_object["disabled"] + if not message: + message = "disabled from xpcshell manifest" + self.log.test_start(name) + self.log.test_end(name, "SKIP", message=message) + + self.retry = False + self.keep_going = True + return + + # Check for known-fail tests + expect_pass = self.test_object["expected"] == "pass" + + # By default self.appPath will equal the gre dir. If specified in the + # xpcshell.ini file, set a different app dir for this test. + if self.app_dir_key and self.app_dir_key in self.test_object: + rel_app_dir = self.test_object[self.app_dir_key] + rel_app_dir = os.path.join(self.xrePath, rel_app_dir) + self.appPath = os.path.abspath(rel_app_dir) + else: + self.appPath = None + + test_dir = os.path.dirname(path) + + # Create a profile and a temp dir that the JS harness can stick + # a profile and temporary data in + self.profileDir = self.setupProfileDir() + self.tempDir = self.setupTempDir() + self.mozInfoJSPath = self.setupMozinfoJS() + + # Setup per-manifest prefs and write them into the tempdir. + self.prefsFile = self.updateTestPrefsFile() + + # The order of the command line is important: + # 1) Arguments for xpcshell itself + self.command = self.buildXpcsCmd() + + # 2) Arguments for the head files + self.command.extend(self.buildCmdHead()) + + # 3) Arguments for the test file + self.command.extend(self.buildCmdTestFile(path)) + self.command.extend(["-e", 'const _TEST_NAME = "%s";' % name]) + + # 4) Arguments for code coverage + if self.jscovdir: + self.command.extend( + ["-e", 'const _JSCOV_DIR = "%s";' % self.jscovdir.replace("\\", "/")] + ) + + # 5) Runtime arguments + if "debug" in self.test_object: + self.command.append("-d") + + self.command.extend(self.xpcsRunArgs) + + if self.test_object.get("dmd") == "true": + self.env["PYTHON"] = sys.executable + self.env["BREAKPAD_SYMBOLS_PATH"] = self.symbolsPath + + if self.test_object.get("snap") == "true": + self.env["SNAP_NAME"] = "firefox" + self.env["SNAP_INSTANCE_NAME"] = "firefox" + + if self.test_object.get("subprocess") == "true": + self.env["PYTHON"] = sys.executable + + if ( + self.test_object.get("headless", "true" if self.headless else None) + == "true" + ): + self.env["MOZ_HEADLESS"] = "1" + self.env["DISPLAY"] = "77" # Set a fake display. + + testTimeoutInterval = self.harness_timeout + # Allow a test to request a multiple of the timeout if it is expected to take long + if "requesttimeoutfactor" in self.test_object: + testTimeoutInterval *= int(self.test_object["requesttimeoutfactor"]) + + testTimer = None + if not self.interactive and not self.debuggerInfo and not self.jsDebuggerInfo: + testTimer = Timer(testTimeoutInterval, lambda: self.testTimeout(proc)) + testTimer.start() + + proc = None + process_output = None + + try: + self.log.test_start(name) + if self.verbose: + self.logCommand(name, self.command, test_dir) + + proc = self.launchProcess( + self.command, + stdout=self.pStdout, + stderr=self.pStderr, + env=self.env, + cwd=test_dir, + timeout=testTimeoutInterval, + test_name=name, + ) + + if hasattr(proc, "pid"): + self.proc_ident = proc.pid + else: + # On mobile, "proc" is just a file. + self.proc_ident = name + + if self.interactive: + self.log.info("%s | Process ID: %d" % (name, self.proc_ident)) + + # Communicate returns a tuple of (stdout, stderr), however we always + # redirect stderr to stdout, so the second element is ignored. + process_output, _ = self.communicate(proc) + + if self.interactive: + # Not sure what else to do here... + self.keep_going = True + return + + if testTimer: + testTimer.cancel() + + if process_output: + # For the remote case, stdout is not yet depleted, so we parse + # it here all at once. + self.parse_output(process_output) + + return_code = self.getReturnCode(proc) + + # TSan'd processes return 66 if races are detected. This isn't + # good in the sense that there's no way to distinguish between + # a process that would normally have returned zero but has races, + # and a race-free process that returns 66. But I don't see how + # to do better. This ambiguity is at least constrained to the + # with-TSan case. It doesn't affect normal builds. + # + # This also assumes that the magic value 66 isn't overridden by + # a TSAN_OPTIONS=exitcode= environment variable setting. + # + TSAN_EXIT_CODE_WITH_RACES = 66 + + return_code_ok = return_code == 0 or ( + self.usingTSan and return_code == TSAN_EXIT_CODE_WITH_RACES + ) + passed = (not self.has_failure_output) and return_code_ok + + status = "PASS" if passed else "FAIL" + expected = "PASS" if expect_pass else "FAIL" + message = "xpcshell return code: %d" % return_code + + if self.timedout: + return + + if status != expected: + if self.retry: + self.log.test_end( + name, + status, + expected=status, + message="Test failed or timed out, will retry", + ) + self.clean_temp_dirs(path) + if self.verboseIfFails and not self.verbose: + self.log_full_output() + return + + self.log.test_end(name, status, expected=expected, message=message) + self.log_full_output() + + self.failCount += 1 + + if self.failureManifest: + with open(self.failureManifest, "a") as f: + f.write("[%s]\n" % self.test_object["path"]) + for k, v in self.test_object.items(): + f.write("%s = %s\n" % (k, v)) + + else: + # If TSan reports a race, dump the output, else we can't + # diagnose what the problem was. See comments above about + # the significance of TSAN_EXIT_CODE_WITH_RACES. + if self.usingTSan and return_code == TSAN_EXIT_CODE_WITH_RACES: + self.log_full_output() + + self.log.test_end(name, status, expected=expected, message=message) + if self.verbose: + self.log_full_output() + + self.retry = False + + if expect_pass: + self.passCount = 1 + else: + self.todoCount = 1 + + if self.checkForCrashes(self.tempDir, self.symbolsPath, test_name=name): + if self.retry: + self.clean_temp_dirs(path) + return + + # If we assert during shutdown there's a chance the test has passed + # but we haven't logged full output, so do so here. + self.log_full_output() + self.failCount = 1 + + if self.logfiles and process_output: + self.createLogFile(name, process_output) + + finally: + self.postCheck(proc) + self.clean_temp_dirs(path) + + if gotSIGINT: + self.log.error("Received SIGINT (control-C) during test execution") + if self.keep_going: + gotSIGINT = False + else: + self.keep_going = False + return + + self.keep_going = True + + +class XPCShellTests(object): + def __init__(self, log=None): + """Initializes node status and logger.""" + self.log = log + self.harness_timeout = HARNESS_TIMEOUT + self.nodeProc = {} + self.http3Server = None + self.conditioned_profile_dir = None + + def getTestManifest(self, manifest): + if isinstance(manifest, TestManifest): + return manifest + elif manifest is not None: + manifest = os.path.normpath(os.path.abspath(manifest)) + if os.path.isfile(manifest): + return TestManifest([manifest], strict=True) + else: + ini_path = os.path.join(manifest, "xpcshell.ini") + else: + ini_path = os.path.join(SCRIPT_DIR, "tests", "xpcshell.ini") + + if os.path.exists(ini_path): + return TestManifest([ini_path], strict=True) + else: + self.log.error( + "Failed to find manifest at %s; use --manifest " + "to set path explicitly." % ini_path + ) + sys.exit(1) + + def normalizeTest(self, root, test_object): + path = test_object.get("file_relpath", test_object["relpath"]) + if "dupe-manifest" in test_object and "ancestor_manifest" in test_object: + test_object["id"] = "%s:%s" % ( + os.path.basename(test_object["ancestor_manifest"]), + path, + ) + else: + test_object["id"] = path + + if root: + test_object["manifest"] = os.path.relpath(test_object["manifest"], root) + + if os.sep != "/": + for key in ("id", "manifest"): + test_object[key] = test_object[key].replace(os.sep, "/") + + return test_object + + def buildTestList(self, test_tags=None, test_paths=None, verify=False): + """Reads the xpcshell.ini manifest and set self.alltests to an array. + + Given the parameters, this method compiles a list of tests to be run + that matches the criteria set by parameters. + + If any chunking of tests are to occur, it is also done in this method. + + If no tests are added to the list of tests to be run, an error + is logged. A sys.exit() signal is sent to the caller. + + Args: + test_tags (list, optional): list of strings. + test_paths (list, optional): list of strings derived from the command + line argument provided by user, specifying + tests to be run. + verify (bool, optional): boolean value. + """ + if test_paths is None: + test_paths = [] + + mp = self.getTestManifest(self.manifest) + + root = mp.rootdir + if build and not root: + root = build.topsrcdir + normalize = partial(self.normalizeTest, root) + + filters = [] + if test_tags: + filters.append(tags(test_tags)) + + path_filter = None + if test_paths: + path_filter = pathprefix(test_paths) + filters.append(path_filter) + + noDefaultFilters = False + if self.runFailures: + filters.append(failures(self.runFailures)) + noDefaultFilters = True + + if self.totalChunks > 1: + filters.append(chunk_by_slice(self.thisChunk, self.totalChunks)) + try: + self.alltests = list( + map( + normalize, + mp.active_tests( + filters=filters, + noDefaultFilters=noDefaultFilters, + **mozinfo.info, + ), + ) + ) + except TypeError: + sys.stderr.write("*** offending mozinfo.info: %s\n" % repr(mozinfo.info)) + raise + + if path_filter and path_filter.missing: + self.log.warning( + "The following path(s) didn't resolve any tests:\n {}".format( + " \n".join(sorted(path_filter.missing)) + ) + ) + + if len(self.alltests) == 0: + if ( + test_paths + and path_filter.missing == set(test_paths) + and os.environ.get("MOZ_AUTOMATION") == "1" + ): + # This can happen in CI when a manifest doesn't exist due to a + # build config variable in moz.build traversal. Don't generate + # an error in this case. Adding a todo count avoids mozharness + # raising an error. + self.todoCount += len(path_filter.missing) + else: + self.log.error( + "no tests to run using specified " + "combination of filters: {}".format(mp.fmt_filters()) + ) + sys.exit(1) + + if len(self.alltests) == 1 and not verify: + self.singleFile = os.path.basename(self.alltests[0]["path"]) + else: + self.singleFile = None + + if self.dump_tests: + self.dump_tests = os.path.expanduser(self.dump_tests) + assert os.path.exists(os.path.dirname(self.dump_tests)) + with open(self.dump_tests, "w") as dumpFile: + dumpFile.write(json.dumps({"active_tests": self.alltests})) + + self.log.info("Dumping active_tests to %s file." % self.dump_tests) + sys.exit() + + def setAbsPath(self): + """ + Set the absolute path for xpcshell, httpdjspath and xrepath. These 3 variables + depend on input from the command line and we need to allow for absolute paths. + This function is overloaded for a remote solution as os.path* won't work remotely. + """ + self.testharnessdir = os.path.dirname(os.path.abspath(__file__)) + self.headJSPath = self.testharnessdir.replace("\\", "/") + "/head.js" + if self.xpcshell is not None: + self.xpcshell = os.path.abspath(self.xpcshell) + + if self.app_binary is not None: + self.app_binary = os.path.abspath(self.app_binary) + + if self.xrePath is None: + binary_path = self.app_binary or self.xpcshell + self.xrePath = os.path.dirname(binary_path) + if mozinfo.isMac: + # Check if we're run from an OSX app bundle and override + # self.xrePath if we are. + appBundlePath = os.path.join( + os.path.dirname(os.path.dirname(self.xpcshell)), "Resources" + ) + if os.path.exists(os.path.join(appBundlePath, "application.ini")): + self.xrePath = appBundlePath + else: + self.xrePath = os.path.abspath(self.xrePath) + + # httpd.js belongs in xrePath/components, which is Contents/Resources on mac + self.httpdJSPath = os.path.join(self.xrePath, "components", "httpd.js") + self.httpdJSPath = self.httpdJSPath.replace("\\", "/") + + if self.mozInfo is None: + self.mozInfo = os.path.join(self.testharnessdir, "mozinfo.json") + + def buildPrefsFile(self, extraPrefs): + # Create the prefs.js file + + # In test packages used in CI, the profile_data directory is installed + # in the SCRIPT_DIR. + profile_data_dir = os.path.join(SCRIPT_DIR, "profile_data") + # If possible, read profile data from topsrcdir. This prevents us from + # requiring a re-build to pick up newly added extensions in the + # /extensions directory. + if build: + path = os.path.join(build.topsrcdir, "testing", "profiles") + if os.path.isdir(path): + profile_data_dir = path + # Still not found? Look for testing/profiles relative to testing/xpcshell. + if not os.path.isdir(profile_data_dir): + path = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "profiles")) + if os.path.isdir(path): + profile_data_dir = path + + with open(os.path.join(profile_data_dir, "profiles.json"), "r") as fh: + base_profiles = json.load(fh)["xpcshell"] + + # values to use when interpolating preferences + interpolation = { + "server": "dummyserver", + } + + profile = Profile(profile=self.tempDir, restore=False) + prefsFile = os.path.join(profile.profile, "user.js") + + # Empty the user.js file in case the file existed before. + with open(prefsFile, "w"): + pass + + for name in base_profiles: + path = os.path.join(profile_data_dir, name) + profile.merge(path, interpolation=interpolation) + + # add command line prefs + prefs = parse_preferences(extraPrefs) + profile.set_preferences(prefs) + + self.prefsFile = prefsFile + return prefs + + def buildCoreEnvironment(self): + """ + Add environment variables likely to be used across all platforms, including + remote systems. + """ + # Make assertions fatal + self.env["XPCOM_DEBUG_BREAK"] = "stack-and-abort" + # Crash reporting interferes with debugging + if not self.debuggerInfo: + self.env["MOZ_CRASHREPORTER"] = "1" + # Don't launch the crash reporter client + self.env["MOZ_CRASHREPORTER_NO_REPORT"] = "1" + # Don't permit remote connections by default. + # MOZ_DISABLE_NONLOCAL_CONNECTIONS can be set to "0" to temporarily + # enable non-local connections for the purposes of local testing. + # Don't override the user's choice here. See bug 1049688. + self.env.setdefault("MOZ_DISABLE_NONLOCAL_CONNECTIONS", "1") + if self.mozInfo.get("topsrcdir") is not None: + self.env["MOZ_DEVELOPER_REPO_DIR"] = self.mozInfo["topsrcdir"] + if self.mozInfo.get("topobjdir") is not None: + self.env["MOZ_DEVELOPER_OBJ_DIR"] = self.mozInfo["topobjdir"] + + # Disable the content process sandbox for the xpcshell tests. They + # currently attempt to do things like bind() sockets, which is not + # compatible with the sandbox. + self.env["MOZ_DISABLE_CONTENT_SANDBOX"] = "1" + if os.getenv("MOZ_FETCHES_DIR", None): + self.env["MOZ_FETCHES_DIR"] = os.getenv("MOZ_FETCHES_DIR", None) + + if self.mozInfo.get("socketprocess_networking"): + self.env["MOZ_FORCE_USE_SOCKET_PROCESS"] = "1" + else: + self.env["MOZ_DISABLE_SOCKET_PROCESS"] = "1" + + def buildEnvironment(self): + """ + Create and returns a dictionary of self.env to include all the appropriate env + variables and values. On a remote system, we overload this to set different + values and are missing things like os.environ and PATH. + """ + self.env = dict(os.environ) + self.buildCoreEnvironment() + if sys.platform == "win32": + self.env["PATH"] = self.env["PATH"] + ";" + self.xrePath + elif sys.platform in ("os2emx", "os2knix"): + os.environ["BEGINLIBPATH"] = self.xrePath + ";" + self.env["BEGINLIBPATH"] + os.environ["LIBPATHSTRICT"] = "T" + elif sys.platform == "osx" or sys.platform == "darwin": + self.env["DYLD_LIBRARY_PATH"] = os.path.join( + os.path.dirname(self.xrePath), "MacOS" + ) + else: # unix or linux? + if "LD_LIBRARY_PATH" not in self.env or self.env["LD_LIBRARY_PATH"] is None: + self.env["LD_LIBRARY_PATH"] = self.xrePath + else: + self.env["LD_LIBRARY_PATH"] = ":".join( + [self.xrePath, self.env["LD_LIBRARY_PATH"]] + ) + + usingASan = "asan" in self.mozInfo and self.mozInfo["asan"] + usingTSan = "tsan" in self.mozInfo and self.mozInfo["tsan"] + if usingASan or usingTSan: + # symbolizer support + if "ASAN_SYMBOLIZER_PATH" in self.env and os.path.isfile( + self.env["ASAN_SYMBOLIZER_PATH"] + ): + llvmsym = self.env["ASAN_SYMBOLIZER_PATH"] + else: + llvmsym = os.path.join( + self.xrePath, "llvm-symbolizer" + self.mozInfo["bin_suffix"] + ) + if os.path.isfile(llvmsym): + if usingASan: + self.env["ASAN_SYMBOLIZER_PATH"] = llvmsym + else: + oldTSanOptions = self.env.get("TSAN_OPTIONS", "") + self.env["TSAN_OPTIONS"] = "external_symbolizer_path={} {}".format( + llvmsym, oldTSanOptions + ) + self.log.info("runxpcshelltests.py | using symbolizer at %s" % llvmsym) + else: + self.log.error( + "TEST-UNEXPECTED-FAIL | runxpcshelltests.py | " + "Failed to find symbolizer at %s" % llvmsym + ) + + return self.env + + def getPipes(self): + """ + Determine the value of the stdout and stderr for the test. + Return value is a list (pStdout, pStderr). + """ + if self.interactive: + pStdout = None + pStderr = None + else: + if self.debuggerInfo and self.debuggerInfo.interactive: + pStdout = None + pStderr = None + else: + if sys.platform == "os2emx": + pStdout = None + else: + pStdout = PIPE + pStderr = STDOUT + return pStdout, pStderr + + def verifyDirPath(self, dirname): + """ + Simple wrapper to get the absolute path for a given directory name. + On a remote system, we need to overload this to work on the remote filesystem. + """ + return os.path.abspath(dirname) + + def trySetupNode(self): + """ + Run node for HTTP/2 tests, if available, and updates mozinfo as appropriate. + """ + if os.getenv("MOZ_ASSUME_NODE_RUNNING", None): + self.log.info("Assuming required node servers are already running") + if not os.getenv("MOZHTTP2_PORT", None): + self.log.warning( + "MOZHTTP2_PORT environment variable not set. " + "Tests requiring http/2 will fail." + ) + return + + # We try to find the node executable in the path given to us by the user in + # the MOZ_NODE_PATH environment variable + nodeBin = os.getenv("MOZ_NODE_PATH", None) + if not nodeBin and build: + nodeBin = build.substs.get("NODEJS") + if not nodeBin: + self.log.warning( + "MOZ_NODE_PATH environment variable not set. " + "Tests requiring http/2 will fail." + ) + return + + if not os.path.exists(nodeBin) or not os.path.isfile(nodeBin): + error = "node not found at MOZ_NODE_PATH %s" % (nodeBin) + self.log.error(error) + raise IOError(error) + + self.log.info("Found node at %s" % (nodeBin,)) + + def startServer(name, serverJs): + if not os.path.exists(serverJs): + error = "%s not found at %s" % (name, serverJs) + self.log.error(error) + raise IOError(error) + + # OK, we found our server, let's try to get it running + self.log.info("Found %s at %s" % (name, serverJs)) + try: + # We pipe stdin to node because the server will exit when its + # stdin reaches EOF + with popenCleanupHack(): + process = Popen( + [nodeBin, serverJs], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + env=self.env, + cwd=os.getcwd(), + universal_newlines=True, + ) + self.nodeProc[name] = process + + # Check to make sure the server starts properly by waiting for it to + # tell us it's started + msg = process.stdout.readline() + if "server listening" in msg: + searchObj = re.search( + r"HTTP2 server listening on ports ([0-9]+),([0-9]+)", msg, 0 + ) + if searchObj: + self.env["MOZHTTP2_PORT"] = searchObj.group(1) + self.env["MOZNODE_EXEC_PORT"] = searchObj.group(2) + except OSError as e: + # This occurs if the subprocess couldn't be started + self.log.error("Could not run %s server: %s" % (name, str(e))) + raise + + myDir = os.path.split(os.path.abspath(__file__))[0] + startServer("moz-http2", os.path.join(myDir, "moz-http2", "moz-http2.js")) + + def shutdownNode(self): + """ + Shut down our node process, if it exists + """ + for name, proc in six.iteritems(self.nodeProc): + self.log.info("Node %s server shutting down ..." % name) + if proc.poll() is not None: + self.log.info("Node server %s already dead %s" % (name, proc.poll())) + else: + proc.terminate() + + def dumpOutput(fd, label): + firstTime = True + for msg in fd: + if firstTime: + firstTime = False + self.log.info("Process %s" % label) + self.log.info(msg) + + dumpOutput(proc.stdout, "stdout") + dumpOutput(proc.stderr, "stderr") + self.nodeProc = {} + + def startHttp3Server(self): + """ + Start a Http3 test server. + """ + binSuffix = "" + if sys.platform == "win32": + binSuffix = ".exe" + http3ServerPath = self.http3ServerPath + if not http3ServerPath: + http3ServerPath = os.path.join( + SCRIPT_DIR, "http3server", "http3server" + binSuffix + ) + if build: + http3ServerPath = os.path.join( + build.topobjdir, "dist", "bin", "http3server" + binSuffix + ) + dbPath = os.path.join(SCRIPT_DIR, "http3server", "http3serverDB") + if build: + dbPath = os.path.join(build.topsrcdir, "netwerk", "test", "http3serverDB") + options = {} + options["http3ServerPath"] = http3ServerPath + options["profilePath"] = dbPath + options["isMochitest"] = False + options["isWin"] = sys.platform == "win32" + self.http3Server = Http3Server(options, self.env, self.log) + self.http3Server.start() + for key, value in self.http3Server.ports().items(): + self.env[key] = value + self.env["MOZHTTP3_ECH"] = self.http3Server.echConfig() + + def shutdownHttp3Server(self): + if self.http3Server is None: + return + self.http3Server.stop() + self.http3Server = None + + def buildXpcsRunArgs(self): + """ + Add arguments to run the test or make it interactive. + """ + if self.interactive: + self.xpcsRunArgs = [ + "-e", + 'print("To start the test, type |_execute_test();|.");', + "-i", + ] + else: + self.xpcsRunArgs = ["-e", "_execute_test(); quit(0);"] + + def addTestResults(self, test): + self.passCount += test.passCount + self.failCount += test.failCount + self.todoCount += test.todoCount + + def updateMozinfo(self, prefs, options): + # Handle filenames in mozInfo + if not isinstance(self.mozInfo, dict): + mozInfoFile = self.mozInfo + if not os.path.isfile(mozInfoFile): + self.log.error( + "Error: couldn't find mozinfo.json at '%s'. Perhaps you " + "need to use --build-info-json?" % mozInfoFile + ) + return False + self.mozInfo = json.load(open(mozInfoFile)) + + # mozinfo.info is used as kwargs. Some builds are done with + # an older Python that can't handle Unicode keys in kwargs. + # All of the keys in question should be ASCII. + fixedInfo = {} + for k, v in self.mozInfo.items(): + if isinstance(k, bytes): + k = k.decode("utf-8") + fixedInfo[k] = v + self.mozInfo = fixedInfo + + self.mozInfo["fission"] = prefs.get("fission.autostart", True) + self.mozInfo["sessionHistoryInParent"] = self.mozInfo[ + "fission" + ] or not prefs.get("fission.disableSessionHistoryInParent", False) + + self.mozInfo["serviceworker_e10s"] = True + + self.mozInfo["verify"] = options.get("verify", False) + + self.mozInfo["socketprocess_networking"] = prefs.get( + "network.http.network_access_on_socket_process.enabled", False + ) + + self.mozInfo["condprof"] = options.get("conditionedProfile", False) + + self.mozInfo["msix"] = options.get( + "app_binary" + ) is not None and "WindowsApps" in options.get("app_binary", "") + + mozinfo.update(self.mozInfo) + + return True + + @property + def conditioned_profile_copy(self): + """Returns a copy of the original conditioned profile that was created.""" + condprof_copy = os.path.join(tempfile.mkdtemp(), "profile") + shutil.copytree( + self.conditioned_profile_dir, + condprof_copy, + ignore=shutil.ignore_patterns("lock"), + ) + self.log.info("Created a conditioned-profile copy: %s" % condprof_copy) + return condprof_copy + + def downloadConditionedProfile(self, profile_scenario, app): + from condprof.client import get_profile + from condprof.util import get_current_platform, get_version + + if self.conditioned_profile_dir: + # We already have a directory, so provide a copy that + # will get deleted after it's done with + return self.conditioned_profile_dir + + # create a temp file to help ensure uniqueness + temp_download_dir = tempfile.mkdtemp() + self.log.info( + "Making temp_download_dir from inside get_conditioned_profile {}".format( + temp_download_dir + ) + ) + # call condprof's client API to yield our platform-specific + # conditioned-profile binary + platform = get_current_platform() + version = None + if isinstance(app, str): + version = get_version(app) + + if not profile_scenario: + profile_scenario = "settled" + try: + cond_prof_target_dir = get_profile( + temp_download_dir, + platform, + profile_scenario, + repo="mozilla-central", + version=version, + retries=2, + ) + except Exception: + if version is None: + # any other error is a showstopper + self.log.critical("Could not get the conditioned profile") + traceback.print_exc() + raise + version = None + try: + self.log.info("Retrying a profile with no version specified") + cond_prof_target_dir = get_profile( + temp_download_dir, + platform, + profile_scenario, + repo="mozilla-central", + version=version, + ) + except Exception: + self.log.critical("Could not get the conditioned profile") + traceback.print_exc() + raise + + # now get the full directory path to our fetched conditioned profile + self.conditioned_profile_dir = os.path.join( + temp_download_dir, cond_prof_target_dir + ) + if not os.path.exists(cond_prof_target_dir): + self.log.critical( + "Can't find target_dir {}, from get_profile()" + "temp_download_dir {}, platform {}, scenario {}".format( + cond_prof_target_dir, temp_download_dir, platform, profile_scenario + ) + ) + raise OSError + + self.log.info( + "Original self.conditioned_profile_dir is now set: {}".format( + self.conditioned_profile_dir + ) + ) + return self.conditioned_profile_copy + + def runSelfTest(self): + import unittest + + import selftest + + this = self + + class XPCShellTestsTests(selftest.XPCShellTestsTests): + def __init__(self, name): + unittest.TestCase.__init__(self, name) + self.testing_modules = this.testingModulesDir + self.xpcshellBin = this.xpcshell + self.app_binary = this.app_binary + self.utility_path = this.utility_path + self.symbols_path = this.symbolsPath + + old_info = dict(mozinfo.info) + try: + suite = unittest.TestLoader().loadTestsFromTestCase(XPCShellTestsTests) + return unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() + finally: + # The self tests modify mozinfo, so we need to reset it. + mozinfo.info.clear() + mozinfo.update(old_info) + + def runTests(self, options, testClass=XPCShellTestThread, mobileArgs=None): + """ + Run xpcshell tests. + """ + global gotSIGINT + + # Number of times to repeat test(s) in --verify mode + VERIFY_REPEAT = 10 + + if isinstance(options, Namespace): + options = vars(options) + + # Try to guess modules directory. + # This somewhat grotesque hack allows the buildbot machines to find the + # modules directory without having to configure the buildbot hosts. This + # code path should never be executed in local runs because the build system + # should always set this argument. + if not options.get("testingModulesDir"): + possible = os.path.join(here, os.path.pardir, "modules") + + if os.path.isdir(possible): + testingModulesDir = possible + + if options.get("rerun_failures"): + if os.path.exists(options.get("failure_manifest")): + rerun_manifest = os.path.join( + os.path.dirname(options["failure_manifest"]), "rerun.ini" + ) + shutil.copyfile(options["failure_manifest"], rerun_manifest) + os.remove(options["failure_manifest"]) + else: + self.log.error("No failures were found to re-run.") + sys.exit(1) + + if options.get("testingModulesDir"): + # The resource loader expects native paths. Depending on how we were + # invoked, a UNIX style path may sneak in on Windows. We try to + # normalize that. + testingModulesDir = os.path.normpath(options["testingModulesDir"]) + + if not os.path.isabs(testingModulesDir): + testingModulesDir = os.path.abspath(testingModulesDir) + + if not testingModulesDir.endswith(os.path.sep): + testingModulesDir += os.path.sep + + self.debuggerInfo = None + + if options.get("debugger"): + self.debuggerInfo = mozdebug.get_debugger_info( + options.get("debugger"), + options.get("debuggerArgs"), + options.get("debuggerInteractive"), + ) + + self.jsDebuggerInfo = None + if options.get("jsDebugger"): + # A namedtuple let's us keep .port instead of ['port'] + JSDebuggerInfo = namedtuple("JSDebuggerInfo", ["port"]) + self.jsDebuggerInfo = JSDebuggerInfo(port=options["jsDebuggerPort"]) + + self.app_binary = options.get("app_binary") + self.xpcshell = options.get("xpcshell") + self.http3ServerPath = options.get("http3server") + self.xrePath = options.get("xrePath") + self.utility_path = options.get("utility_path") + self.appPath = options.get("appPath") + self.symbolsPath = options.get("symbolsPath") + self.tempDir = os.path.normpath(options.get("tempDir") or tempfile.gettempdir()) + self.manifest = options.get("manifest") + self.dump_tests = options.get("dump_tests") + self.interactive = options.get("interactive") + self.verbose = options.get("verbose") + self.verboseIfFails = options.get("verboseIfFails") + self.keepGoing = options.get("keepGoing") + self.logfiles = options.get("logfiles") + self.totalChunks = options.get("totalChunks", 1) + self.thisChunk = options.get("thisChunk") + self.profileName = options.get("profileName") or "xpcshell" + self.mozInfo = options.get("mozInfo") + self.testingModulesDir = testingModulesDir + self.sequential = options.get("sequential") + self.failure_manifest = options.get("failure_manifest") + self.threadCount = options.get("threadCount") or NUM_THREADS + self.jscovdir = options.get("jscovdir") + self.headless = options.get("headless") + self.runFailures = options.get("runFailures") + self.timeoutAsPass = options.get("timeoutAsPass") + self.crashAsPass = options.get("crashAsPass") + self.conditionedProfile = options.get("conditionedProfile") + self.repeat = options.get("repeat") + + self.testCount = 0 + self.passCount = 0 + self.failCount = 0 + self.todoCount = 0 + + if self.conditionedProfile: + self.conditioned_profile_dir = self.downloadConditionedProfile( + "full", self.appPath + ) + options["self_test"] = False + if not options["test_tags"]: + options["test_tags"] = [] + options["test_tags"].append("condprof") + + self.setAbsPath() + + eprefs = options.get("extraPrefs") or [] + # enable fission by default + if options.get("disableFission"): + eprefs.append("fission.autostart=false") + else: + # should be by default, just in case + eprefs.append("fission.autostart=true") + + prefs = self.buildPrefsFile(eprefs) + self.buildXpcsRunArgs() + + self.event = Event() + + if not self.updateMozinfo(prefs, options): + return False + + self.log.info( + "These variables are available in the mozinfo environment and " + "can be used to skip tests conditionally:" + ) + for info in sorted(self.mozInfo.items(), key=lambda item: item[0]): + self.log.info(" {key}: {value}".format(key=info[0], value=info[1])) + + if options.get("self_test"): + if not self.runSelfTest(): + return False + + if ( + ("tsan" in self.mozInfo and self.mozInfo["tsan"]) + or ("asan" in self.mozInfo and self.mozInfo["asan"]) + ) and not options.get("threadCount"): + # TSan/ASan require significantly more memory, so reduce the amount of parallel + # tests we run to avoid OOMs and timeouts. We always keep a minimum of 2 for + # non-sequential execution. + # pylint --py3k W1619 + self.threadCount = max(self.threadCount / 2, 2) + + self.stack_fixer_function = None + if self.utility_path and os.path.exists(self.utility_path): + self.stack_fixer_function = get_stack_fixer_function( + self.utility_path, self.symbolsPath + ) + + # buildEnvironment() needs mozInfo, so we call it after mozInfo is initialized. + self.buildEnvironment() + + # The appDirKey is a optional entry in either the default or individual test + # sections that defines a relative application directory for test runs. If + # defined we pass 'grePath/$appDirKey' for the -a parameter of the xpcshell + # test harness. + appDirKey = None + if "appname" in self.mozInfo: + appDirKey = self.mozInfo["appname"] + "-appdir" + + # We have to do this before we run tests that depend on having the node + # http/2 server. + self.trySetupNode() + + self.startHttp3Server() + + pStdout, pStderr = self.getPipes() + + self.buildTestList( + options.get("test_tags"), options.get("testPaths"), options.get("verify") + ) + if self.singleFile: + self.sequential = True + + if options.get("shuffle"): + random.shuffle(self.alltests) + + self.cleanup_dir_list = [] + + kwargs = { + "appPath": self.appPath, + "xrePath": self.xrePath, + "utility_path": self.utility_path, + "testingModulesDir": self.testingModulesDir, + "debuggerInfo": self.debuggerInfo, + "jsDebuggerInfo": self.jsDebuggerInfo, + "httpdJSPath": self.httpdJSPath, + "headJSPath": self.headJSPath, + "tempDir": self.tempDir, + "testharnessdir": self.testharnessdir, + "profileName": self.profileName, + "singleFile": self.singleFile, + "env": self.env, # making a copy of this in the testthreads + "symbolsPath": self.symbolsPath, + "logfiles": self.logfiles, + "app_binary": self.app_binary, + "xpcshell": self.xpcshell, + "xpcsRunArgs": self.xpcsRunArgs, + "failureManifest": self.failure_manifest, + "jscovdir": self.jscovdir, + "harness_timeout": self.harness_timeout, + "stack_fixer_function": self.stack_fixer_function, + "event": self.event, + "cleanup_dir_list": self.cleanup_dir_list, + "pStdout": pStdout, + "pStderr": pStderr, + "keep_going": self.keepGoing, + "log": self.log, + "interactive": self.interactive, + "app_dir_key": appDirKey, + "rootPrefsFile": self.prefsFile, + "extraPrefs": options.get("extraPrefs") or [], + "verboseIfFails": self.verboseIfFails, + "headless": self.headless, + "runFailures": self.runFailures, + "timeoutAsPass": self.timeoutAsPass, + "crashAsPass": self.crashAsPass, + "conditionedProfileDir": self.conditioned_profile_dir, + "repeat": self.repeat, + } + + if self.sequential: + # Allow user to kill hung xpcshell subprocess with SIGINT + # when we are only running tests sequentially. + signal.signal(signal.SIGINT, markGotSIGINT) + + if self.debuggerInfo: + # Force a sequential run + self.sequential = True + + # If we have an interactive debugger, disable SIGINT entirely. + if self.debuggerInfo.interactive: + signal.signal(signal.SIGINT, lambda signum, frame: None) + + if "lldb" in self.debuggerInfo.path: + # Ask people to start debugging using 'process launch', see bug 952211. + self.log.info( + "It appears that you're using LLDB to debug this test. " + + "Please use the 'process launch' command instead of " + "the 'run' command to start xpcshell." + ) + + if self.jsDebuggerInfo: + # The js debugger magic needs more work to do the right thing + # if debugging multiple files. + if len(self.alltests) != 1: + self.log.error( + "Error: --jsdebugger can only be used with a single test!" + ) + return False + + # The test itself needs to know whether it is a tsan build, since + # that has an effect on interpretation of the process return value. + usingTSan = "tsan" in self.mozInfo and self.mozInfo["tsan"] + + # create a queue of all tests that will run + tests_queue = deque() + # also a list for the tests that need to be run sequentially + sequential_tests = [] + status = None + + if options.get("repeat") > 0: + self.sequential = True + + if not options.get("verify"): + for test_object in self.alltests: + # Test identifiers are provided for the convenience of logging. These + # start as path names but are rewritten in case tests from the same path + # are re-run. + + path = test_object["path"] + + if self.singleFile and not path.endswith(self.singleFile): + continue + + # if we have --repeat, duplicate the tests as needed + for i in range(0, options.get("repeat") + 1): + self.testCount += 1 + + test = testClass( + test_object, + verbose=self.verbose or test_object.get("verbose") == "true", + usingTSan=usingTSan, + mobileArgs=mobileArgs, + **kwargs, + ) + if "run-sequentially" in test_object or self.sequential: + sequential_tests.append(test) + else: + tests_queue.append(test) + + status = self.runTestList( + tests_queue, sequential_tests, testClass, mobileArgs, **kwargs + ) + else: + # + # Test verification: Run each test many times, in various configurations, + # in hopes of finding intermittent failures. + # + + def step1(): + # Run tests sequentially. Parallel mode would also work, except that + # the logging system gets confused when 2 or more tests with the same + # name run at the same time. + sequential_tests = [] + for i in range(VERIFY_REPEAT): + self.testCount += 1 + test = testClass( + test_object, retry=False, mobileArgs=mobileArgs, **kwargs + ) + sequential_tests.append(test) + status = self.runTestList( + tests_queue, sequential_tests, testClass, mobileArgs, **kwargs + ) + return status + + def step2(): + # Run tests sequentially, with MOZ_CHAOSMODE enabled. + sequential_tests = [] + self.env["MOZ_CHAOSMODE"] = "0xfb" + # chaosmode runs really slow, allow tests extra time to pass + self.harness_timeout = self.harness_timeout * 2 + for i in range(VERIFY_REPEAT): + self.testCount += 1 + test = testClass( + test_object, retry=False, mobileArgs=mobileArgs, **kwargs + ) + sequential_tests.append(test) + status = self.runTestList( + tests_queue, sequential_tests, testClass, mobileArgs, **kwargs + ) + self.harness_timeout = self.harness_timeout / 2 + return status + + steps = [ + ("1. Run each test %d times, sequentially." % VERIFY_REPEAT, step1), + ( + "2. Run each test %d times, sequentially, in chaos mode." + % VERIFY_REPEAT, + step2, + ), + ] + startTime = datetime.now() + maxTime = timedelta(seconds=options["verifyMaxTime"]) + for test_object in self.alltests: + stepResults = {} + for (descr, step) in steps: + stepResults[descr] = "not run / incomplete" + finalResult = "PASSED" + for (descr, step) in steps: + if (datetime.now() - startTime) > maxTime: + self.log.info( + "::: Test verification is taking too long: Giving up!" + ) + self.log.info( + "::: So far, all checks passed, but not " + "all checks were run." + ) + break + self.log.info(":::") + self.log.info('::: Running test verification step "%s"...' % descr) + self.log.info(":::") + status = step() + if status is not True: + stepResults[descr] = "FAIL" + finalResult = "FAILED!" + break + stepResults[descr] = "Pass" + self.log.info(":::") + self.log.info( + "::: Test verification summary for: %s" % test_object["path"] + ) + self.log.info(":::") + for descr in sorted(stepResults.keys()): + self.log.info("::: %s : %s" % (descr, stepResults[descr])) + self.log.info(":::") + self.log.info("::: Test verification %s" % finalResult) + self.log.info(":::") + + self.shutdownNode() + self.shutdownHttp3Server() + + return status + + def start_test(self, test): + test.start() + + def test_ended(self, test): + pass + + def runTestList( + self, tests_queue, sequential_tests, testClass, mobileArgs, **kwargs + ): + if self.sequential: + self.log.info("Running tests sequentially.") + else: + self.log.info("Using at most %d threads." % self.threadCount) + + # keep a set of threadCount running tests and start running the + # tests in the queue at most threadCount at a time + running_tests = set() + keep_going = True + infra_abort = False + exceptions = [] + tracebacks = [] + self.try_again_list = [] + + tests_by_manifest = defaultdict(list) + for test in self.alltests: + group = test["manifest"] + if "ancestor_manifest" in test: + ancestor_manifest = normsep(test["ancestor_manifest"]) + # Only change the group id if ancestor is not the generated root manifest. + if "/" in ancestor_manifest: + group = "{}:{}".format(ancestor_manifest, group) + tests_by_manifest[group].append(test["id"]) + + self.log.suite_start(tests_by_manifest, name="xpcshell") + + while tests_queue or running_tests: + # if we're not supposed to continue and all of the running tests + # are done, stop + if not keep_going and not running_tests: + break + + # if there's room to run more tests, start running them + while ( + keep_going and tests_queue and (len(running_tests) < self.threadCount) + ): + test = tests_queue.popleft() + running_tests.add(test) + self.start_test(test) + + # queue is full (for now) or no more new tests, + # process the finished tests so far + + # wait for at least one of the tests to finish + self.event.wait(1) + self.event.clear() + + # find what tests are done (might be more than 1) + done_tests = set() + for test in running_tests: + if test.done: + self.test_ended(test) + done_tests.add(test) + test.join( + 1 + ) # join with timeout so we don't hang on blocked threads + # if the test had trouble, we will try running it again + # at the end of the run + if test.retry or test.is_alive(): + # if the join call timed out, test.is_alive => True + self.try_again_list.append(test.test_object) + continue + # did the test encounter any exception? + if test.exception: + exceptions.append(test.exception) + tracebacks.append(test.traceback) + # we won't add any more tests, will just wait for + # the currently running ones to finish + keep_going = False + infra_abort = infra_abort and test.infra + keep_going = keep_going and test.keep_going + self.addTestResults(test) + + # make room for new tests to run + running_tests.difference_update(done_tests) + + if infra_abort: + return TBPL_RETRY # terminate early + + if keep_going: + # run the other tests sequentially + for test in sequential_tests: + if not keep_going: + self.log.error( + "TEST-UNEXPECTED-FAIL | Received SIGINT (control-C), so " + "stopped run. (Use --keep-going to keep running tests " + "after killing one with SIGINT)" + ) + break + self.start_test(test) + test.join() + self.test_ended(test) + if (test.failCount > 0 or test.passCount <= 0) and os.environ.get( + "MOZ_AUTOMATION", 0 + ) != 0: + self.try_again_list.append(test.test_object) + continue + self.addTestResults(test) + # did the test encounter any exception? + if test.exception: + exceptions.append(test.exception) + tracebacks.append(test.traceback) + break + keep_going = test.keep_going + + # retry tests that failed when run in parallel + if self.try_again_list: + self.log.info("Retrying tests that failed when run in parallel.") + for test_object in self.try_again_list: + test = testClass( + test_object, + retry=False, + verbose=self.verbose, + mobileArgs=mobileArgs, + **kwargs, + ) + self.start_test(test) + test.join() + self.test_ended(test) + self.addTestResults(test) + # did the test encounter any exception? + if test.exception: + exceptions.append(test.exception) + tracebacks.append(test.traceback) + break + keep_going = test.keep_going + + # restore default SIGINT behaviour + signal.signal(signal.SIGINT, signal.SIG_DFL) + + # Clean up any slacker directories that might be lying around + # Some might fail because of windows taking too long to unlock them. + # We don't do anything if this fails because the test machines will have + # their $TEMP dirs cleaned up on reboot anyway. + for directory in self.cleanup_dir_list: + try: + shutil.rmtree(directory) + except Exception: + self.log.info("%s could not be cleaned up." % directory) + + if exceptions: + self.log.info("Following exceptions were raised:") + for t in tracebacks: + self.log.error(t) + raise exceptions[0] + + if self.testCount == 0 and os.environ.get("MOZ_AUTOMATION") != "1": + self.log.error("No tests run. Did you pass an invalid --test-path?") + self.failCount = 1 + + # doing this allows us to pass the mozharness parsers that + # report an orange job for failCount>0 + if self.runFailures: + passed = self.passCount + self.passCount = self.failCount + self.failCount = passed + + self.log.info("INFO | Result summary:") + self.log.info("INFO | Passed: %d" % self.passCount) + self.log.info("INFO | Failed: %d" % self.failCount) + self.log.info("INFO | Todo: %d" % self.todoCount) + self.log.info("INFO | Retried: %d" % len(self.try_again_list)) + + if gotSIGINT and not keep_going: + self.log.error( + "TEST-UNEXPECTED-FAIL | Received SIGINT (control-C), so stopped run. " + "(Use --keep-going to keep running tests after " + "killing one with SIGINT)" + ) + return False + + self.log.suite_end() + return self.runFailures or self.failCount == 0 + + +def main(): + parser = parser_desktop() + options = parser.parse_args() + + log = commandline.setup_logging("XPCShell", options, {"tbpl": sys.stdout}) + + if options.xpcshell is None and options.app_binary is None: + log.error( + "Must provide path to xpcshell using --xpcshell or Firefox using --app-binary" + ) + sys.exit(1) + + if options.xpcshell is not None and options.app_binary is not None: + log.error( + "Cannot provide --xpcshell and --app-binary - they are mutually exclusive options. Choose one." + ) + sys.exit(1) + + xpcsh = XPCShellTests(log) + + if options.interactive and not options.testPath: + log.error("Error: You must specify a test filename in interactive mode!") + sys.exit(1) + + result = xpcsh.runTests(options) + if result == TBPL_RETRY: + sys.exit(4) + + if not result: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/testing/xpcshell/selftest.py b/testing/xpcshell/selftest.py new file mode 100755 index 0000000000..09eb9915c8 --- /dev/null +++ b/testing/xpcshell/selftest.py @@ -0,0 +1,1475 @@ +#!/usr/bin/env python +# +# Any copyright is dedicated to the Public Domain. +# http://creativecommons.org/publicdomain/zero/1.0/ +# + +import os +import pprint +import re +import shutil +import sys +import tempfile +import unittest + +import mozinfo +import six +from mozlog import structured +from runxpcshelltests import XPCShellTests + +TEST_PASS_STRING = "TEST-PASS" +TEST_FAIL_STRING = "TEST-UNEXPECTED-FAIL" + +SIMPLE_PASSING_TEST = "function run_test() { Assert.ok(true); }" +SIMPLE_FAILING_TEST = "function run_test() { Assert.ok(false); }" +SIMPLE_PREFCHECK_TEST = """ +function run_test() { + Assert.ok(Services.prefs.getBoolPref("fake.pref.to.test")); +} +""" + +SIMPLE_UNCAUGHT_REJECTION_TEST = """ +function run_test() { + Promise.reject(new Error("Test rejection.")); + Assert.ok(true); +} +""" + +ADD_TEST_SIMPLE = """ +function run_test() { run_next_test(); } + +add_test(function test_simple() { + Assert.ok(true); + run_next_test(); +}); +""" + +ADD_TEST_FAILING = """ +function run_test() { run_next_test(); } + +add_test(function test_failing() { + Assert.ok(false); + run_next_test(); +}); +""" + +ADD_TEST_UNCAUGHT_REJECTION = """ +function run_test() { run_next_test(); } + +add_test(function test_uncaught_rejection() { + Promise.reject(new Error("Test rejection.")); + run_next_test(); +}); +""" + +CHILD_TEST_PASSING = """ +function run_test () { run_next_test(); } + +add_test(function test_child_simple () { + run_test_in_child("test_pass.js"); + run_next_test(); +}); +""" + +CHILD_TEST_FAILING = """ +function run_test () { run_next_test(); } + +add_test(function test_child_simple () { + run_test_in_child("test_fail.js"); + run_next_test(); +}); +""" + +CHILD_HARNESS_SIMPLE = """ +function run_test () { run_next_test(); } + +add_test(function test_child_assert () { + do_load_child_test_harness(); + do_test_pending("test child assertion"); + sendCommand("Assert.ok(true);", do_test_finished); + run_next_test(); +}); +""" + +CHILD_TEST_HANG = """ +function run_test () { run_next_test(); } + +add_test(function test_child_simple () { + do_test_pending("hang test"); + do_load_child_test_harness(); + sendCommand("_testLogger.info('CHILD-TEST-STARTED'); " + + + "const _TEST_FILE=['test_pass.js']; _execute_test(); ", + do_test_finished); + run_next_test(); +}); +""" + +SIMPLE_LOOPING_TEST = """ +function run_test () { run_next_test(); } + +add_test(function test_loop () { + do_test_pending() +}); +""" + +PASSING_TEST_UNICODE = b""" +function run_test () { run_next_test(); } + +add_test(function test_unicode_print () { + Assert.equal("\u201c\u201d", "\u201c\u201d"); + run_next_test(); +}); +""" + +ADD_TASK_SINGLE = """ +function run_test() { run_next_test(); } + +add_task(async function test_task() { + await Promise.resolve(true); + await Promise.resolve(false); +}); +""" + +ADD_TASK_MULTIPLE = """ +function run_test() { run_next_test(); } + +add_task(async function test_task() { + await Promise.resolve(true); +}); + +add_task(async function test_2() { + await Promise.resolve(true); +}); +""" + +ADD_TASK_REJECTED = """ +function run_test() { run_next_test(); } + +add_task(async function test_failing() { + await Promise.reject(new Error("I fail.")); +}); +""" + +ADD_TASK_FAILURE_INSIDE = """ +function run_test() { run_next_test(); } + +add_task(async function test() { + let result = await Promise.resolve(false); + + Assert.ok(result); +}); +""" + +ADD_TASK_RUN_NEXT_TEST = """ +function run_test() { run_next_test(); } + +add_task(function () { + Assert.ok(true); + + run_next_test(); +}); +""" + +ADD_TASK_STACK_TRACE = """ +function run_test() { run_next_test(); } + +add_task(async function this_test_will_fail() { + for (let i = 0; i < 10; ++i) { + await Promise.resolve(); + } + Assert.ok(false); +}); +""" + +ADD_TASK_SKIP = """ +add_task(async function skipMeNot1() { + Assert.ok(true, "Well well well."); +}); + +add_task(async function skipMe1() { + Assert.ok(false, "Not skipped after all."); +}).skip(); + +add_task(async function skipMeNot2() { + Assert.ok(true, "Well well well."); +}); + +add_task(async function skipMeNot3() { + Assert.ok(true, "Well well well."); +}); + +add_task(async function skipMe2() { + Assert.ok(false, "Not skipped after all."); +}).skip(); +""" + +ADD_TASK_SKIPALL = """ +add_task(async function skipMe1() { + Assert.ok(false, "Not skipped after all."); +}); + +add_task(async function skipMe2() { + Assert.ok(false, "Not skipped after all."); +}).skip(); + +add_task(async function skipMe3() { + Assert.ok(false, "Not skipped after all."); +}).only(); + +add_task(async function skipMeNot() { + Assert.ok(true, "Well well well."); +}).only(); + +add_task(async function skipMe4() { + Assert.ok(false, "Not skipped after all."); +}); +""" + +ADD_TEST_THROW_STRING = """ +function run_test() {do_throw("Passing a string to do_throw")}; +""" + +ADD_TEST_THROW_OBJECT = """ +let error = { + message: "Error object", + fileName: "failure.js", + stack: "ERROR STACK", + toString: function() {return this.message;} +}; +function run_test() {do_throw(error)}; +""" + +ADD_TEST_REPORT_OBJECT = """ +let error = { + message: "Error object", + fileName: "failure.js", + stack: "ERROR STACK", + toString: function() {return this.message;} +}; +function run_test() {do_report_unexpected_exception(error)}; +""" + +ADD_TEST_VERBOSE = """ +function run_test() {info("a message from info")}; +""" + +# A test for genuine JS-generated Error objects +ADD_TEST_REPORT_REF_ERROR = """ +function run_test() { + let obj = {blah: 0}; + try { + obj.noSuchFunction(); + } + catch (error) { + do_report_unexpected_exception(error); + } +}; +""" + +# A test for failure to load a test due to a syntax error +LOAD_ERROR_SYNTAX_ERROR = """ +function run_test( +""" + +# A test for failure to load a test due to an error other than a syntax error +LOAD_ERROR_OTHER_ERROR = """ +"use strict"; +no_such_var = "foo"; // assignment to undeclared variable +""" + +# A test that crashes outright. +TEST_CRASHING = """ +function run_test () { + const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm"); + let zero = new ctypes.intptr_t(8); + let badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); + badptr.contents; +} +""" + +# A test for asynchronous cleanup functions +ASYNC_CLEANUP = """ +function run_test() { + let { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" + ); + + // The list of checkpoints in the order we encounter them. + let checkpoints = []; + + // Cleanup tasks, in reverse order + registerCleanupFunction(function cleanup_checkout() { + Assert.equal(checkpoints.join(""), "123456"); + info("At this stage, the test has succeeded"); + do_throw("Throwing an error to force displaying the log"); + }); + + registerCleanupFunction(function sync_cleanup_2() { + checkpoints.push(6); + }); + + registerCleanupFunction(async function async_cleanup_4() { + await undefined; + checkpoints.push(5); + }); + + registerCleanupFunction(async function async_cleanup_3() { + await undefined; + checkpoints.push(4); + }); + + registerCleanupFunction(function async_cleanup_2() { + let deferred = PromiseUtils.defer(); + executeSoon(deferred.resolve); + return deferred.promise.then(function() { + checkpoints.push(3); + }); + }); + + registerCleanupFunction(function sync_cleanup() { + checkpoints.push(2); + }); + + registerCleanupFunction(function async_cleanup() { + let deferred = PromiseUtils.defer(); + executeSoon(deferred.resolve); + return deferred.promise.then(function() { + checkpoints.push(1); + }); + }); + +} +""" + +# A test to check that add_test() tests run without run_test() +NO_RUN_TEST_ADD_TEST = """ +add_test(function no_run_test_add_test() { + Assert.ok(true); + run_next_test(); +}); +""" + +# A test to check that add_task() tests run without run_test() +NO_RUN_TEST_ADD_TASK = """ +add_task(function no_run_test_add_task() { + Assert.ok(true); +}); +""" + +# A test to check that both add_task() and add_test() work without run_test() +NO_RUN_TEST_ADD_TEST_ADD_TASK = """ +add_test(function no_run_test_add_test() { + Assert.ok(true); + run_next_test(); +}); + +add_task(function no_run_test_add_task() { + Assert.ok(true); +}); +""" + +# A test to check that an empty test file without run_test(), +# add_test() or add_task() works. +NO_RUN_TEST_EMPTY_TEST = """ +// This is an empty test file. +""" + +NO_RUN_TEST_ADD_TEST_FAIL = """ +add_test(function no_run_test_add_test_fail() { + Assert.ok(false); + run_next_test(); +}); +""" + +NO_RUN_TEST_ADD_TASK_FAIL = """ +add_task(function no_run_test_add_task_fail() { + Assert.ok(false); +}); +""" + +NO_RUN_TEST_ADD_TASK_MULTIPLE = """ +add_task(async function test_task() { + await Promise.resolve(true); +}); + +add_task(async function test_2() { + await Promise.resolve(true); +}); +""" + +LOAD_MOZINFO = """ +function run_test() { + Assert.notEqual(typeof mozinfo, undefined); + Assert.notEqual(typeof mozinfo.os, undefined); +} +""" + +CHILD_MOZINFO = """ +function run_test () { run_next_test(); } + +add_test(function test_child_mozinfo () { + run_test_in_child("test_mozinfo.js"); + run_next_test(); +}); +""" + +HEADLESS_TRUE = """ +add_task(function headless_true() { + Assert.equal(Services.env.get("MOZ_HEADLESS"), "1", "Check MOZ_HEADLESS"); + Assert.equal(Services.env.get("DISPLAY"), "77", "Check DISPLAY"); +}); +""" + +HEADLESS_FALSE = """ +add_task(function headless_false() { + Assert.notEqual(Services.env.get("MOZ_HEADLESS"), "1", "Check MOZ_HEADLESS"); + Assert.notEqual(Services.env.get("DISPLAY"), "77", "Check DISPLAY"); +}); +""" + + +class XPCShellTestsTests(unittest.TestCase): + """ + Yes, these are unit tests for a unit test harness. + """ + + def __init__(self, name): + super(XPCShellTestsTests, self).__init__(name) + from buildconfig import substs + from mozbuild.base import MozbuildObject + + os.environ.pop("MOZ_OBJDIR", None) + self.build_obj = MozbuildObject.from_environment() + + objdir = self.build_obj.topobjdir + self.testing_modules = os.path.join(objdir, "_tests", "modules") + + if mozinfo.isMac: + self.xpcshellBin = os.path.join( + objdir, + "dist", + substs["MOZ_MACBUNDLE_NAME"], + "Contents", + "MacOS", + "xpcshell", + ) + else: + self.xpcshellBin = os.path.join(objdir, "dist", "bin", "xpcshell") + + if sys.platform == "win32": + self.xpcshellBin += ".exe" + self.utility_path = os.path.join(objdir, "dist", "bin") + self.symbols_path = None + candidate_path = os.path.join(self.build_obj.distdir, "crashreporter-symbols") + if os.path.isdir(candidate_path): + self.symbols_path = candidate_path + + def setUp(self): + self.log = six.StringIO() + self.tempdir = tempfile.mkdtemp() + logger = structured.commandline.setup_logging( + "selftest%s" % id(self), {}, {"tbpl": self.log} + ) + self.x = XPCShellTests(logger) + self.x.harness_timeout = 30 if not mozinfo.info["ccov"] else 60 + + def tearDown(self): + shutil.rmtree(self.tempdir) + self.x.shutdownNode() + + def writeFile(self, name, contents, mode="w"): + """ + Write |contents| to a file named |name| in the temp directory, + and return the full path to the file. + """ + fullpath = os.path.join(self.tempdir, name) + with open(fullpath, mode) as f: + f.write(contents) + return fullpath + + def writeManifest(self, tests, prefs=[]): + """ + Write an xpcshell.ini in the temp directory and set + self.manifest to its pathname. |tests| is a list containing + either strings (for test names), or tuples with a test name + as the first element and manifest conditions as the following + elements. |prefs| is an optional list of prefs in the form of + "prefname=prefvalue" strings. + """ + testlines = [] + for t in tests: + testlines.append("[%s]" % (t if isinstance(t, six.string_types) else t[0])) + if isinstance(t, tuple): + testlines.extend(t[1:]) + prefslines = [] + for p in prefs: + # Append prefs lines as indented inside "prefs=" manifest option. + prefslines.append(" %s" % p) + + self.manifest = self.writeFile( + "xpcshell.ini", + """ +[DEFAULT] +head = +tail = +prefs = +""" + + "\n".join(prefslines) + + "\n" + + "\n".join(testlines), + ) + + def assertTestResult(self, expected, shuffle=False, verbose=False, headless=False): + """ + Assert that self.x.runTests with manifest=self.manifest + returns |expected|. + """ + kwargs = {} + kwargs["app_binary"] = self.app_binary + kwargs["xpcshell"] = self.xpcshellBin + kwargs["symbolsPath"] = self.symbols_path + kwargs["manifest"] = self.manifest + kwargs["mozInfo"] = mozinfo.info + kwargs["shuffle"] = shuffle + kwargs["verbose"] = verbose + kwargs["headless"] = headless + kwargs["sequential"] = True + kwargs["testingModulesDir"] = self.testing_modules + kwargs["utility_path"] = self.utility_path + kwargs["repeat"] = 0 + self.assertEqual( + expected, + self.x.runTests(kwargs), + msg="""Tests should have %s, log: +======== +%s +======== +""" + % ("passed" if expected else "failed", self.log.getvalue()), + ) + + def _assertLog(self, s, expected): + l = self.log.getvalue() + self.assertEqual( + expected, + s in l, + msg="""Value %s %s in log: +======== +%s +========""" + % (s, "expected" if expected else "not expected", l), + ) + + def assertInLog(self, s): + """ + Assert that the string |s| is contained in self.log. + """ + self._assertLog(s, True) + + def assertNotInLog(self, s): + """ + Assert that the string |s| is not contained in self.log. + """ + self._assertLog(s, False) + + def testPass(self): + """ + Check that a simple test without any manifest conditions passes. + """ + self.writeFile("test_basic.js", SIMPLE_PASSING_TEST) + self.writeManifest(["test_basic.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testFail(self): + """ + Check that a simple failing test without any manifest conditions fails. + """ + self.writeFile("test_basic.js", SIMPLE_FAILING_TEST) + self.writeManifest(["test_basic.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testPrefsInManifestVerbose(self): + """ + Check prefs configuration option is supported in xpcshell manifests. + """ + self.writeFile("test_prefs.js", SIMPLE_PREFCHECK_TEST) + self.writeManifest(tests=["test_prefs.js"], prefs=["fake.pref.to.test=true"]) + + self.assertTestResult(True, verbose=True) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertInLog("Per-test extra prefs will be set:") + self.assertInLog("fake.pref.to.test=true") + + def testPrefsInManifestNonVerbose(self): + """ + Check prefs configuration are not logged in non verbose mode. + """ + self.writeFile("test_prefs.js", SIMPLE_PREFCHECK_TEST) + self.writeManifest(tests=["test_prefs.js"], prefs=["fake.pref.to.test=true"]) + + self.assertTestResult(True, verbose=False) + self.assertNotInLog("Per-test extra prefs will be set:") + self.assertNotInLog("fake.pref.to.test=true") + + @unittest.skipIf( + mozinfo.isWin or not mozinfo.info.get("debug"), + "We don't have a stack fixer on hand for windows.", + ) + def testAssertStack(self): + """ + When an assertion is hit, we should produce a useful stack. + """ + self.writeFile( + "test_assert.js", + """ + add_test(function test_asserts_immediately() { + Components.classes["@mozilla.org/xpcom/debug;1"] + .getService(Components.interfaces.nsIDebug2) + .assertion("foo", "assertion failed", "test.js", 1) + run_next_test(); + }); + """, + ) + + self.writeManifest(["test_assert.js"]) + self.assertTestResult(False) + + self.assertInLog("###!!! ASSERTION") + log_lines = self.log.getvalue().splitlines() + line_pat = "#\d\d:" + unknown_pat = "#\d\d\: \?\?\?\[.* \+0x[a-f0-9]+\]" + self.assertFalse( + any(re.search(unknown_pat, line) for line in log_lines), + "An stack frame without symbols was found in\n%s" + % pprint.pformat(log_lines), + ) + self.assertTrue( + any(re.search(line_pat, line) for line in log_lines), + "No line resembling a stack frame was found in\n%s" + % pprint.pformat(log_lines), + ) + + def testChildPass(self): + """ + Check that a simple test running in a child process passes. + """ + self.writeFile("test_pass.js", SIMPLE_PASSING_TEST) + self.writeFile("test_child_pass.js", CHILD_TEST_PASSING) + self.writeManifest(["test_child_pass.js"]) + + self.assertTestResult(True, verbose=True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertInLog("CHILD-TEST-STARTED") + self.assertInLog("CHILD-TEST-COMPLETED") + self.assertNotInLog(TEST_FAIL_STRING) + + def testChildFail(self): + """ + Check that a simple failing test running in a child process fails. + """ + self.writeFile("test_fail.js", SIMPLE_FAILING_TEST) + self.writeFile("test_child_fail.js", CHILD_TEST_FAILING) + self.writeManifest(["test_child_fail.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("CHILD-TEST-STARTED") + self.assertInLog("CHILD-TEST-COMPLETED") + self.assertNotInLog(TEST_PASS_STRING) + + def testChildHang(self): + """ + Check that incomplete output from a child process results in a + test failure. + """ + self.writeFile("test_pass.js", SIMPLE_PASSING_TEST) + self.writeFile("test_child_hang.js", CHILD_TEST_HANG) + self.writeManifest(["test_child_hang.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("CHILD-TEST-STARTED") + self.assertNotInLog("CHILD-TEST-COMPLETED") + self.assertNotInLog(TEST_PASS_STRING) + + def testChild(self): + """ + Checks that calling do_load_child_test_harness without run_test_in_child + results in a usable test state. This test has a spurious failure when + run using |mach python-test|. See bug 1103226. + """ + self.writeFile("test_child_assertions.js", CHILD_HARNESS_SIMPLE) + self.writeManifest(["test_child_assertions.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testSkipForAddTest(self): + """ + Check that add_test is skipped if |skip_if| condition is true + """ + self.writeFile( + "test_skip.js", + """ +add_test({ + skip_if: () => true, +}, function test_should_be_skipped() { + Assert.ok(false); + run_next_test(); +}); +""", + ) + self.writeManifest(["test_skip.js"]) + self.assertTestResult(True, verbose=True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertInLog("TEST-SKIP") + self.assertNotInLog(TEST_FAIL_STRING) + + def testNotSkipForAddTask(self): + """ + Check that add_task is not skipped if |skip_if| condition is false + """ + self.writeFile( + "test_not_skip.js", + """ +add_task({ + skip_if: () => false, +}, function test_should_not_be_skipped() { + Assert.ok(true); +}); +""", + ) + self.writeManifest(["test_not_skip.js"]) + self.assertTestResult(True, verbose=True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog("TEST-SKIP") + self.assertNotInLog(TEST_FAIL_STRING) + + def testSkipForAddTask(self): + """ + Check that add_task is skipped if |skip_if| condition is true + """ + self.writeFile( + "test_skip.js", + """ +add_task({ + skip_if: () => true, +}, function test_should_be_skipped() { + Assert.ok(false); +}); +""", + ) + self.writeManifest(["test_skip.js"]) + self.assertTestResult(True, verbose=True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertInLog("TEST-SKIP") + self.assertNotInLog(TEST_FAIL_STRING) + + def testNotSkipForAddTest(self): + """ + Check that add_test is not skipped if |skip_if| condition is false + """ + self.writeFile( + "test_not_skip.js", + """ +add_test({ + skip_if: () => false, +}, function test_should_not_be_skipped() { + Assert.ok(true); + run_next_test(); +}); +""", + ) + self.writeManifest(["test_not_skip.js"]) + self.assertTestResult(True, verbose=True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog("TEST-SKIP") + self.assertNotInLog(TEST_FAIL_STRING) + + def testSyntaxError(self): + """ + Check that running a test file containing a syntax error produces + a test failure and expected output. + """ + self.writeFile("test_syntax_error.js", '"') + self.writeManifest(["test_syntax_error.js"]) + + self.assertTestResult(False, verbose=True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testUnicodeInAssertMethods(self): + """ + Check that passing unicode characters through an assertion method works. + """ + self.writeFile("test_unicode_assert.js", PASSING_TEST_UNICODE, mode="wb") + self.writeManifest(["test_unicode_assert.js"]) + + self.assertTestResult(True, verbose=True) + + @unittest.skipIf( + "MOZ_AUTOMATION" in os.environ, + "Timeout code path occasionally times out (bug 1098121)", + ) + def testHangingTimeout(self): + """ + Check that a test that never finishes results in the correct error log. + """ + self.writeFile("test_loop.js", SIMPLE_LOOPING_TEST) + self.writeManifest(["test_loop.js"]) + + old_timeout = self.x.harness_timeout + self.x.harness_timeout = 1 + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog("TEST-UNEXPECTED-TIMEOUT") + + self.x.harness_timeout = old_timeout + + def testPassFail(self): + """ + Check that running more than one test works. + """ + self.writeFile("test_pass.js", SIMPLE_PASSING_TEST) + self.writeFile("test_fail.js", SIMPLE_FAILING_TEST) + self.writeManifest(["test_pass.js", "test_fail.js"]) + + self.assertTestResult(False) + self.assertEqual(2, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertInLog(TEST_FAIL_STRING) + + def testSkip(self): + """ + Check that a simple failing test skipped in the manifest does + not cause failure. + """ + self.writeFile("test_basic.js", SIMPLE_FAILING_TEST) + self.writeManifest([("test_basic.js", "skip-if = true")]) + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertNotInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testKnownFail(self): + """ + Check that a simple failing test marked as known-fail in the manifest + does not cause failure. + """ + self.writeFile("test_basic.js", SIMPLE_FAILING_TEST) + self.writeManifest([("test_basic.js", "fail-if = true")]) + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(1, self.x.todoCount) + self.assertInLog("TEST-FAIL") + # This should be suppressed because the harness doesn't include + # the full log from the xpcshell run when things pass. + self.assertNotInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testUnexpectedPass(self): + """ + Check that a simple failing test marked as known-fail in the manifest + that passes causes an unexpected pass. + """ + self.writeFile("test_basic.js", SIMPLE_PASSING_TEST) + self.writeManifest([("test_basic.js", "fail-if = true")]) + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + # From the outer (Python) harness + self.assertInLog("TEST-UNEXPECTED-PASS") + self.assertNotInLog("TEST-KNOWN-FAIL") + + def testReturnNonzero(self): + """ + Check that a test where xpcshell returns nonzero fails. + """ + self.writeFile("test_error.js", "throw 'foo'") + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testUncaughtRejection(self): + """ + Ensure a simple test with an uncaught rejection is reported. + """ + self.writeFile( + "test_simple_uncaught_rejection.js", SIMPLE_UNCAUGHT_REJECTION_TEST + ) + self.writeManifest(["test_simple_uncaught_rejection.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("test_simple_uncaught_rejection.js:3:18") + self.assertInLog("Test rejection.") + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + + def testAddTestSimple(self): + """ + Ensure simple add_test() works. + """ + self.writeFile("test_add_test_simple.js", ADD_TEST_SIMPLE) + self.writeManifest(["test_add_test_simple.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + + def testCrashLogging(self): + """ + Test that a crashing test process logs a failure. + """ + self.writeFile("test_crashes.js", TEST_CRASHING) + self.writeManifest(["test_crashes.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + if mozinfo.info.get("crashreporter"): + self.assertInLog("\nPROCESS-CRASH") + + def testLogCorrectFileName(self): + """ + Make sure a meaningful filename and line number is logged + by a passing test. + """ + self.writeFile("test_add_test_simple.js", ADD_TEST_SIMPLE) + self.writeManifest(["test_add_test_simple.js"]) + + self.assertTestResult(True, verbose=True) + self.assertInLog("true == true") + self.assertNotInLog("[Assert.ok :") + self.assertInLog("[test_simple : 5]") + + def testAddTestFailing(self): + """ + Ensure add_test() with a failing test is reported. + """ + self.writeFile("test_add_test_failing.js", ADD_TEST_FAILING) + self.writeManifest(["test_add_test_failing.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + + def testAddTestUncaughtRejection(self): + """ + Ensure add_test() with an uncaught rejection is reported. + """ + self.writeFile( + "test_add_test_uncaught_rejection.js", ADD_TEST_UNCAUGHT_REJECTION + ) + self.writeManifest(["test_add_test_uncaught_rejection.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + + def testAddTaskTestSingle(self): + """ + Ensure add_test_task() with a single passing test works. + """ + self.writeFile("test_add_task_simple.js", ADD_TASK_SINGLE) + self.writeManifest(["test_add_task_simple.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + + def testAddTaskTestMultiple(self): + """ + Ensure multiple calls to add_test_task() work as expected. + """ + self.writeFile("test_add_task_multiple.js", ADD_TASK_MULTIPLE) + self.writeManifest(["test_add_task_multiple.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + + def testAddTaskTestRejected(self): + """ + Ensure rejected task reports as failure. + """ + self.writeFile("test_add_task_rejected.js", ADD_TASK_REJECTED) + self.writeManifest(["test_add_task_rejected.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + + def testAddTaskTestFailureInside(self): + """ + Ensure tests inside task are reported as failures. + """ + self.writeFile("test_add_task_failure_inside.js", ADD_TASK_FAILURE_INSIDE) + self.writeManifest(["test_add_task_failure_inside.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + + def testAddTaskRunNextTest(self): + """ + Calling run_next_test() from inside add_task() results in failure. + """ + self.writeFile("test_add_task_run_next_test.js", ADD_TASK_RUN_NEXT_TEST) + self.writeManifest(["test_add_task_run_next_test.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + + def testAddTaskStackTrace(self): + """ + Ensuring that calling Assert.ok(false) from inside add_task() + results in a human-readable stack trace. + """ + self.writeFile("test_add_task_stack_trace.js", ADD_TASK_STACK_TRACE) + self.writeManifest(["test_add_task_stack_trace.js"]) + + self.assertTestResult(False) + self.assertInLog("this_test_will_fail") + self.assertInLog("run_next_test") + self.assertInLog("run_test") + self.assertNotInLog("Task.jsm") + + def testAddTaskSkip(self): + self.writeFile("test_tasks_skip.js", ADD_TASK_SKIP) + self.writeManifest(["test_tasks_skip.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + + def testAddTaskSkipAll(self): + self.writeFile("test_tasks_skipall.js", ADD_TASK_SKIPALL) + self.writeManifest(["test_tasks_skipall.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + + def testMissingHeadFile(self): + """ + Ensure that missing head file results in fatal error. + """ + self.writeFile("test_basic.js", SIMPLE_PASSING_TEST) + self.writeManifest([("test_basic.js", "head = missing.js")]) + + raised = False + + try: + # The actual return value is never checked because we raise. + self.assertTestResult(True) + except Exception as ex: + raised = True + self.assertEqual(str(ex)[0:9], "head file") + + self.assertTrue(raised) + + def testRandomExecution(self): + """ + Check that random execution doesn't break. + """ + manifest = [] + for i in range(0, 10): + filename = "test_pass_%d.js" % i + self.writeFile(filename, SIMPLE_PASSING_TEST) + manifest.append(filename) + + self.writeManifest(manifest) + self.assertTestResult(True, shuffle=True) + self.assertEqual(10, self.x.testCount) + self.assertEqual(10, self.x.passCount) + + def testDoThrowString(self): + """ + Check that do_throw produces reasonable messages when the + input is a string instead of an object + """ + self.writeFile("test_error.js", ADD_TEST_THROW_STRING) + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("Passing a string to do_throw") + self.assertNotInLog(TEST_PASS_STRING) + + def testDoThrowForeignObject(self): + """ + Check that do_throw produces reasonable messages when the + input is a generic object with 'filename', 'message' and 'stack' attributes + but 'object instanceof Error' returns false + """ + self.writeFile("test_error.js", ADD_TEST_THROW_OBJECT) + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("failure.js") + self.assertInLog("Error object") + self.assertInLog("ERROR STACK") + self.assertNotInLog(TEST_PASS_STRING) + + def testDoReportForeignObject(self): + """ + Check that do_report_unexpected_exception produces reasonable messages when the + input is a generic object with 'filename', 'message' and 'stack' attributes + but 'object instanceof Error' returns false + """ + self.writeFile("test_error.js", ADD_TEST_REPORT_OBJECT) + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("failure.js") + self.assertInLog("Error object") + self.assertInLog("ERROR STACK") + self.assertNotInLog(TEST_PASS_STRING) + + def testDoReportRefError(self): + """ + Check that do_report_unexpected_exception produces reasonable messages when the + input is a JS-generated Error + """ + self.writeFile("test_error.js", ADD_TEST_REPORT_REF_ERROR) + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("test_error.js") + self.assertInLog("obj.noSuchFunction is not a function") + self.assertInLog("run_test@") + self.assertNotInLog(TEST_PASS_STRING) + + def testDoReportSyntaxError(self): + """ + Check that attempting to load a test file containing a syntax error + generates details of the error in the log + """ + self.writeFile("test_error.js", LOAD_ERROR_SYNTAX_ERROR) + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("test_error.js:3") + self.assertNotInLog(TEST_PASS_STRING) + + def testDoReportNonSyntaxError(self): + """ + Check that attempting to load a test file containing an error other + than a syntax error generates details of the error in the log + """ + self.writeFile("test_error.js", LOAD_ERROR_OTHER_ERROR) + self.writeManifest(["test_error.js"]) + + self.assertTestResult(False) + self.assertInLog(TEST_FAIL_STRING) + self.assertInLog("ReferenceError: assignment to undeclared variable") + self.assertInLog("test_error.js:3") + self.assertNotInLog(TEST_PASS_STRING) + + def testDoPrintWhenVerboseNotExplicit(self): + """ + Check that info() and similar calls that generate output do + not have the output when not run verbosely. + """ + self.writeFile("test_verbose.js", ADD_TEST_VERBOSE) + self.writeManifest(["test_verbose.js"]) + + self.assertTestResult(True) + self.assertNotInLog("a message from info") + + def testDoPrintWhenVerboseExplicit(self): + """ + Check that info() and similar calls that generate output have the + output shown when run verbosely. + """ + self.writeFile("test_verbose.js", ADD_TEST_VERBOSE) + self.writeManifest(["test_verbose.js"]) + self.assertTestResult(True, verbose=True) + self.assertInLog("a message from info") + + def testDoPrintWhenVerboseInManifest(self): + """ + Check that info() and similar calls that generate output have the + output shown when 'verbose = true' is in the manifest, even when + not run verbosely. + """ + self.writeFile("test_verbose.js", ADD_TEST_VERBOSE) + self.writeManifest([("test_verbose.js", "verbose = true")]) + + self.assertTestResult(True) + self.assertInLog("a message from info") + + def testAsyncCleanup(self): + """ + Check that registerCleanupFunction handles nicely async cleanup tasks + """ + self.writeFile("test_asyncCleanup.js", ASYNC_CLEANUP) + self.writeManifest(["test_asyncCleanup.js"]) + self.assertTestResult(False) + self.assertInLog('"123456" == "123456"') + self.assertInLog("At this stage, the test has succeeded") + self.assertInLog("Throwing an error to force displaying the log") + + def testNoRunTestAddTest(self): + """ + Check that add_test() works fine without run_test() in the test file. + """ + self.writeFile("test_noRunTestAddTest.js", NO_RUN_TEST_ADD_TEST) + self.writeManifest(["test_noRunTestAddTest.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testNoRunTestAddTask(self): + """ + Check that add_task() works fine without run_test() in the test file. + """ + self.writeFile("test_noRunTestAddTask.js", NO_RUN_TEST_ADD_TASK) + self.writeManifest(["test_noRunTestAddTask.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testNoRunTestAddTestAddTask(self): + """ + Check that both add_test() and add_task() work without run_test() + in the test file. + """ + self.writeFile("test_noRunTestAddTestAddTask.js", NO_RUN_TEST_ADD_TEST_ADD_TASK) + self.writeManifest(["test_noRunTestAddTestAddTask.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testNoRunTestEmptyTest(self): + """ + Check that the test passes on an empty file that contains neither + run_test() nor add_test(), add_task(). + """ + self.writeFile("test_noRunTestEmptyTest.js", NO_RUN_TEST_EMPTY_TEST) + self.writeManifest(["test_noRunTestEmptyTest.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testNoRunTestAddTestFail(self): + """ + Check that test fails on using add_test() without run_test(). + """ + self.writeFile("test_noRunTestAddTestFail.js", NO_RUN_TEST_ADD_TEST_FAIL) + self.writeManifest(["test_noRunTestAddTestFail.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testNoRunTestAddTaskFail(self): + """ + Check that test fails on using add_task() without run_test(). + """ + self.writeFile("test_noRunTestAddTaskFail.js", NO_RUN_TEST_ADD_TASK_FAIL) + self.writeManifest(["test_noRunTestAddTaskFail.js"]) + + self.assertTestResult(False) + self.assertEqual(1, self.x.testCount) + self.assertEqual(0, self.x.passCount) + self.assertEqual(1, self.x.failCount) + self.assertInLog(TEST_FAIL_STRING) + self.assertNotInLog(TEST_PASS_STRING) + + def testNoRunTestAddTaskMultiple(self): + """ + Check that multple add_task() tests work without run_test(). + """ + self.writeFile( + "test_noRunTestAddTaskMultiple.js", NO_RUN_TEST_ADD_TASK_MULTIPLE + ) + self.writeManifest(["test_noRunTestAddTaskMultiple.js"]) + + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testMozinfo(self): + """ + Check that mozinfo.json is loaded + """ + self.writeFile("test_mozinfo.js", LOAD_MOZINFO) + self.writeManifest(["test_mozinfo.js"]) + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testChildMozinfo(self): + """ + Check that mozinfo.json is loaded in child process + """ + self.writeFile("test_mozinfo.js", LOAD_MOZINFO) + self.writeFile("test_child_mozinfo.js", CHILD_MOZINFO) + self.writeManifest(["test_child_mozinfo.js"]) + self.assertTestResult(True) + self.assertEqual(1, self.x.testCount) + self.assertEqual(1, self.x.passCount) + self.assertEqual(0, self.x.failCount) + self.assertEqual(0, self.x.todoCount) + self.assertInLog(TEST_PASS_STRING) + self.assertNotInLog(TEST_FAIL_STRING) + + def testNotHeadlessByDefault(self): + """ + Check that the default is not headless. + """ + self.writeFile("test_notHeadlessByDefault.js", HEADLESS_FALSE) + self.writeManifest(["test_notHeadlessByDefault.js"]) + self.assertTestResult(True) + + def testHeadlessWhenHeadlessExplicit(self): + """ + Check that explicitly requesting headless works when the manifest doesn't override. + """ + self.writeFile("test_headlessWhenExplicit.js", HEADLESS_TRUE) + self.writeManifest(["test_headlessWhenExplicit.js"]) + self.assertTestResult(True, headless=True) + + def testHeadlessWhenHeadlessTrueInManifest(self): + """ + Check that enabling headless in the manifest alone works. + """ + self.writeFile("test_headlessWhenTrueInManifest.js", HEADLESS_TRUE) + self.writeManifest([("test_headlessWhenTrueInManifest.js", "headless = true")]) + self.assertTestResult(True) + + def testNotHeadlessWhenHeadlessFalseInManifest(self): + """ + Check that the manifest entry overrides the explicit default. + """ + self.writeFile("test_notHeadlessWhenFalseInManifest.js", HEADLESS_FALSE) + self.writeManifest( + [("test_notHeadlessWhenFalseInManifest.js", "headless = false")] + ) + self.assertTestResult(True, headless=True) + + +if __name__ == "__main__": + import mozunit + + mozinfo.find_and_update_from_json() + mozunit.main() diff --git a/testing/xpcshell/xpcshellcommandline.py b/testing/xpcshell/xpcshellcommandline.py new file mode 100644 index 0000000000..eec168e0d8 --- /dev/null +++ b/testing/xpcshell/xpcshellcommandline.py @@ -0,0 +1,420 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import argparse + +from mozlog import commandline + + +def add_common_arguments(parser): + parser.add_argument( + "--app-binary", + type=str, + dest="app_binary", + default=None, + help="path to application binary (eg: c:\program files\mozilla firefox\firefox.exe)", + ) + parser.add_argument( + "--app-path", + type=str, + dest="appPath", + default=None, + help="application directory (as opposed to XRE directory)", + ) + parser.add_argument( + "--interactive", + action="store_true", + dest="interactive", + default=False, + help="don't automatically run tests, drop to an xpcshell prompt", + ) + parser.add_argument( + "--verbose", + action="store_true", + dest="verbose", + default=False, + help="always print stdout and stderr from tests", + ) + parser.add_argument( + "--verbose-if-fails", + action="store_true", + dest="verboseIfFails", + default=False, + help="Output the log if a test fails, even when run in parallel", + ) + parser.add_argument( + "--keep-going", + action="store_true", + dest="keepGoing", + default=False, + help="continue running tests after test killed with control-C (SIGINT)", + ) + parser.add_argument( + "--logfiles", + action="store_true", + dest="logfiles", + default=True, + help="create log files (default, only used to override --no-logfiles)", + ) + parser.add_argument( + "--dump-tests", + type=str, + dest="dump_tests", + default=None, + help="Specify path to a filename to dump all the tests that will be run", + ) + parser.add_argument( + "--manifest", + type=str, + dest="manifest", + default=None, + help="Manifest of test directories to use", + ) + parser.add_argument( + "--no-logfiles", + action="store_false", + dest="logfiles", + help="don't create log files", + ) + parser.add_argument( + "--sequential", + action="store_true", + dest="sequential", + default=False, + help="Run all tests sequentially", + ) + parser.add_argument( + "--temp-dir", + dest="tempDir", + default=None, + help="Directory to use for temporary files", + ) + parser.add_argument( + "--testing-modules-dir", + dest="testingModulesDir", + default=None, + help="Directory where testing modules are located.", + ) + parser.add_argument( + "--total-chunks", + type=int, + dest="totalChunks", + default=1, + help="how many chunks to split the tests up into", + ) + parser.add_argument( + "--this-chunk", + type=int, + dest="thisChunk", + default=1, + help="which chunk to run between 1 and --total-chunks", + ) + parser.add_argument( + "--profile-name", + type=str, + dest="profileName", + default=None, + help="name of application profile being tested", + ) + parser.add_argument( + "--build-info-json", + type=str, + dest="mozInfo", + default=None, + help="path to a mozinfo.json including information about the build " + "configuration. defaults to looking for mozinfo.json next to " + "the script.", + ) + parser.add_argument( + "--shuffle", + action="store_true", + dest="shuffle", + default=False, + help="Execute tests in random order", + ) + parser.add_argument( + "--xre-path", + action="store", + type=str, + dest="xrePath", + # individual scripts will set a sane default + default=None, + help="absolute path to directory containing XRE (probably xulrunner)", + ) + parser.add_argument( + "--symbols-path", + action="store", + type=str, + dest="symbolsPath", + default=None, + help="absolute path to directory containing breakpad symbols, " + "or the URL of a zip file containing symbols", + ) + parser.add_argument( + "--jscov-dir-prefix", + action="store", + type=str, + dest="jscovdir", + default=argparse.SUPPRESS, + help="Directory to store per-test javascript line coverage data as json.", + ) + parser.add_argument( + "--debugger", + action="store", + dest="debugger", + help="use the given debugger to launch the application", + ) + parser.add_argument( + "--debugger-args", + action="store", + dest="debuggerArgs", + help="pass the given args to the debugger _before_ " + "the application on the command line", + ) + parser.add_argument( + "--debugger-interactive", + action="store_true", + dest="debuggerInteractive", + help="prevents the test harness from redirecting " + "stdout and stderr for interactive debuggers", + ) + parser.add_argument( + "--jsdebugger", + dest="jsDebugger", + action="store_true", + help="Waits for a devtools JS debugger to connect before " "starting the test.", + ) + parser.add_argument( + "--jsdebugger-port", + type=int, + dest="jsDebuggerPort", + default=6000, + help="The port to listen on for a debugger connection if " + "--jsdebugger is specified.", + ) + parser.add_argument( + "--tag", + action="append", + dest="test_tags", + default=None, + help="filter out tests that don't have the given tag. Can be " + "used multiple times in which case the test must contain " + "at least one of the given tags.", + ) + parser.add_argument( + "--utility-path", + action="store", + dest="utility_path", + default=None, + help="Path to a directory containing utility programs, such " + "as stack fixer scripts.", + ) + parser.add_argument( + "--xpcshell", + action="store", + dest="xpcshell", + default=None, + help="Path to xpcshell binary", + ) + parser.add_argument( + "--http3server", + action="store", + dest="http3server", + default=None, + help="Path to http3server binary", + ) + # This argument can be just present, or the path to a manifest file. The + # just-present case is usually used for mach which can provide a default + # path to the failure file from the previous run + parser.add_argument( + "--rerun-failures", + action="store_true", + help="Rerun failures from the previous run, if any", + ) + parser.add_argument( + "--failure-manifest", + action="store", + help="Path to a manifest file from which to rerun failures " + "(with --rerun-failure) or in which to record failed tests", + ) + parser.add_argument( + "--threads", + type=int, + dest="threadCount", + default=0, + help="override the number of jobs (threads) when running tests " + "in parallel, the default is CPU x 1.5 when running via mach " + "and CPU x 4 when running in automation", + ) + parser.add_argument( + "--setpref", + action="append", + dest="extraPrefs", + metavar="PREF=VALUE", + help="Defines an extra user preference (can be passed multiple times.", + ) + parser.add_argument( + "testPaths", nargs="*", default=None, help="Paths of tests to run." + ) + parser.add_argument( + "--verify", + action="store_true", + default=False, + help="Run tests in verification mode: Run many times in different " + "ways, to see if there are intermittent failures.", + ) + parser.add_argument( + "--verify-max-time", + dest="verifyMaxTime", + type=int, + default=3600, + help="Maximum time, in seconds, to run in --verify mode.", + ) + parser.add_argument( + "--headless", + action="store_true", + default=False, + dest="headless", + help="Enable headless mode by default for tests which don't specify " + "whether to use headless mode", + ) + parser.add_argument( + "--conditioned-profile", + action="store_true", + default=False, + dest="conditionedProfile", + help="Run with conditioned profile instead of fresh blank profile", + ) + parser.add_argument( + "--self-test", + action="store_true", + default=False, + dest="self_test", + help="Run self tests", + ) + parser.add_argument( + "--run-failures", + action="store", + default="", + dest="runFailures", + help="Run failures matching keyword", + ) + parser.add_argument( + "--timeout-as-pass", + action="store_true", + default=False, + dest="timeoutAsPass", + help="Harness level timeouts will be treated as passing", + ) + parser.add_argument( + "--crash-as-pass", + action="store_true", + default=False, + dest="crashAsPass", + help="Harness level crashes will be treated as passing", + ) + parser.add_argument( + "--disable-fission", + action="store_true", + default=False, + dest="disableFission", + help="disable fission mode (back to e10s || 1proc)", + ) + parser.add_argument( + "--repeat", + action="store", + default=0, + type=int, + dest="repeat", + help="repeat the test X times, default [0]", + ) + + +def add_remote_arguments(parser): + parser.add_argument( + "--objdir", + action="store", + type=str, + dest="objdir", + help="Local objdir, containing xpcshell binaries.", + ) + + parser.add_argument( + "--apk", + action="store", + type=str, + dest="localAPK", + help="Local path to Firefox for Android APK.", + ) + + parser.add_argument( + "--deviceSerial", + action="store", + type=str, + dest="deviceSerial", + help="adb serial number of remote device. This is required " + "when more than one device is connected to the host. " + "Use 'adb devices' to see connected devices.", + ) + + parser.add_argument( + "--adbPath", + action="store", + type=str, + dest="adbPath", + default=None, + help="Path to adb binary.", + ) + + parser.add_argument( + "--noSetup", + action="store_false", + dest="setup", + default=True, + help="Do not copy any files to device (to be used only if " + "device is already setup).", + ) + parser.add_argument( + "--no-install", + action="store_false", + dest="setup", + default=True, + help="Don't install the app or any files to the device (to be used if " + "the device is already set up)", + ) + + parser.add_argument( + "--local-bin-dir", + action="store", + type=str, + dest="localBin", + help="Local path to bin directory.", + ) + + parser.add_argument( + "--remoteTestRoot", + action="store", + type=str, + dest="remoteTestRoot", + help="Remote directory to use as test root " "(eg. /data/local/tmp/test_root).", + ) + + +def parser_desktop(): + parser = argparse.ArgumentParser() + add_common_arguments(parser) + commandline.add_logging_group(parser) + + return parser + + +def parser_remote(): + parser = argparse.ArgumentParser() + common = parser.add_argument_group("Common Options") + add_common_arguments(common) + remote = parser.add_argument_group("Remote Options") + add_remote_arguments(remote) + commandline.add_logging_group(parser) + + return parser -- cgit v1.2.3