From 8062c625dfc8ff0991c05ff2a47457e7da30c462 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=A9my=20Lal?= Date: Sun, 26 Jan 2025 16:18:40 +0100 Subject: [PATCH] Import node-undici_7.3.0+dfsg1+~cs24.12.11.orig.tar.xz [dgit import orig node-undici_7.3.0+dfsg1+~cs24.12.11.orig.tar.xz] --- .c8rc.json | 13 + .dockerignore | 8 + .editorconfig | 9 + .github/ISSUE_TEMPLATE/bug-report.md | 34 + .github/ISSUE_TEMPLATE/feature-request.md | 28 + .github/PULL_REQUEST_TEMPLATE.md | 53 + .github/dependabot.yml | 35 + .github/workflows/autobahn.yml | 60 + .github/workflows/backport.yml | 29 + .github/workflows/bench.yml | 137 + .github/workflows/codeql.yml | 78 + .github/workflows/nightly.yml | 79 + .github/workflows/nodejs-shared.yml | 102 + .github/workflows/nodejs.yml | 223 + .github/workflows/release-create-pr.yml | 57 + .github/workflows/release.yml | 74 + .github/workflows/scorecard.yml | 56 + .github/workflows/test.yml | 64 + .github/workflows/triggered-autobahn.yml | 16 + .github/workflows/update-cache-tests.yml | 45 + .github/workflows/update-wpt.yml | 51 + .gitignore | 89 + .husky/pre-commit | 1 + .npmignore | 16 + CODE_OF_CONDUCT.md | 6 + CONTRIBUTING.md | 213 + GOVERNANCE.md | 136 + LICENSE | 21 + MAINTAINERS.md | 33 + README.md | 466 ++ SECURITY.md | 2 + benchmarks/_util/index.js | 53 + benchmarks/api/util.mjs | 37 + benchmarks/benchmark-http2.js | 283 + benchmarks/benchmark-https.js | 291 + benchmarks/benchmark.js | 342 ++ benchmarks/cache/date.mjs | 71 + benchmarks/cache/get-field-values.mjs | 23 + benchmarks/cookies/Is-ctl-excluding-htab.mjs | 17 + benchmarks/cookies/to-imf-date.mjs | 12 + benchmarks/cookies/validate-cookie-name.mjs | 12 + benchmarks/cookies/validate-cookie-value.mjs | 16 + benchmarks/core/is-blob-like.mjs | 64 + benchmarks/core/is-valid-header-char.mjs | 57 + benchmarks/core/is-valid-port.mjs | 16 + benchmarks/core/parse-headers.mjs | 105 + benchmarks/core/parse-raw-headers.mjs | 24 + benchmarks/core/request-instantiation.mjs | 12 + benchmarks/core/tree.mjs | 20 + benchmarks/fetch/body-arraybuffer.mjs | 24 + benchmarks/fetch/bytes-match.mjs | 24 + benchmarks/fetch/headers-length32.mjs | 52 + benchmarks/fetch/headers.mjs | 53 + benchmarks/fetch/is-valid-encoded-url.mjs | 14 + benchmarks/fetch/is-valid-header-value.mjs | 38 + benchmarks/fetch/isomorphic-encode.mjs | 75 + benchmarks/fetch/request-creation.mjs | 8 + benchmarks/fetch/url-has-https-scheme.mjs | 22 + benchmarks/fetch/webidl-is.mjs | 26 + benchmarks/package.json | 26 + benchmarks/post-benchmark.js | 384 ++ benchmarks/server-http2.js | 55 + benchmarks/server-https.js | 41 + benchmarks/server.js | 44 + benchmarks/timers/compare-timer-getters.mjs | 18 + benchmarks/wait.js | 22 + benchmarks/websocket/generate-mask.mjs | 22 + benchmarks/websocket/is-valid-subprotocol.mjs | 17 + benchmarks/websocket/messageevent.mjs | 20 + build/wasm.js | 119 + docs/.nojekyll | 0 docs/CNAME | 1 + docs/README.md | 1 + docs/docs/api/Agent.md | 77 + docs/docs/api/BalancedPool.md | 99 + docs/docs/api/CacheStorage.md | 30 + docs/docs/api/CacheStore.md | 131 + docs/docs/api/Client.md | 281 + docs/docs/api/Connector.md | 115 + docs/docs/api/ContentType.md | 57 + docs/docs/api/Cookies.md | 101 + docs/docs/api/Debug.md | 62 + docs/docs/api/DiagnosticsChannel.md | 204 + docs/docs/api/Dispatcher.md | 1200 ++++ docs/docs/api/EnvHttpProxyAgent.md | 161 + docs/docs/api/Errors.md | 48 + docs/docs/api/EventSource.md | 45 + docs/docs/api/Fetch.md | 52 + docs/docs/api/MockAgent.md | 542 ++ docs/docs/api/MockClient.md | 77 + docs/docs/api/MockErrors.md | 12 + docs/docs/api/MockPool.md | 548 ++ docs/docs/api/Pool.md | 83 + docs/docs/api/PoolStats.md | 35 + docs/docs/api/ProxyAgent.md | 226 + docs/docs/api/RedirectHandler.md | 96 + docs/docs/api/RetryAgent.md | 45 + docs/docs/api/RetryHandler.md | 117 + docs/docs/api/Util.md | 25 + docs/docs/api/WebSocket.md | 85 + docs/docs/api/api-lifecycle.md | 91 + .../docs/best-practices/client-certificate.md | 64 + docs/docs/best-practices/mocking-request.md | 136 + docs/docs/best-practices/proxy.md | 127 + docs/docs/best-practices/writing-tests.md | 20 + docs/docsify/sidebar.md | 39 + docs/examples/README.md | 142 + docs/examples/ca-fingerprint/index.js | 80 + docs/examples/eventsource.js | 20 + docs/examples/fetch.js | 13 + docs/examples/proxy-agent.js | 25 + docs/examples/proxy/fetch.mjs | 35 + docs/examples/proxy/index.js | 49 + docs/examples/proxy/proxy.js | 318 ++ docs/examples/proxy/websocket.js | 91 + docs/examples/request.js | 94 + docs/index.html | 107 + docs/package.json | 11 + eslint.config.js | 28 + index-fetch.js | 32 + index.d.ts | 3 + index.js | 178 + lib/api/abort-signal.js | 59 + lib/api/api-connect.js | 110 + lib/api/api-pipeline.js | 252 + lib/api/api-request.js | 199 + lib/api/api-stream.js | 209 + lib/api/api-upgrade.js | 110 + lib/api/index.js | 7 + lib/api/readable.js | 558 ++ lib/api/util.js | 95 + lib/cache/memory-cache-store.js | 177 + lib/cache/sqlite-cache-store.js | 458 ++ lib/core/connect.js | 240 + lib/core/constants.js | 143 + lib/core/diagnostics.js | 196 + lib/core/errors.js | 244 + lib/core/request.js | 397 ++ lib/core/symbols.js | 68 + lib/core/tree.js | 160 + lib/core/util.js | 912 +++ lib/dispatcher/agent.js | 115 + lib/dispatcher/balanced-pool.js | 206 + lib/dispatcher/client-h1.js | 1615 ++++++ lib/dispatcher/client-h2.js | 795 +++ lib/dispatcher/client.js | 609 ++ lib/dispatcher/dispatcher-base.js | 161 + lib/dispatcher/dispatcher.js | 48 + lib/dispatcher/env-http-proxy-agent.js | 160 + lib/dispatcher/fixed-queue.js | 159 + lib/dispatcher/pool-base.js | 194 + lib/dispatcher/pool-stats.js | 36 + lib/dispatcher/pool.js | 90 + lib/dispatcher/proxy-agent.js | 189 + lib/dispatcher/retry-agent.js | 35 + lib/global.js | 32 + lib/handler/cache-handler.js | 448 ++ lib/handler/cache-revalidation-handler.js | 124 + lib/handler/decorator-handler.js | 67 + lib/handler/redirect-handler.js | 227 + lib/handler/retry-handler.js | 342 ++ lib/handler/unwrap-handler.js | 96 + lib/handler/wrap-handler.js | 95 + lib/interceptor/cache.js | 362 ++ lib/interceptor/dns.js | 432 ++ lib/interceptor/dump.js | 111 + lib/interceptor/redirect.js | 21 + lib/interceptor/response-error.js | 95 + lib/interceptor/retry.js | 19 + lib/llhttp/.gitkeep | 0 lib/llhttp/constants.d.ts | 97 + lib/llhttp/constants.js | 498 ++ lib/llhttp/utils.d.ts | 2 + lib/llhttp/utils.js | 15 + lib/mock/mock-agent.js | 157 + lib/mock/mock-client.js | 64 + lib/mock/mock-errors.js | 19 + lib/mock/mock-interceptor.js | 209 + lib/mock/mock-pool.js | 64 + lib/mock/mock-symbols.js | 25 + lib/mock/mock-utils.js | 391 ++ lib/mock/pending-interceptors-formatter.js | 43 + lib/util/cache.js | 359 ++ lib/util/date.js | 259 + lib/util/timers.js | 423 ++ lib/web/cache/cache.js | 862 +++ lib/web/cache/cachestorage.js | 152 + lib/web/cache/util.js | 45 + lib/web/cookies/constants.js | 12 + lib/web/cookies/index.js | 199 + lib/web/cookies/parse.js | 322 ++ lib/web/cookies/util.js | 282 + lib/web/eventsource/eventsource-stream.js | 399 ++ lib/web/eventsource/eventsource.js | 484 ++ lib/web/eventsource/util.js | 37 + lib/web/fetch/LICENSE | 21 + lib/web/fetch/body.js | 532 ++ lib/web/fetch/constants.js | 131 + lib/web/fetch/data-url.js | 744 +++ lib/web/fetch/dispatcher-weakref.js | 46 + lib/web/fetch/formdata-parser.js | 501 ++ lib/web/fetch/formdata.js | 263 + lib/web/fetch/global.js | 40 + lib/web/fetch/headers.js | 719 +++ lib/web/fetch/index.js | 2257 ++++++++ lib/web/fetch/request.js | 1096 ++++ lib/web/fetch/response.js | 636 +++ lib/web/fetch/util.js | 1782 ++++++ lib/web/fetch/webidl.js | 740 +++ lib/web/websocket/connection.js | 325 ++ lib/web/websocket/constants.js | 126 + lib/web/websocket/events.js | 331 ++ lib/web/websocket/frame.js | 138 + lib/web/websocket/permessage-deflate.js | 70 + lib/web/websocket/receiver.js | 454 ++ lib/web/websocket/sender.js | 109 + lib/web/websocket/stream/websocketerror.js | 83 + lib/web/websocket/stream/websocketstream.js | 485 ++ lib/web/websocket/util.js | 338 ++ lib/web/websocket/websocket.js | 686 +++ package.json | 149 + scripts/clean-coverage.js | 15 + scripts/generate-pem.js | 4 + scripts/generate-undici-types-package-json.js | 30 + scripts/platform-shell.js | 12 + scripts/release.js | 73 + scripts/strip-comments.js | 10 + scripts/verifyVersion.js | 17 + test/autobahn/.gitignore | 1 + test/autobahn/client.js | 52 + test/autobahn/config/fuzzingserver.json | 7 + test/autobahn/report.js | 106 + test/autobahn/run.sh | 6 + test/busboy/LICENSE | 19 + test/busboy/issue-3676.js | 24 + test/busboy/issue-3760.js | 59 + test/busboy/test-types-multipart-charsets.js | 73 + test/busboy/test-types-multipart.js | 629 +++ .../cache-store-test-utils.js | 354 ++ test/cache-interceptor/cache-tests-worker.mjs | 219 + test/cache-interceptor/cache-tests.mjs | 273 + .../memory-cache-store-tests.js | 6 + .../sqlite-cache-store-tests.js | 224 + test/cache-interceptor/utils.js | 242 + test/cache/cache.js | 12 + test/cache/cachestorage.js | 12 + test/cache/get-field-values.js | 19 + test/client-connect.js | 45 + test/client-head-reset-override.js | 68 + test/client-idempotent-body.js | 47 + test/client-keep-alive.js | 377 ++ test/client-node-max-header-size.js | 63 + test/client-pipeline.js | 1102 ++++ test/client-pipelining.js | 777 +++ test/client-post.js | 83 + test/client-reconnect.js | 57 + test/client-request.js | 1375 +++++ test/client-stream.js | 834 +++ test/client-timeout.js | 206 + test/client-unref.js | 53 + test/client-upgrade.js | 474 ++ test/client-wasm.js | 365 ++ test/client-write-max-listeners.js | 56 + test/client.js | 2168 +++++++ test/close-and-destroy.js | 365 ++ test/connect-abort.js | 31 + test/connect-errconnect.js | 35 + test/connect-pre-shared-session.js | 50 + test/connect-timeout.js | 83 + test/content-length.js | 462 ++ test/cookie/cookies.js | 711 +++ test/cookie/global-headers.js | 68 + test/cookie/is-ctl-excluding-htab.js | 85 + test/cookie/npm-cookie.js | 113 + test/cookie/to-imf-date.js | 21 + test/cookie/validate-cookie-name.js | 130 + test/cookie/validate-cookie-path.js | 59 + test/cookie/validate-cookie-value.js | 78 + test/decorator-handler.js | 405 ++ test/dispatcher.js | 39 + test/env-http-proxy-agent.js | 484 ++ test/errors.js | 77 + test/esm-wrapper.js | 14 + test/eventsource/eventsource-attributes.js | 91 + test/eventsource/eventsource-close.js | 59 + test/eventsource/eventsource-connect.js | 184 + .../eventsource-constructor-stringify.js | 31 + test/eventsource/eventsource-constructor.js | 53 + .../eventsource-custom-dispatcher.js | 38 + test/eventsource/eventsource-message.js | 365 ++ test/eventsource/eventsource-properties.js | 15 + test/eventsource/eventsource-reconnect.js | 155 + test/eventsource/eventsource-redirecting.js | 119 + .../eventsource-request-status-error.js | 36 + test/eventsource/eventsource-stream-bom.js | 135 + .../eventsource-stream-parse-line.js | 281 + .../eventsource-stream-process-event.js | 137 + test/eventsource/eventsource-stream.js | 298 + test/eventsource/eventsource.js | 14 + test/eventsource/util.js | 18 + test/examples.js | 68 + test/fetch/407-statuscode-window-null.js | 23 + test/fetch/abort.js | 52 + test/fetch/abort2.js | 60 + test/fetch/about-uri.js | 20 + test/fetch/blob-uri.js | 89 + test/fetch/bundle.js | 39 + test/fetch/client-error-stack-trace.js | 27 + test/fetch/client-fetch.js | 711 +++ test/fetch/client-node-max-header-size.js | 45 + test/fetch/content-length.js | 31 + test/fetch/cookies.js | 111 + test/fetch/data-uri.js | 194 + test/fetch/encoding.js | 60 + test/fetch/exiting.js | 39 + test/fetch/export-env-proxy-agent.js | 15 + test/fetch/fetch-leak.js | 52 + test/fetch/fetch-timeouts.js | 57 + test/fetch/fetch-url-after-redirect.js | 61 + test/fetch/fire-and-forget.js | 58 + test/fetch/formdata-inspect-custom.js | 16 + test/fetch/formdata.js | 388 ++ test/fetch/general.js | 27 + test/fetch/headers-case.js | 32 + test/fetch/headers-inspect-custom.js | 17 + test/fetch/headers.js | 782 +++ test/fetch/headerslist-sortedarray.js | 38 + test/fetch/http2.js | 509 ++ test/fetch/integrity.js | 349 ++ test/fetch/issue-1447.js | 41 + test/fetch/issue-1711.js | 60 + test/fetch/issue-2009.js | 32 + test/fetch/issue-2021.js | 34 + test/fetch/issue-2171.js | 26 + test/fetch/issue-2242.js | 54 + test/fetch/issue-2294-patch-method.js | 22 + test/fetch/issue-2318.js | 27 + test/fetch/issue-2828.js | 32 + test/fetch/issue-2898-comment.js | 42 + test/fetch/issue-2898.js | 33 + test/fetch/issue-3267.js | 18 + test/fetch/issue-3334.js | 27 + test/fetch/issue-3616.js | 48 + test/fetch/issue-3624.js | 29 + test/fetch/issue-3630.js | 12 + test/fetch/issue-3767.js | 30 + test/fetch/issue-node-46525.js | 28 + test/fetch/issue-node-56474.js | 30 + test/fetch/issue-rsshub-15532.js | 25 + test/fetch/iterators.js | 121 + test/fetch/long-lived-abort-controller.js | 48 + test/fetch/max-listeners.js | 16 + test/fetch/pull-dont-push.js | 54 + test/fetch/redirect-cross-origin-header.js | 51 + test/fetch/redirect.js | 78 + test/fetch/referrrer-policy.js | 122 + test/fetch/relative-url.js | 104 + test/fetch/request-inspect-custom.js | 22 + test/fetch/request.js | 461 ++ test/fetch/resource-timing.js | 141 + test/fetch/response-inspect-custom.js | 30 + test/fetch/response-json.js | 98 + test/fetch/response.js | 299 + test/fetch/spread.js | 41 + test/fetch/user-agent.js | 28 + test/fetch/util.js | 377 ++ test/fixed-queue.js | 39 + test/fixtures/ca.pem | 34 + test/fixtures/cache-tests/LICENSE | 29 + test/fixtures/cache-tests/results/apache.json | 675 +++ test/fixtures/cache-tests/results/caddy.json | 843 +++ test/fixtures/cache-tests/results/chrome.json | 599 ++ test/fixtures/cache-tests/results/fastly.json | 882 +++ .../fixtures/cache-tests/results/firefox.json | 587 ++ test/fixtures/cache-tests/results/index.mjs | 71 + test/fixtures/cache-tests/results/nginx.json | 849 +++ test/fixtures/cache-tests/results/safari.json | 611 ++ test/fixtures/cache-tests/results/squid.json | 681 +++ .../cache-tests/results/trafficserver.json | 678 +++ .../fixtures/cache-tests/results/varnish.json | 804 +++ test/fixtures/cache-tests/test-engine/cli.mjs | 44 + .../cache-tests/test-engine/client/config.mjs | 22 + .../test-engine/client/fetching.mjs | 45 + .../cache-tests/test-engine/client/runner.mjs | 41 + .../cache-tests/test-engine/client/test.mjs | 299 + .../cache-tests/test-engine/client/utils.mjs | 82 + .../cache-tests/test-engine/export.mjs | 18 + .../cache-tests/test-engine/lib/defines.mjs | 28 + .../cache-tests/test-engine/lib/display.mjs | 153 + .../test-engine/lib/header-fixup.mjs | 28 + .../cache-tests/test-engine/lib/modal.mjs | 27 + .../cache-tests/test-engine/lib/results.mjs | 73 + .../cache-tests/test-engine/lib/summary.mjs | 178 + .../test-engine/lib/testsuite-schema.json | 445 ++ .../test-engine/lib/tpl/checks.liquid | 52 + .../test-engine/lib/tpl/explain-test.liquid | 90 + .../test-engine/lib/tpl/header-list.liquid | 16 + .../test-engine/lib/tpl/header-magic.liquid | 6 + .../cache-tests/test-engine/lib/utils.mjs | 89 + .../test-engine/server/handle-config.mjs | 22 + .../test-engine/server/handle-file.mjs | 24 + .../test-engine/server/handle-state.mjs | 12 + .../test-engine/server/handle-test.mjs | 118 + .../cache-tests/test-engine/server/server.mjs | 54 + .../cache-tests/test-engine/server/utils.mjs | 54 + test/fixtures/cache-tests/tests/age-parse.mjs | 301 + .../cache-tests/tests/authorization.mjs | 110 + .../cache-tests/tests/cc-freshness.mjs | 468 ++ test/fixtures/cache-tests/tests/cc-parse.mjs | 278 + .../fixtures/cache-tests/tests/cc-request.mjs | 241 + .../cache-tests/tests/cc-response.mjs | 375 ++ .../cache-tests/tests/cdn-cache-control.mjs | 491 ++ .../cache-tests/tests/conditional-etag.mjs | 456 ++ .../cache-tests/tests/conditional-lm.mjs | 119 + .../cache-tests/tests/expires-freshness.mjs | 154 + .../cache-tests/tests/expires-parse.mjs | 301 + test/fixtures/cache-tests/tests/headers.mjs | 77 + .../cache-tests/tests/heuristic-freshness.mjs | 95 + test/fixtures/cache-tests/tests/index.mjs | 26 + .../cache-tests/tests/invalidation.mjs | 121 + .../cache-tests/tests/lib/header-list.mjs | 135 + .../cache-tests/tests/lib/templates.mjs | 74 + test/fixtures/cache-tests/tests/lib/utils.mjs | 20 + test/fixtures/cache-tests/tests/method.mjs | 35 + test/fixtures/cache-tests/tests/other.mjs | 239 + test/fixtures/cache-tests/tests/partial.mjs | 271 + test/fixtures/cache-tests/tests/pragma.mjs | 97 + test/fixtures/cache-tests/tests/stale.mjs | 177 + test/fixtures/cache-tests/tests/status.mjs | 118 + test/fixtures/cache-tests/tests/update304.mjs | 124 + .../fixtures/cache-tests/tests/updateHead.mjs | 109 + .../fixtures/cache-tests/tests/vary-parse.mjs | 157 + test/fixtures/cache-tests/tests/vary.mjs | 470 ++ test/fixtures/cert.pem | 33 + test/fixtures/fetch.js | 22 + .../fixtures/interceptors/retry-event-loop.js | 33 + test/fixtures/key.pem | 52 + test/fixtures/undici.js | 19 + test/fixtures/websocket.js | 18 + test/fixtures/wpt/LICENSE.md | 11 + .../fixtures/wpt/common/CustomCorsResponse.py | 30 + test/fixtures/wpt/common/META.yml | 2 + .../wpt/common/PrefixedLocalStorage.js | 116 + .../common/PrefixedLocalStorage.js.headers | 1 + .../wpt/common/PrefixedPostMessage.js | 100 + .../wpt/common/PrefixedPostMessage.js.headers | 1 + test/fixtures/wpt/common/README.md | 10 + test/fixtures/wpt/common/__init__.py | 0 test/fixtures/wpt/common/arrays.js | 31 + test/fixtures/wpt/common/blank-with-cors.html | 0 .../wpt/common/blank-with-cors.html.headers | 1 + test/fixtures/wpt/common/blank.html | 0 .../wpt/common/custom-cors-response.js | 32 + test/fixtures/wpt/common/dispatcher/README.md | 228 + .../wpt/common/dispatcher/dispatcher.js | 281 + .../wpt/common/dispatcher/dispatcher.py | 53 + .../dispatcher/executor-service-worker.js | 24 + .../wpt/common/dispatcher/executor-worker.js | 12 + .../wpt/common/dispatcher/executor.html | 15 + .../common/dispatcher/remote-executor.html | 12 + .../wpt/common/domain-setter.sub.html | 8 + test/fixtures/wpt/common/dummy.json | 1 + test/fixtures/wpt/common/dummy.xhtml | 2 + test/fixtures/wpt/common/dummy.xml | 1 + test/fixtures/wpt/common/echo.py | 6 + test/fixtures/wpt/common/gc.js | 52 + test/fixtures/wpt/common/get-host-info.sub.js | 63 + .../wpt/common/get-host-info.sub.js.headers | 1 + test/fixtures/wpt/common/media.js | 57 + test/fixtures/wpt/common/media.js.headers | 1 + .../fixtures/wpt/common/object-association.js | 74 + .../wpt/common/object-association.js.headers | 1 + .../wpt/common/performance-timeline-utils.js | 56 + .../performance-timeline-utils.js.headers | 1 + test/fixtures/wpt/common/proxy-all.sub.pac | 3 + test/fixtures/wpt/common/redirect-opt-in.py | 20 + test/fixtures/wpt/common/redirect.py | 19 + test/fixtures/wpt/common/refresh.py | 11 + test/fixtures/wpt/common/reftest-wait.js | 39 + .../wpt/common/reftest-wait.js.headers | 1 + test/fixtures/wpt/common/rendering-utils.js | 19 + test/fixtures/wpt/common/sab.js | 21 + .../wpt/common/security-features/README.md | 460 ++ .../wpt/common/security-features/__init__.py | 0 .../security-features/resources/common.sub.js | 1311 +++++ .../resources/common.sub.js.headers | 1 + .../security-features/scope/__init__.py | 0 .../security-features/scope/document.py | 36 + .../scope/template/document.html.template | 30 + .../scope/template/worker.js.template | 29 + .../common/security-features/scope/util.py | 43 + .../common/security-features/scope/worker.py | 44 + .../security-features/subresource/__init__.py | 0 .../security-features/subresource/audio.py | 18 + .../security-features/subresource/document.py | 12 + .../security-features/subresource/empty.py | 14 + .../security-features/subresource/font.py | 76 + .../security-features/subresource/image.py | 116 + .../security-features/subresource/referrer.py | 4 + .../security-features/subresource/script.py | 14 + .../subresource/shared-worker.py | 13 + .../subresource/static-import.py | 61 + .../subresource/stylesheet.py | 61 + .../subresource/subresource.py | 199 + .../security-features/subresource/svg.py | 37 + .../template/document.html.template | 16 + .../subresource/template/font.css.template | 9 + .../subresource/template/image.css.template | 3 + .../subresource/template/script.js.template | 3 + .../template/shared-worker.js.template | 5 + .../template/static-import.js.template | 1 + .../subresource/template/svg.css.template | 3 + .../template/svg.embedded.template | 5 + .../subresource/template/worker.js.template | 3 + .../security-features/subresource/video.py | 17 + .../security-features/subresource/worker.py | 13 + .../security-features/subresource/xhr.py | 16 + .../tools/format_spec_src_json.py | 24 + .../security-features/tools/generate.py | 462 ++ .../security-features/tools/spec.src.json | 533 ++ .../security-features/tools/spec_validator.py | 251 + .../tools/template/disclaimer.template | 1 + .../tools/template/spec_json.js.template | 1 + .../tools/template/test.debug.html.template | 26 + .../tools/template/test.release.html.template | 22 + .../common/security-features/tools/util.py | 228 + .../wpt/common/security-features/types.md | 62 + test/fixtures/wpt/common/slow-redirect.py | 29 + test/fixtures/wpt/common/slow.py | 6 + test/fixtures/wpt/common/square.png | Bin 0 -> 18299 bytes test/fixtures/wpt/common/stringifiers.js | 57 + .../wpt/common/stringifiers.js.headers | 1 + .../wpt/common/subset-tests-by-key.js | 83 + test/fixtures/wpt/common/subset-tests.js | 60 + .../test-setting-immutable-prototype.js | 67 + ...est-setting-immutable-prototype.js.headers | 1 + test/fixtures/wpt/common/text-plain.txt | 4 + .../common/third_party/reftest-analyzer.xhtml | 934 ++++ test/fixtures/wpt/common/top-layer.js | 29 + test/fixtures/wpt/common/utils.js | 98 + test/fixtures/wpt/common/utils.js.headers | 1 + .../wpt/common/window-name-setter.html | 12 + test/fixtures/wpt/common/worklet-reftest.js | 50 + .../wpt/common/worklet-reftest.js.headers | 1 + test/fixtures/wpt/eventsource/META.yml | 6 + test/fixtures/wpt/eventsource/README.md | 4 + .../dedicated-worker/eventsource-close.htm | 24 + .../dedicated-worker/eventsource-close.js | 9 + .../dedicated-worker/eventsource-close2.htm | 23 + .../dedicated-worker/eventsource-close2.js | 3 + .../eventsource-constructor-no-new.any.js | 7 + ...ventsource-constructor-non-same-origin.htm | 34 + ...eventsource-constructor-non-same-origin.js | 10 + .../eventsource-constructor-url-bogus.js | 7 + .../eventsource-eventtarget.worker.js | 11 + .../dedicated-worker/eventsource-onmesage.js | 9 + .../eventsource-onmessage.htm | 24 + .../dedicated-worker/eventsource-onopen.htm | 27 + .../dedicated-worker/eventsource-onopen.js | 9 + .../eventsource-prototype.htm | 25 + .../dedicated-worker/eventsource-prototype.js | 8 + .../dedicated-worker/eventsource-url.htm | 25 + .../dedicated-worker/eventsource-url.js | 7 + .../wpt/eventsource/event-data.any.js | 21 + .../eventsource/eventsource-close.window.js | 70 + ...urce-constructor-document-domain.window.js | 18 + .../eventsource-constructor-empty-url.any.js | 6 + ...urce-constructor-non-same-origin.window.js | 21 + ...ventsource-constructor-stringify.window.js | 28 + .../eventsource-constructor-url-bogus.any.js | 8 + ...entsource-constructor-url-multi-window.htm | 37 + .../eventsource-cross-origin.window.js | 51 + .../eventsource-eventtarget.any.js | 16 + .../eventsource-onmessage-realm.htm | 25 + .../eventsource-onmessage-trusted.any.js | 12 + .../eventsource/eventsource-onmessage.any.js | 14 + .../wpt/eventsource/eventsource-onopen.any.js | 17 + .../eventsource/eventsource-prototype.any.js | 10 + .../eventsource-reconnect.window.js | 47 + ...eventsource-request-cancellation.window.js | 21 + .../wpt/eventsource/eventsource-url.any.js | 8 + .../wpt/eventsource/format-bom-2.any.js | 24 + .../wpt/eventsource/format-bom.any.js | 24 + .../wpt/eventsource/format-comments.any.js | 16 + ...format-data-before-final-empty-line.any.js | 17 + .../wpt/eventsource/format-field-data.any.js | 23 + .../format-field-event-empty.any.js | 13 + .../wpt/eventsource/format-field-event.any.js | 15 + .../wpt/eventsource/format-field-id-2.any.js | 25 + .../eventsource/format-field-id-3.window.js | 56 + .../format-field-id-null.window.js | 25 + .../wpt/eventsource/format-field-id.any.js | 21 + .../eventsource/format-field-parsing.any.js | 14 + .../format-field-retry-bogus.any.js | 19 + .../format-field-retry-empty.any.js | 13 + .../wpt/eventsource/format-field-retry.any.js | 21 + .../eventsource/format-field-unknown.any.js | 13 + .../eventsource/format-leading-space.any.js | 14 + .../wpt/eventsource/format-mime-bogus.any.js | 25 + .../format-mime-trailing-semicolon.any.js | 20 + .../format-mime-valid-bogus.any.js | 24 + .../wpt/eventsource/format-newlines.any.js | 13 + .../eventsource/format-null-character.any.js | 17 + .../wpt/eventsource/format-utf-8.any.js | 12 + .../wpt/eventsource/request-accept.any.js | 13 + .../eventsource/request-cache-control.any.js | 35 + .../eventsource/request-credentials.window.js | 37 + .../eventsource/request-redirect.window.js | 24 + .../request-status-error.window.js | 27 + .../eventsource/resources/accept.event_stream | 2 + .../resources/cache-control.event_stream | 2 + .../wpt/eventsource/resources/cors-cookie.py | 31 + .../wpt/eventsource/resources/cors.py | 36 + .../resources/eventsource-onmessage-realm.htm | 2 + .../wpt/eventsource/resources/init.htm | 9 + .../eventsource/resources/last-event-id.py | 9 + .../eventsource/resources/last-event-id2.py | 23 + .../wpt/eventsource/resources/message.py | 14 + .../wpt/eventsource/resources/message2.py | 33 + .../eventsource/resources/reconnect-fail.py | 24 + .../wpt/eventsource/resources/status-error.py | 15 + .../eventsource/resources/status-reconnect.py | 21 + .../shared-worker/eventsource-close.htm | 24 + .../shared-worker/eventsource-close.js | 12 + ...ventsource-constructor-non-same-origin.htm | 34 + ...eventsource-constructor-non-same-origin.js | 13 + .../eventsource-constructor-url-bogus.js | 10 + .../shared-worker/eventsource-eventtarget.htm | 24 + .../shared-worker/eventsource-eventtarget.js | 13 + .../shared-worker/eventsource-onmesage.js | 12 + .../shared-worker/eventsource-onmessage.htm | 24 + .../shared-worker/eventsource-onopen.htm | 27 + .../shared-worker/eventsource-onopen.js | 12 + .../shared-worker/eventsource-prototype.htm | 25 + .../shared-worker/eventsource-prototype.js | 11 + .../shared-worker/eventsource-url.htm | 25 + .../shared-worker/eventsource-url.js | 10 + test/fixtures/wpt/fetch/META.yml | 7 + test/fixtures/wpt/fetch/README.md | 6 + .../wpt/fetch/api/abort/cache.https.any.js | 47 + .../fetch/api/abort/destroyed-context.html | 27 + .../wpt/fetch/api/abort/general.any.js | 572 ++ .../wpt/fetch/api/abort/keepalive.html | 85 + .../wpt/fetch/api/abort/request.any.js | 85 + .../serviceworker-intercepted.https.html | 212 + .../wpt/fetch/api/basic/accept-header.any.js | 34 + .../fetch/api/basic/block-mime-as-script.html | 43 + .../fetch/api/basic/conditional-get.any.js | 38 + .../api/basic/error-after-response.any.js | 24 + .../api/basic/header-value-combining.any.js | 15 + .../api/basic/header-value-null-byte.any.js | 5 + .../wpt/fetch/api/basic/historical.any.js | 17 + .../fetch/api/basic/http-response-code.any.js | 14 + .../wpt/fetch/api/basic/integrity.sub.any.js | 87 + .../wpt/fetch/api/basic/keepalive.any.js | 77 + .../wpt/fetch/api/basic/mediasource.window.js | 5 + .../fetch/api/basic/mode-no-cors.sub.any.js | 29 + .../fetch/api/basic/mode-same-origin.any.js | 28 + .../wpt/fetch/api/basic/referrer.any.js | 29 + .../basic/request-forbidden-headers.any.js | 82 + .../wpt/fetch/api/basic/request-head.any.js | 6 + .../api/basic/request-headers-case.any.js | 13 + .../api/basic/request-headers-nonascii.any.js | 29 + .../fetch/api/basic/request-headers.any.js | 83 + ...t-private-network-headers.tentative.any.js | 18 + .../request-referrer-redirected-worker.html | 17 + .../fetch/api/basic/request-referrer.any.js | 24 + .../wpt/fetch/api/basic/request-upload.any.js | 139 + .../fetch/api/basic/request-upload.h2.any.js | 209 + .../fetch/api/basic/response-null-body.any.js | 38 + .../fetch/api/basic/response-url.sub.any.js | 16 + .../wpt/fetch/api/basic/scheme-about.any.js | 26 + .../fetch/api/basic/scheme-blob.sub.any.js | 125 + .../wpt/fetch/api/basic/scheme-data.any.js | 43 + .../fetch/api/basic/scheme-others.sub.any.js | 31 + .../wpt/fetch/api/basic/status.h2.any.js | 17 + .../fetch/api/basic/stream-response.any.js | 40 + .../api/basic/stream-safe-creation.any.js | 54 + .../wpt/fetch/api/basic/text-utf8.any.js | 74 + .../wpt/fetch/api/basic/url-parsing.sub.html | 33 + .../fixtures/wpt/fetch/api/body/cloned-any.js | 50 + .../wpt/fetch/api/body/formdata.any.js | 25 + .../wpt/fetch/api/body/mime-type.any.js | 127 + .../wpt/fetch/api/cors/cors-basic.any.js | 43 + .../api/cors/cors-cookies-redirect.any.js | 49 + .../wpt/fetch/api/cors/cors-cookies.any.js | 56 + .../api/cors/cors-expose-star.sub.any.js | 41 + .../fetch/api/cors/cors-filtering.sub.any.js | 65 + .../wpt/fetch/api/cors/cors-keepalive.any.js | 116 + .../api/cors/cors-multiple-origins.sub.any.js | 22 + .../fetch/api/cors/cors-no-preflight.any.js | 41 + .../wpt/fetch/api/cors/cors-origin.any.js | 51 + .../api/cors/cors-preflight-cache.any.js | 46 + .../cors-preflight-not-cors-safelisted.any.js | 19 + .../api/cors/cors-preflight-redirect.any.js | 37 + .../api/cors/cors-preflight-referrer.any.js | 51 + .../cors-preflight-response-validation.any.js | 33 + .../fetch/api/cors/cors-preflight-star.any.js | 86 + .../api/cors/cors-preflight-status.any.js | 37 + .../wpt/fetch/api/cors/cors-preflight.any.js | 62 + .../api/cors/cors-redirect-credentials.any.js | 52 + .../api/cors/cors-redirect-preflight.any.js | 46 + .../wpt/fetch/api/cors/cors-redirect.any.js | 42 + .../wpt/fetch/api/cors/data-url-iframe.html | 58 + .../api/cors/data-url-shared-worker.html | 53 + .../wpt/fetch/api/cors/data-url-worker.html | 50 + .../fetch/api/cors/resources/corspreflight.js | 58 + .../cors/resources/not-cors-safelisted.json | 13 + .../wpt/fetch/api/cors/sandboxed-iframe.html | 14 + .../aborted-fetch-response.https.html | 11 + .../api/crashtests/body-window-destroy.html | 11 + .../fetch/api/crashtests/huge-fetch.any.js | 16 + .../wpt/fetch/api/crashtests/request.html | 8 + .../credentials/authentication-basic.any.js | 17 + .../authentication-redirection.any.js | 29 + .../wpt/fetch/api/credentials/cookies.any.js | 49 + .../fetch/api/headers/header-setcookie.any.js | 266 + .../headers/header-values-normalize.any.js | 72 + .../fetch/api/headers/header-values.any.js | 63 + .../fetch/api/headers/headers-basic.any.js | 275 + .../fetch/api/headers/headers-casing.any.js | 54 + .../fetch/api/headers/headers-combine.any.js | 66 + .../fetch/api/headers/headers-errors.any.js | 96 + .../fetch/api/headers/headers-no-cors.any.js | 59 + .../api/headers/headers-normalize.any.js | 56 + .../fetch/api/headers/headers-record.any.js | 357 ++ .../api/headers/headers-structure.any.js | 20 + test/fixtures/wpt/fetch/api/idlharness.any.js | 21 + .../api/policies/csp-blocked-worker.html | 16 + .../wpt/fetch/api/policies/csp-blocked.html | 15 + .../api/policies/csp-blocked.html.headers | 1 + .../wpt/fetch/api/policies/csp-blocked.js | 13 + .../fetch/api/policies/csp-blocked.js.headers | 1 + .../wpt/fetch/api/policies/nested-policy.js | 1 + .../api/policies/nested-policy.js.headers | 1 + ...rrer-no-referrer-service-worker.https.html | 18 + .../policies/referrer-no-referrer-worker.html | 17 + .../api/policies/referrer-no-referrer.html | 15 + .../referrer-no-referrer.html.headers | 1 + .../api/policies/referrer-no-referrer.js | 19 + .../policies/referrer-no-referrer.js.headers | 1 + .../referrer-origin-service-worker.https.html | 18 + ...hen-cross-origin-service-worker.https.html | 17 + ...errer-origin-when-cross-origin-worker.html | 16 + .../referrer-origin-when-cross-origin.html | 16 + ...rrer-origin-when-cross-origin.html.headers | 1 + .../referrer-origin-when-cross-origin.js | 21 + ...ferrer-origin-when-cross-origin.js.headers | 1 + .../api/policies/referrer-origin-worker.html | 17 + .../fetch/api/policies/referrer-origin.html | 16 + .../api/policies/referrer-origin.html.headers | 1 + .../wpt/fetch/api/policies/referrer-origin.js | 30 + .../api/policies/referrer-origin.js.headers | 1 + ...errer-unsafe-url-service-worker.https.html | 18 + .../policies/referrer-unsafe-url-worker.html | 17 + .../api/policies/referrer-unsafe-url.html | 16 + .../policies/referrer-unsafe-url.html.headers | 1 + .../fetch/api/policies/referrer-unsafe-url.js | 21 + .../policies/referrer-unsafe-url.js.headers | 1 + .../redirect-back-to-original-origin.any.js | 38 + .../fetch/api/redirect/redirect-count.any.js | 51 + .../redirect/redirect-empty-location.any.js | 21 + .../api/redirect/redirect-keepalive.any.js | 35 + .../redirect/redirect-keepalive.https.any.js | 18 + .../redirect-location-escape.tentative.any.js | 46 + .../api/redirect/redirect-location.any.js | 73 + .../fetch/api/redirect/redirect-method.any.js | 112 + .../fetch/api/redirect/redirect-mode.any.js | 59 + .../fetch/api/redirect/redirect-origin.any.js | 68 + .../redirect-referrer-override.any.js | 104 + .../api/redirect/redirect-referrer.any.js | 66 + .../api/redirect/redirect-schemes.any.js | 19 + .../api/redirect/redirect-to-dataurl.any.js | 28 + .../api/redirect/redirect-upload.h2.any.js | 33 + .../fetch-destination-frame.https.html | 51 + .../fetch-destination-iframe.https.html | 51 + ...fetch-destination-no-load-event.https.html | 138 + .../fetch-destination-prefetch.https.html | 46 + .../fetch-destination-worker.https.html | 60 + .../destination/fetch-destination.https.html | 485 ++ .../api/request/destination/resources/dummy | 0 .../request/destination/resources/dummy.css | 0 .../request/destination/resources/dummy.es | 0 .../destination/resources/dummy.es.headers | 1 + .../request/destination/resources/dummy.html | 0 .../request/destination/resources/dummy.json | 1 + .../request/destination/resources/dummy.png | Bin 0 -> 18299 bytes .../request/destination/resources/dummy.ttf | Bin 0 -> 2528 bytes .../destination/resources/dummy_audio.mp3 | Bin 0 -> 20498 bytes .../destination/resources/dummy_audio.oga | Bin 0 -> 18541 bytes .../destination/resources/dummy_video.mp4 | Bin 0 -> 67369 bytes .../destination/resources/dummy_video.webm | Bin 0 -> 96902 bytes .../destination/resources/empty.https.html | 0 .../fetch-destination-worker-frame.js | 20 + .../fetch-destination-worker-iframe.js | 20 + .../fetch-destination-worker-no-load-event.js | 20 + .../resources/fetch-destination-worker.js | 12 + .../resources/import-declaration-type-css.js | 1 + .../resources/import-declaration-type-json.js | 1 + .../request/destination/resources/importer.js | 1 + .../fetch/api/request/forbidden-method.any.js | 13 + .../construct-in-detached-frame.window.js | 11 + .../multi-globals/current/current.html | 3 + .../multi-globals/incumbent/incumbent.html | 14 + .../request/multi-globals/url-parsing.html | 27 + .../fetch/api/request/request-bad-port.any.js | 94 + .../request-cache-default-conditional.any.js | 170 + .../api/request/request-cache-default.any.js | 39 + .../request/request-cache-force-cache.any.js | 67 + .../api/request/request-cache-no-cache.any.js | 25 + .../api/request/request-cache-no-store.any.js | 37 + .../request-cache-only-if-cached.any.js | 66 + .../api/request/request-cache-reload.any.js | 51 + .../wpt/fetch/api/request/request-cache.js | 223 + .../fetch/api/request/request-clone.sub.html | 63 + ...uest-constructor-init-body-override.any.js | 37 + .../api/request/request-consume-empty.any.js | 89 + .../fetch/api/request/request-consume.any.js | 148 + .../api/request/request-disturbed.any.js | 109 + .../fetch/api/request/request-error.any.js | 56 + .../wpt/fetch/api/request/request-error.js | 57 + .../fetch/api/request/request-headers.any.js | 177 + .../api/request/request-init-001.sub.html | 112 + .../fetch/api/request/request-init-002.any.js | 60 + .../api/request/request-init-003.sub.html | 84 + .../request/request-init-contenttype.any.js | 141 + .../api/request/request-init-priority.any.js | 26 + .../api/request/request-init-stream.any.js | 147 + .../api/request/request-keepalive-quota.html | 97 + .../api/request/request-keepalive.any.js | 17 + .../request-reset-attributes.https.html | 96 + .../api/request/request-structure.any.js | 143 + .../wpt/fetch/api/request/resources/cache.py | 67 + .../wpt/fetch/api/request/resources/hello.txt | 1 + .../request-reset-attributes-worker.js | 19 + .../wpt/fetch/api/request/url-encoding.html | 25 + .../wpt/fetch/api/resources/authentication.py | 14 + .../fetch/api/resources/bad-chunk-encoding.py | 13 + .../wpt/fetch/api/resources/basic.html | 5 + .../fixtures/wpt/fetch/api/resources/cache.py | 18 + .../wpt/fetch/api/resources/clean-stash.py | 6 + .../wpt/fetch/api/resources/cors-top.txt | 1 + .../fetch/api/resources/cors-top.txt.headers | 1 + .../wpt/fetch/api/resources/data.json | 1 + .../resources/dump-authorization-header.py | 19 + .../fetch/api/resources/echo-content.h2.py | 7 + .../wpt/fetch/api/resources/echo-content.py | 12 + .../wpt/fetch/api/resources/empty.txt | 0 .../wpt/fetch/api/resources/huge-response.py | 22 + .../api/resources/infinite-slow-response.py | 35 + .../fetch/api/resources/inspect-headers.py | 24 + .../fetch/api/resources/keepalive-helper.js | 199 + .../fetch/api/resources/keepalive-iframe.html | 22 + .../resources/keepalive-redirect-iframe.html | 23 + .../resources/keepalive-redirect-window.html | 42 + .../fetch/api/resources/keepalive-worker.js | 15 + .../wpt/fetch/api/resources/method.py | 18 + .../wpt/fetch/api/resources/preflight.py | 78 + .../api/resources/redirect-empty-location.py | 3 + .../wpt/fetch/api/resources/redirect.h2.py | 14 + .../wpt/fetch/api/resources/redirect.py | 73 + .../fetch/api/resources/sandboxed-iframe.html | 34 + .../fetch/api/resources/script-with-header.py | 7 + .../wpt/fetch/api/resources/stash-put.py | 41 + .../wpt/fetch/api/resources/stash-take.py | 9 + .../wpt/fetch/api/resources/status.py | 11 + .../fetch/api/resources/sw-intercept-abort.js | 19 + .../wpt/fetch/api/resources/sw-intercept.js | 10 + test/fixtures/wpt/fetch/api/resources/top.txt | 1 + .../wpt/fetch/api/resources/trickle.py | 15 + .../fixtures/wpt/fetch/api/resources/utils.js | 120 + .../wpt/fetch/api/response/json.any.js | 14 + .../api/response/many-empty-chunks-crash.html | 14 + .../multi-globals/current/current.html | 3 + .../multi-globals/incumbent/incumbent.html | 16 + .../multi-globals/relevant/relevant.html | 2 + .../response/multi-globals/url-parsing.html | 27 + .../response-arraybuffer-realm.window.js | 23 + .../api/response/response-blob-realm.any.js | 24 + .../response-body-read-task-handling.html | 86 + .../response/response-cancel-stream.any.js | 64 + .../response/response-clone-iframe.window.js | 32 + .../fetch/api/response/response-clone.any.js | 141 + .../response/response-consume-empty.any.js | 88 + .../response/response-consume-stream.any.js | 80 + .../fetch/api/response/response-consume.html | 317 ++ .../response-error-from-stream.any.js | 61 + .../fetch/api/response/response-error.any.js | 27 + .../api/response/response-from-stream.any.js | 23 + .../response/response-headers-guard.any.js | 8 + .../api/response/response-init-001.any.js | 64 + .../api/response/response-init-002.any.js | 61 + .../response/response-init-contenttype.any.js | 125 + .../api/response/response-static-error.any.js | 22 + .../api/response/response-static-json.any.js | 96 + .../response/response-static-redirect.any.js | 40 + .../response/response-stream-bad-chunk.any.js | 25 + .../response-stream-disturbed-1.any.js | 44 + .../response-stream-disturbed-2.any.js | 35 + .../response-stream-disturbed-3.any.js | 36 + .../response-stream-disturbed-4.any.js | 35 + .../response-stream-disturbed-5.any.js | 19 + .../response-stream-disturbed-6.any.js | 76 + .../response-stream-disturbed-by-pipe.any.js | 17 + .../response-stream-disturbed-util.js | 17 + .../response-stream-with-broken-then.any.js | 117 + ...clear-site-data-cache.tentative.https.html | 27 + ...ear-site-data-cookies.tentative.https.html | 27 + ...ear-site-data-storage.tentative.https.html | 27 + ...tionary-decompression.tentative.https.html | 58 + ...tch-with-link-element.tentative.https.html | 72 + ...etch-with-link-header.tentative.https.html | 54 + ...ctionary-registration.tentative.https.html | 61 + .../resources/clear-site-data.py | 4 + .../resources/compressed-data.py | 31 + .../resources/compression-dictionary-util.js | 120 + .../resources/echo-headers.py | 10 + .../resources/empty.html | 1 + .../resources/register-dictionary.py | 37 + .../network-partition-key.html | 264 + ...network-partition-about-blank-checker.html | 35 + .../resources/network-partition-checker.html | 30 + .../network-partition-iframe-checker.html | 22 + .../resources/network-partition-key.js | 47 + .../resources/network-partition-key.py | 130 + .../network-partition-worker-checker.html | 24 + .../resources/network-partition-worker.js | 15 + .../br/bad-br-body.https.any.js | 12 + .../br/big-br-body.https.any.js | 55 + .../content-encoding/br/br-body.https.any.js | 15 + .../br/resources/bad-br-body.py | 3 + .../content-encoding/br/resources/big.text.br | Bin 0 -> 49 bytes .../br/resources/big.text.br.headers | 3 + .../br/resources/foo.octetstream.br | Bin 0 -> 15 bytes .../br/resources/foo.octetstream.br.headers | 2 + .../content-encoding/br/resources/foo.text.br | Bin 0 -> 15 bytes .../br/resources/foo.text.br.headers | 2 + .../gzip/bad-gzip-body.any.js | 22 + .../gzip/big-gzip-body.https.any.js | 55 + .../content-encoding/gzip/gzip-body.any.js | 16 + .../gzip/resources/bad-gzip-body.py | 3 + .../gzip/resources/big.text.gz | Bin 0 -> 65509 bytes .../gzip/resources/big.text.gz.headers | 3 + .../gzip/resources/foo.octetstream.gz | Bin 0 -> 64 bytes .../gzip/resources/foo.octetstream.gz.headers | 2 + .../gzip/resources/foo.text.gz | Bin 0 -> 57 bytes .../gzip/resources/foo.text.gz.headers | 2 + .../zstd/bad-zstd-body.https.any.js | 22 + ...ig-window-zstd-body.tentative.https.any.js | 9 + .../zstd/big-zstd-body.https.any.js | 55 + .../zstd/resources/bad-zstd-body.py | 3 + .../zstd/resources/big.text.zst | Bin 0 -> 2509 bytes .../zstd/resources/big.text.zst.headers | 3 + .../zstd/resources/big.window.zst | Bin 0 -> 599 bytes .../zstd/resources/big.window.zst.headers | 2 + .../zstd/resources/foo.octetstream.zst | Bin 0 -> 25 bytes .../resources/foo.octetstream.zst.headers | 2 + .../zstd/resources/foo.text.zst | Bin 0 -> 25 bytes .../zstd/resources/foo.text.zst.headers | 2 + .../zstd/zstd-body.https.any.js | 15 + .../api-and-duplicate-headers.any.js | 23 + .../fetch/content-length/content-length.html | 14 + .../content-length.html.headers | 1 + .../fetch/content-length/parsing.window.js | 18 + .../resources/content-length.py | 10 + .../resources/content-lengths.json | 142 + .../resources/identical-duplicates.asis | 9 + .../fetch/content-length/too-long.window.js | 4 + .../fixtures/wpt/fetch/content-type/README.md | 20 + .../content-type/multipart-malformed.any.js | 22 + .../fetch/content-type/multipart.window.js | 33 + .../content-type/resources/content-type.py | 18 + .../content-type/resources/content-types.json | 122 + .../resources/script-content-types.json | 92 + .../wpt/fetch/content-type/response.window.js | 72 + .../wpt/fetch/content-type/script.window.js | 48 + test/fixtures/wpt/fetch/corb/README.md | 67 + .../img-html-correctly-labeled.sub-ref.html | 4 + .../corb/img-html-correctly-labeled.sub.html | 11 + ...img-mime-types-coverage.tentative.sub.html | 85 + ...led-as-html-nosniff.tentative.sub-ref.html | 4 + ...labeled-as-html-nosniff.tentative.sub.html | 11 + .../img-png-mislabeled-as-html.sub-ref.html | 4 + .../corb/img-png-mislabeled-as-html.sub.html | 10 + ...g-svg-doctype-html-mimetype-empty.sub.html | 7 + ...img-svg-doctype-html-mimetype-svg.sub.html | 11 + .../fetch/corb/img-svg-invalid.sub-ref.html | 5 + .../corb/img-svg-labeled-as-dash.sub.html | 6 + .../corb/img-svg-labeled-as-svg-xml.sub.html | 6 + .../wpt/fetch/corb/img-svg-xml-decl.sub.html | 6 + .../wpt/fetch/corb/img-svg.sub-ref.html | 5 + ...labeled-as-html-nosniff.tentative.sub.html | 24 + .../css-mislabeled-as-html-nosniff.css | 1 + ...css-mislabeled-as-html-nosniff.css.headers | 2 + .../corb/resources/css-mislabeled-as-html.css | 1 + .../css-mislabeled-as-html.css.headers | 1 + .../css-with-json-parser-breaker.css | 3 + .../corb/resources/empty-labeled-as-png.png | 0 .../empty-labeled-as-png.png.headers | 1 + .../resources/html-correctly-labeled.html | 10 + .../html-correctly-labeled.html.headers | 1 + .../fetch/corb/resources/html-js-polyglot.js | 9 + .../resources/html-js-polyglot.js.headers | 1 + .../fetch/corb/resources/html-js-polyglot2.js | 10 + .../resources/html-js-polyglot2.js.headers | 1 + .../js-mislabeled-as-html-nosniff.js | 1 + .../js-mislabeled-as-html-nosniff.js.headers | 2 + .../corb/resources/js-mislabeled-as-html.js | 1 + .../js-mislabeled-as-html.js.headers | 1 + .../corb/resources/png-correctly-labeled.png | Bin 0 -> 1010 bytes .../png-correctly-labeled.png.headers | 1 + .../png-mislabeled-as-html-nosniff.png | Bin 0 -> 1010 bytes ...png-mislabeled-as-html-nosniff.png.headers | 2 + .../corb/resources/png-mislabeled-as-html.png | Bin 0 -> 1010 bytes .../png-mislabeled-as-html.png.headers | 1 + .../corb/resources/response_block_probe.js | 1 + .../resources/response_block_probe.js.headers | 1 + .../corb/resources/sniffable-resource.py | 11 + ...ts-html-containing-blob-url-to-parent.html | 16 + .../svg-doctype-html-mimetype-empty.svg | 4 + ...vg-doctype-html-mimetype-empty.svg.headers | 1 + .../svg-doctype-html-mimetype-svg.svg | 4 + .../svg-doctype-html-mimetype-svg.svg.headers | 1 + .../corb/resources/svg-labeled-as-dash.svg | 3 + .../resources/svg-labeled-as-dash.svg.headers | 1 + .../corb/resources/svg-labeled-as-svg-xml.svg | 3 + .../svg-labeled-as-svg-xml.svg.headers | 1 + .../wpt/fetch/corb/resources/svg-xml-decl.svg | 4 + .../fixtures/wpt/fetch/corb/resources/svg.svg | 3 + .../wpt/fetch/corb/resources/svg.svg.headers | 1 + .../corb/response_block.tentative.https.html | 50 + ...-html-correctly-labeled.tentative.sub.html | 32 + .../corb/script-html-js-polyglot.sub.html | 32 + ...pt-html-via-cross-origin-blob-url.sub.html | 38 + ...ipt-js-mislabeled-as-html-nosniff.sub.html | 33 + .../script-js-mislabeled-as-html.sub.html | 25 + ...ith-json-parser-breaker.tentative.sub.html | 85 + ...with-nonsniffable-types.tentative.sub.html | 84 + ...le-css-mislabeled-as-html-nosniff.sub.html | 42 + .../style-css-mislabeled-as-html.sub.html | 36 + ...tyle-css-with-json-parser-breaker.sub.html | 38 + .../style-html-correctly-labeled.sub.html | 41 + .../fetch-in-iframe.html | 67 + .../cross-origin-resource-policy/fetch.any.js | 76 + .../fetch.https.any.js | 56 + .../iframe-loads.html | 46 + .../image-loads.html | 54 + .../resources/green.png | Bin 0 -> 87 bytes .../resources/hello.py | 6 + .../resources/iframe.py | 5 + .../resources/iframeFetch.html | 19 + .../resources/image.py | 22 + .../resources/redirect.py | 6 + .../resources/script.py | 6 + .../scheme-restriction.any.js | 7 + .../scheme-restriction.https.window.js | 13 + .../script-loads.html | 52 + .../syntax.any.js | 19 + test/fixtures/wpt/fetch/data-urls/README.md | 11 + .../wpt/fetch/data-urls/base64.any.js | 18 + .../wpt/fetch/data-urls/navigate.window.js | 75 + .../wpt/fetch/data-urls/processing.any.js | 22 + .../wpt/fetch/data-urls/resources/base64.json | 82 + .../fetch/data-urls/resources/data-urls.json | 214 + test/fixtures/wpt/fetch/fetch-later/META.yml | 3 + test/fixtures/wpt/fetch/fetch-later/README.md | 3 + .../activate-after.tentative.https.window.js | 53 + .../basic.tentative.https.window.js | 46 + .../basic.tentative.https.worker.js | 6 + ...ferrer-when-downgrade.tentative.https.html | 23 + ...-referrer-no-referrer.tentative.https.html | 19 + ...gin-when-cross-origin.tentative.https.html | 25 + ...eader-referrer-origin.tentative.https.html | 23 + ...-referrer-same-origin.tentative.https.html | 24 + ...gin-when-cross-origin.tentative.https.html | 24 + ...eferrer-strict-origin.tentative.https.html | 24 + ...r-referrer-unsafe-url.tentative.https.html | 24 + .../iframe.tentative.https.window.js | 55 + .../new-window.tentative.https.window.js | 75 + .../fetch/fetch-later/non-secure.window.js | 5 + .../fetch-later/permissions-policy/README.md | 8 + ...tribute-redirect.tentative.https.window.js | 31 + ...policy-attribute.tentative.https.window.js | 36 + ...rmissions-policy.tentative.https.window.js | 45 + ...s-policy.tentative.https.window.js.headers | 1 + ...rmissions-policy.tentative.https.window.js | 38 + ...-by-permissions-policy.tentative.window.js | 8 + .../permissions-policy/resources/helper.js | 13 + .../permissions-policy-deferred-fetch.html | 14 + .../csp-allowed.tentative.https.window.js | 26 + .../csp-blocked.tentative.https.window.js | 31 + ...irect-to-blocked.tentative.https.window.js | 33 + .../quota.tentative.https.window.js | 134 + .../resources/fetch-later-helper.js | 206 + .../fetch-later/resources/fetch-later.html | 14 + .../fetch/fetch-later/resources/get_beacon.py | 30 + .../resources/header-referrer-helper.js | 39 + .../fetch/fetch-later/resources/set_beacon.py | 83 + ...-background-sync.tentative.https.window.js | 128 + ...nd-on-deactivate.tentative.https.window.js | 183 + ...send-after-abort.tentative.https.window.js | 23 + ...h-activate-after.tentative.https.window.js | 30 + .../send-multiple.tentative.https.window.js | 28 + test/fixtures/wpt/fetch/h1-parsing/README.md | 5 + .../wpt/fetch/h1-parsing/lone-cr.window.js | 23 + .../resources-with-0x00-in-header.window.js | 37 + .../wpt/fetch/h1-parsing/resources/README.md | 6 + .../resources/blue-with-0x00-in-a-header.asis | Bin 0 -> 546 bytes .../resources/document-with-0x00-in-header.py | 4 + .../wpt/fetch/h1-parsing/resources/message.py | 3 + .../resources/script-with-0x00-in-header.py | 4 + .../fetch/h1-parsing/resources/status-code.py | 6 + .../fetch/h1-parsing/status-code.window.js | 98 + .../wpt/fetch/http-cache/304-update.any.js | 146 + test/fixtures/wpt/fetch/http-cache/README.md | 72 + .../http-cache/basic-auth-cache-test-ref.html | 6 + .../http-cache/basic-auth-cache-test.html | 27 + .../wpt/fetch/http-cache/cache-mode.any.js | 61 + .../wpt/fetch/http-cache/cc-request.any.js | 202 + .../http-cache/credentials.tentative.any.js | 62 + .../wpt/fetch/http-cache/freshness.any.js | 243 + .../wpt/fetch/http-cache/heuristic.any.js | 93 + .../wpt/fetch/http-cache/http-cache.js | 274 + .../wpt/fetch/http-cache/invalidate.any.js | 235 + .../wpt/fetch/http-cache/partial.any.js | 208 + .../wpt/fetch/http-cache/post-patch.any.js | 46 + .../fetch/http-cache/resources/http-cache.py | 124 + .../http-cache/resources/securedimage.py | 19 + .../split-cache-popup-with-iframe.html | 34 + .../resources/split-cache-popup.html | 28 + .../wpt/fetch/http-cache/split-cache.html | 158 + .../wpt/fetch/http-cache/status.any.js | 60 + .../fixtures/wpt/fetch/http-cache/vary.any.js | 313 ++ ...vas-remote-read-remote-image-redirect.html | 28 + test/fixtures/wpt/fetch/metadata/META.yml | 4 + test/fixtures/wpt/fetch/metadata/README.md | 9 + .../wpt/fetch/metadata/WEB_FEATURES.yml | 3 + .../fetch/metadata/audio-worklet.https.html | 20 + .../metadata/embed.https.sub.tentative.html | 63 + .../metadata/fetch-preflight.https.sub.any.js | 29 + .../wpt/fetch/metadata/fetch.https.sub.any.js | 58 + .../generated/audioworklet.https.sub.html | 297 + .../css-font-face.https.sub.tentative.html | 250 + .../css-font-face.sub.tentative.html | 226 + .../css-images.https.sub.tentative.html | 1529 +++++ .../generated/css-images.sub.tentative.html | 1309 +++++ .../generated/element-a.https.sub.html | 522 ++ .../metadata/generated/element-a.sub.html | 402 ++ .../generated/element-area.https.sub.html | 522 ++ .../metadata/generated/element-area.sub.html | 402 ++ .../generated/element-audio.https.sub.html | 352 ++ .../metadata/generated/element-audio.sub.html | 268 + .../generated/element-embed.https.sub.html | 245 + .../metadata/generated/element-embed.sub.html | 220 + .../generated/element-frame.https.sub.html | 338 ++ .../metadata/generated/element-frame.sub.html | 292 + .../generated/element-iframe.https.sub.html | 338 ++ .../generated/element-iframe.sub.html | 292 + ...ment-img-environment-change.https.sub.html | 386 ++ .../element-img-environment-change.sub.html | 312 ++ .../generated/element-img.https.sub.html | 703 +++ .../metadata/generated/element-img.sub.html | 540 ++ .../element-input-image.https.sub.html | 250 + .../generated/element-input-image.sub.html | 214 + .../element-link-icon.https.sub.html | 402 ++ .../generated/element-link-icon.sub.html | 324 ++ ...ment-link-prefetch.https.optional.sub.html | 590 ++ .../element-link-prefetch.optional.sub.html | 320 ++ ...ement-meta-refresh.https.optional.sub.html | 300 + .../element-meta-refresh.optional.sub.html | 261 + .../generated/element-picture.https.sub.html | 1090 ++++ .../generated/element-picture.sub.html | 856 +++ .../generated/element-script.https.sub.html | 624 +++ .../generated/element-script.sub.html | 578 ++ .../element-video-poster.https.sub.html | 264 + .../generated/element-video-poster.sub.html | 228 + .../generated/element-video.https.sub.html | 352 ++ .../metadata/generated/element-video.sub.html | 268 + .../fetch-via-serviceworker.https.sub.html | 745 +++ .../metadata/generated/fetch.https.sub.html | 329 ++ .../fetch/metadata/generated/fetch.sub.html | 259 + .../generated/form-submission.https.sub.html | 566 ++ .../generated/form-submission.sub.html | 466 ++ .../generated/header-link.https.sub.html | 587 ++ .../header-link.https.sub.tentative.html | 51 + .../metadata/generated/header-link.sub.html | 544 ++ .../header-refresh.https.optional.sub.html | 297 + .../header-refresh.optional.sub.html | 258 + ...t-json-module-import-static.https.sub.html | 373 ++ .../script-json-module-import-static.sub.html | 378 ++ ...cript-module-import-dynamic.https.sub.html | 280 + .../script-module-import-dynamic.sub.html | 253 + ...script-module-import-static.https.sub.html | 316 ++ .../script-module-import-static.sub.html | 288 + .../generated/serviceworker.https.sub.html | 170 + .../generated/svg-image.https.sub.html | 396 ++ .../metadata/generated/svg-image.sub.html | 307 + .../generated/window-history.https.sub.html | 281 + .../generated/window-history.sub.html | 426 ++ .../generated/window-location.https.sub.html | 1296 +++++ .../generated/window-location.sub.html | 1062 ++++ ...orker-dedicated-constructor.https.sub.html | 118 + .../worker-dedicated-constructor.sub.html | 99 + ...ker-dedicated-importscripts.https.sub.html | 295 + .../worker-dedicated-importscripts.sub.html | 267 + .../fetch/metadata/navigation.https.sub.html | 23 + .../wpt/fetch/metadata/object.https.sub.html | 62 + .../fetch/metadata/paint-worklet.https.html | 19 + .../wpt/fetch/metadata/preload.https.sub.html | 50 + ...-redirect-https-downgrade-upgrade.sub.html | 18 + .../redirect/redirect-http-upgrade.sub.html | 17 + .../redirect-https-downgrade.sub.html | 17 + .../wpt/fetch/metadata/report.https.sub.html | 33 + .../report.https.sub.html.sub.headers | 3 + .../resources/appcache-iframe.sub.html | 15 + .../metadata/resources/dedicatedWorker.js | 1 + .../fetch/metadata/resources/echo-as-json.py | 29 + .../metadata/resources/echo-as-script.py | 14 + .../metadata/resources/es-json-module.sub.js | 1 + .../fetch/metadata/resources/es-module.sub.js | 1 + .../fetch-via-serviceworker--fallback--sw.js | 3 + ...etch-via-serviceworker--respondWith--sw.js | 3 + .../fetch-via-serviceworker-frame.html | 3 + .../fetch/metadata/resources/header-link.py | 15 + .../wpt/fetch/metadata/resources/helper.js | 42 + .../fetch/metadata/resources/helper.sub.js | 67 + .../metadata/resources/message-opener.html | 17 + .../fetch/metadata/resources/post-to-owner.py | 26 + .../fetch/metadata/resources/record-header.py | 145 + .../metadata/resources/record-headers.py | 73 + .../resources/redirectTestHelper.sub.js | 167 + .../serviceworker-accessors-frame.html | 3 + .../resources/serviceworker-accessors.sw.js | 14 + .../fetch/metadata/resources/sharedWorker.js | 9 + .../resources/unload-with-beacon.html | 12 + .../metadata/resources/xslt-test.sub.xml | 12 + .../serviceworker-accessors.https.sub.html | 51 + .../metadata/sharedworker.https.sub.html | 40 + .../wpt/fetch/metadata/style.https.sub.html | 86 + .../wpt/fetch/metadata/tools/README.md | 126 + .../metadata/tools/fetch-metadata.conf.yml | 943 ++++ .../wpt/fetch/metadata/tools/generate.py | 195 + .../templates/audioworklet.https.sub.html | 53 + .../tools/templates/css-font-face.sub.html | 60 + .../tools/templates/css-images.sub.html | 137 + .../tools/templates/element-a.sub.html | 72 + .../tools/templates/element-area.sub.html | 72 + .../tools/templates/element-audio.sub.html | 51 + .../tools/templates/element-embed.sub.html | 54 + .../tools/templates/element-frame.sub.html | 62 + .../tools/templates/element-iframe.sub.html | 62 + .../element-img-environment-change.sub.html | 78 + .../tools/templates/element-img.sub.html | 52 + .../templates/element-input-image.sub.html | 48 + .../templates/element-link-icon.sub.html | 75 + .../element-link-prefetch.optional.sub.html | 71 + .../element-meta-refresh.optional.sub.html | 60 + .../tools/templates/element-picture.sub.html | 101 + .../tools/templates/element-script.sub.html | 54 + .../templates/element-video-poster.sub.html | 62 + .../tools/templates/element-video.sub.html | 51 + .../fetch-via-serviceworker.https.sub.html | 88 + .../metadata/tools/templates/fetch.sub.html | 42 + .../tools/templates/form-submission.sub.html | 87 + .../tools/templates/header-link.sub.html | 56 + .../header-refresh.optional.sub.html | 59 + .../script-json-module-import-static.sub.html | 58 + .../script-module-import-dynamic.sub.html | 35 + .../script-module-import-static.sub.html | 53 + .../templates/serviceworker.https.sub.html | 72 + .../tools/templates/svg-image.sub.html | 75 + .../tools/templates/window-history.sub.html | 134 + .../tools/templates/window-location.sub.html | 128 + .../worker-dedicated-constructor.sub.html | 49 + .../worker-dedicated-importscripts.sub.html | 54 + .../wpt/fetch/metadata/track.https.sub.html | 119 + .../metadata/trailing-dot.https.sub.any.js | 30 + .../wpt/fetch/metadata/unload.https.sub.html | 64 + .../fetch/metadata/window-open.https.sub.html | 199 + .../wpt/fetch/metadata/worker.https.sub.html | 24 + .../wpt/fetch/metadata/xslt.https.sub.html | 25 + test/fixtures/wpt/fetch/nosniff/image.html | 39 + .../wpt/fetch/nosniff/importscripts.html | 14 + .../wpt/fetch/nosniff/importscripts.js | 28 + .../fetch/nosniff/parsing-nosniff.window.js | 27 + .../wpt/fetch/nosniff/resources/css.py | 23 + .../wpt/fetch/nosniff/resources/image.py | 24 + .../wpt/fetch/nosniff/resources/js.py | 17 + .../wpt/fetch/nosniff/resources/nosniff.py | 11 + .../wpt/fetch/nosniff/resources/worker.py | 16 + .../resources/x-content-type-options.json | 62 + test/fixtures/wpt/fetch/nosniff/script.html | 43 + .../wpt/fetch/nosniff/stylesheet.html | 60 + test/fixtures/wpt/fetch/nosniff/worker.html | 28 + .../wpt/fetch/orb/resources/data.json | 3 + .../fetch/orb/resources/data_non_ascii.json | 1 + .../wpt/fetch/orb/resources/empty.json | 1 + .../fixtures/wpt/fetch/orb/resources/font.ttf | Bin 0 -> 2528 bytes .../wpt/fetch/orb/resources/image.png | Bin 0 -> 1010 bytes .../js-unlabeled-utf16-without-bom.json | Bin 0 -> 70 bytes .../wpt/fetch/orb/resources/js-unlabeled.js | 1 + .../orb/resources/png-mislabeled-as-html.png | Bin 0 -> 1010 bytes .../png-mislabeled-as-html.png.headers | 1 + .../wpt/fetch/orb/resources/png-unlabeled.png | Bin 0 -> 1010 bytes .../orb/resources/script-asm-js-invalid.js | 4 + .../orb/resources/script-asm-js-valid.js | 4 + .../fetch/orb/resources/script-iso-8559-1.js | 4 + .../fetch/orb/resources/script-utf16-bom.js | Bin 0 -> 92 bytes .../orb/resources/script-utf16-without-bom.js | Bin 0 -> 90 bytes .../wpt/fetch/orb/resources/script.js | 4 + .../wpt/fetch/orb/resources/sound.mp3 | Bin 0 -> 539 bytes .../fixtures/wpt/fetch/orb/resources/text.txt | 1 + .../fixtures/wpt/fetch/orb/resources/utils.js | 101 + .../compressed-image-sniffing.sub.html | 20 + .../orb/tentative/content-range.sub.any.js | 20 + ...img-mime-types-coverage.tentative.sub.html | 126 + .../img-png-mislabeled-as-html.sub-ref.html | 5 + .../img-png-mislabeled-as-html.sub.html | 7 + .../tentative/img-png-unlabeled.sub-ref.html | 5 + .../orb/tentative/img-png-unlabeled.sub.html | 7 + .../orb/tentative/known-mime-type.sub.any.js | 99 + .../fetch/orb/tentative/nosniff.sub.any.js | 32 + .../script-js-unlabeled-gziped.sub.html | 24 + .../orb/tentative/script-unlabeled.sub.html | 24 + ...pt-utf16-without-bom-hint-charset.sub.html | 22 + .../wpt/fetch/orb/tentative/status.sub.any.js | 16 + .../wpt/fetch/orb/tentative/status.sub.html | 17 + .../tentative/unknown-mime-type.sub.any.js | 40 + .../wpt/fetch/origin/assorted.window.js | 211 + .../origin/resources/redirect-and-stash.py | 38 + .../fetch/origin/resources/referrer-policy.py | 7 + .../wpt/fetch/private-network-access/META.yml | 7 + .../fetch/private-network-access/README.md | 10 + .../anchor.tentative.https.window.js | 191 + .../anchor.tentative.window.js | 95 + ...eflight-required.tentative.https.window.js | 91 + ...ubresource-fetch.tentative.https.window.js | 330 ++ .../fenced-frame.tentative.https.window.js | 150 + ...-treat-as-public.tentative.https.window.js | 80 + .../fetch.tentative.https.window.js | 271 + .../fetch.tentative.window.js | 183 + .../iframe.tentative.https.window.js | 267 + .../iframe.tentative.window.js | 110 + ...ed-content-fetch.tentative.https.window.js | 278 + .../nested-worker.tentative.https.window.js | 36 + .../nested-worker.tentative.window.js | 36 + .../preflight-cache.https.tentative.window.js | 88 + .../redirect.tentative.https.window.js | 640 +++ .../resources/anchor.html | 16 + .../resources/executor.html | 9 + .../resources/fenced-frame-fetcher.https.html | 25 + .../fenced-frame-fetcher.https.html.headers | 1 + ...e-private-network-access-target.https.html | 8 + ...ed-frame-private-network-access.https.html | 14 + ...-private-network-access.https.html.headers | 1 + .../resources/fetcher.html | 21 + .../resources/fetcher.js | 20 + .../iframed-no-preflight-received.html | 7 + .../resources/iframed.html | 7 + .../resources/iframer.html | 9 + .../resources/no-preflight-received.html | 6 + .../resources/open-to-existing-window.html | 12 + .../resources/openee.html | 8 + .../resources/opener.html | 11 + .../resources/preflight.py | 191 + .../resources/service-worker-bridge.html | 155 + .../resources/service-worker-fetch-all.js | 20 + .../resources/service-worker.js | 18 + .../resources/shared-fetcher.js | 23 + .../resources/shared-worker-blob-fetcher.html | 50 + .../resources/shared-worker-fetcher.html | 19 + .../resources/socket-opener.html | 15 + .../resources/support.sub.js | 920 +++ .../resources/worker-blob-fetcher.html | 45 + .../resources/worker-fetcher.html | 18 + .../resources/worker-fetcher.js | 11 + .../resources/xhr-sender.html | 33 + ...background-fetch.tentative.https.window.js | 143 + ...-treat-as-public.tentative.https.window.js | 101 + ...r-fetch-document.tentative.https.window.js | 114 + ...ice-worker-fetch.tentative.https.window.js | 188 + ...ce-worker-update.tentative.https.window.js | 106 + .../service-worker.tentative.https.window.js | 84 + ...orker-blob-fetch.tentative.https.window.js | 168 + ...ared-worker-blob-fetch.tentative.window.js | 173 + ...red-worker-fetch.tentative.https.window.js | 167 + .../shared-worker-fetch.tentative.window.js | 154 + .../shared-worker.tentative.https.window.js | 34 + .../shared-worker.tentative.window.js | 34 + .../websocket.tentative.https.window.js | 40 + .../websocket.tentative.window.js | 40 + ...ow-open-existing.tentative.https.window.js | 209 + .../window-open-existing.tentative.window.js | 95 + .../window-open.tentative.https.window.js | 205 + .../window-open.tentative.window.js | 95 + .../worker-blob-fetch.tentative.window.js | 155 + .../worker-fetch.tentative.https.window.js | 151 + .../worker-fetch.tentative.window.js | 154 + .../worker.tentative.https.window.js | 37 + .../worker.tentative.window.js | 37 + ...-treat-as-public.tentative.https.window.js | 83 + .../xhr.https.tentative.window.js | 142 + .../xhr.tentative.window.js | 195 + test/fixtures/wpt/fetch/range/blob.any.js | 233 + test/fixtures/wpt/fetch/range/data.any.js | 29 + test/fixtures/wpt/fetch/range/general.any.js | 140 + .../wpt/fetch/range/general.window.js | 29 + .../range/non-matching-range-response.html | 34 + .../wpt/fetch/range/resources/basic.html | 1 + .../wpt/fetch/range/resources/long-wav.py | 134 + .../fetch/range/resources/partial-script.py | 29 + .../wpt/fetch/range/resources/partial-text.py | 53 + .../wpt/fetch/range/resources/range-sw.js | 218 + .../wpt/fetch/range/resources/stash-take.py | 7 + .../wpt/fetch/range/resources/utils.js | 36 + .../fetch/range/resources/video-with-range.py | 43 + .../wpt/fetch/range/sw.https.window.js | 228 + .../302-found-post-handler.py | 15 + .../redirect-navigate/302-found-post.html | 20 + .../redirect-navigate/preserve-fragment.html | 202 + .../resources/destination.html | 28 + .../wpt/fetch/redirects/data.window.js | 25 + .../redirects/subresource-fragments.html | 39 + .../wpt/fetch/security/1xx-response.any.js | 28 + ...tigation-allowed-apis.tentative.https.html | 80 + ...kup-mitigation-data-url.tentative.sub.html | 229 + .../dangling-markup-mitigation.tentative.html | 147 + ...ing-markup-mitigation.tentative.https.html | 61 + .../fetch/security/dangling-markup/media.html | 27 + .../security/dangling-markup/option.html | 51 + .../dangling-markup/resources/empty.html | 1 + .../dangling-markup/resources/helper.js | 63 + .../dangling-markup/service-worker.js | 41 + .../security/dangling-markup/textarea.html | 34 + .../embedded-credentials.tentative.sub.html | 89 + ...edirect-to-url-with-credentials.https.html | 68 + .../embedded-credential-window.sub.html | 19 + .../fetch-sw.https.html | 65 + .../fetch/stale-while-revalidate/fetch.any.js | 32 + .../resources/stale-css.py | 28 + .../resources/stale-image.py | 40 + .../resources/stale-script.py | 32 + .../revalidate-not-blocked-by-csp.html | 69 + .../stale-while-revalidate/stale-css.html | 51 + .../stale-while-revalidate/stale-image.html | 55 + .../stale-while-revalidate/stale-script.html | 59 + .../stale-while-revalidate/sw-intercept.js | 14 + .../wpt/interfaces/ANGLE_instanced_arrays.idl | 12 + test/fixtures/wpt/interfaces/CSP.idl | 56 + test/fixtures/wpt/interfaces/DOM-Parsing.idl | 10 + .../wpt/interfaces/EXT_blend_minmax.idl | 10 + .../wpt/interfaces/EXT_color_buffer_float.idl | 8 + .../EXT_color_buffer_half_float.idl | 12 + .../interfaces/EXT_disjoint_timer_query.idl | 30 + .../EXT_disjoint_timer_query_webgl2.idl | 14 + .../wpt/interfaces/EXT_float_blend.idl | 8 + .../wpt/interfaces/EXT_frag_depth.idl | 8 + test/fixtures/wpt/interfaces/EXT_sRGB.idl | 12 + .../wpt/interfaces/EXT_shader_texture_lod.idl | 8 + .../EXT_texture_compression_bptc.idl | 12 + .../EXT_texture_compression_rgtc.idl | 12 + .../EXT_texture_filter_anisotropic.idl | 10 + .../wpt/interfaces/EXT_texture_norm16.idl | 16 + test/fixtures/wpt/interfaces/FileAPI.idl | 101 + test/fixtures/wpt/interfaces/IndexedDB.idl | 226 + .../KHR_parallel_shader_compile.idl | 9 + test/fixtures/wpt/interfaces/META.yml | 4 + .../interfaces/OES_draw_buffers_indexed.idl | 26 + .../wpt/interfaces/OES_element_index_uint.idl | 8 + .../wpt/interfaces/OES_fbo_render_mipmap.idl | 8 + .../interfaces/OES_standard_derivatives.idl | 9 + .../wpt/interfaces/OES_texture_float.idl | 7 + .../interfaces/OES_texture_float_linear.idl | 7 + .../wpt/interfaces/OES_texture_half_float.idl | 9 + .../OES_texture_half_float_linear.idl | 7 + .../interfaces/OES_vertex_array_object.idl | 18 + .../wpt/interfaces/OVR_multiview2.idl | 14 + test/fixtures/wpt/interfaces/README.md | 3 + test/fixtures/wpt/interfaces/SVG.idl | 693 +++ ...WEBGL_blend_equation_advanced_coherent.idl | 23 + .../interfaces/WEBGL_clip_cull_distance.idl | 20 + .../interfaces/WEBGL_color_buffer_float.idl | 11 + .../WEBGL_compressed_texture_astc.idl | 41 + .../WEBGL_compressed_texture_etc.idl | 19 + .../WEBGL_compressed_texture_etc1.idl | 10 + .../WEBGL_compressed_texture_pvrtc.idl | 13 + .../WEBGL_compressed_texture_s3tc.idl | 13 + .../WEBGL_compressed_texture_s3tc_srgb.idl | 13 + .../interfaces/WEBGL_debug_renderer_info.idl | 12 + .../wpt/interfaces/WEBGL_debug_shaders.idl | 11 + .../wpt/interfaces/WEBGL_depth_texture.idl | 9 + .../wpt/interfaces/WEBGL_draw_buffers.idl | 46 + ...aw_instanced_base_vertex_base_instance.idl | 14 + .../wpt/interfaces/WEBGL_lose_context.idl | 10 + .../wpt/interfaces/WEBGL_multi_draw.idl | 32 + ...aw_instanced_base_vertex_base_instance.idl | 26 + .../wpt/interfaces/WEBGL_provoking_vertex.idl | 13 + test/fixtures/wpt/interfaces/WebCryptoAPI.idl | 262 + .../fixtures/wpt/interfaces/accelerometer.idl | 28 + .../fixtures/wpt/interfaces/ambient-light.idl | 10 + test/fixtures/wpt/interfaces/anchors.idl | 37 + .../wpt/interfaces/anonymous-iframe.idl | 12 + .../interfaces/attribution-reporting-api.idl | 27 + test/fixtures/wpt/interfaces/audio-output.idl | 17 + .../fixtures/wpt/interfaces/audio-session.idl | 33 + .../wpt/interfaces/autoplay-detection.idl | 19 + .../wpt/interfaces/background-fetch.idl | 89 + .../wpt/interfaces/background-sync.idl | 30 + test/fixtures/wpt/interfaces/badging.idl | 15 + .../wpt/interfaces/battery-status.idl | 21 + test/fixtures/wpt/interfaces/beacon.idl | 8 + .../interfaces/capture-handle-identity.idl | 27 + .../wpt/interfaces/captured-mouse-events.idl | 20 + .../captured-mouse-events.tentative.idl | 25 + .../wpt/interfaces/clipboard-apis.idl | 57 + .../command-and-commandfor.tentative.idl | 15 + test/fixtures/wpt/interfaces/compat.idl | 13 + test/fixtures/wpt/interfaces/compression.idl | 22 + .../wpt/interfaces/compute-pressure.idl | 37 + test/fixtures/wpt/interfaces/console.idl | 34 + .../wpt/interfaces/contact-picker.idl | 44 + .../fixtures/wpt/interfaces/content-index.idl | 46 + test/fixtures/wpt/interfaces/cookie-store.idl | 113 + .../wpt/interfaces/credential-management.idl | 107 + .../interfaces/csp-embedded-enforcement.idl | 8 + test/fixtures/wpt/interfaces/csp-next.idl | 21 + .../wpt/interfaces/css-anchor-position.idl | 84 + .../wpt/interfaces/css-animation-worklet.idl | 37 + .../wpt/interfaces/css-animations-2.idl | 9 + .../wpt/interfaces/css-animations.idl | 47 + .../fixtures/wpt/interfaces/css-cascade-6.idl | 10 + test/fixtures/wpt/interfaces/css-cascade.idl | 14 + test/fixtures/wpt/interfaces/css-color-5.idl | 12 + .../wpt/interfaces/css-conditional-5.idl | 10 + .../wpt/interfaces/css-conditional.idl | 29 + test/fixtures/wpt/interfaces/css-contain.idl | 13 + .../wpt/interfaces/css-counter-styles.idl | 23 + .../wpt/interfaces/css-font-loading.idl | 128 + test/fixtures/wpt/interfaces/css-fonts.idl | 70 + .../wpt/interfaces/css-highlight-api.idl | 27 + test/fixtures/wpt/interfaces/css-images-4.idl | 8 + .../wpt/interfaces/css-layout-api.idl | 144 + test/fixtures/wpt/interfaces/css-masking.idl | 20 + test/fixtures/wpt/interfaces/css-mixins.idl | 7 + test/fixtures/wpt/interfaces/css-nav.idl | 48 + test/fixtures/wpt/interfaces/css-nesting.idl | 9 + .../fixtures/wpt/interfaces/css-paint-api.idl | 39 + .../wpt/interfaces/css-parser-api.idl | 76 + .../interfaces/css-properties-values-api.idl | 23 + test/fixtures/wpt/interfaces/css-pseudo.idl | 16 + test/fixtures/wpt/interfaces/css-regions.idl | 29 + .../wpt/interfaces/css-scroll-snap-2.idl | 21 + .../wpt/interfaces/css-shadow-parts.idl | 8 + .../wpt/interfaces/css-transitions-2.idl | 13 + .../wpt/interfaces/css-transitions.idl | 25 + test/fixtures/wpt/interfaces/css-typed-om.idl | 428 ++ .../wpt/interfaces/css-view-transitions-2.idl | 29 + .../wpt/interfaces/css-view-transitions.idl | 14 + test/fixtures/wpt/interfaces/css-viewport.idl | 13 + test/fixtures/wpt/interfaces/cssom-view.idl | 208 + test/fixtures/wpt/interfaces/cssom.idl | 191 + test/fixtures/wpt/interfaces/datacue.idl | 12 + .../wpt/interfaces/deprecation-reporting.idl | 15 + .../wpt/interfaces/device-attributes.idl | 13 + .../fixtures/wpt/interfaces/device-memory.idl | 14 + .../wpt/interfaces/device-posture.idl | 20 + .../wpt/interfaces/digital-credentials.idl | 23 + .../fixtures/wpt/interfaces/digital-goods.idl | 44 + .../document-picture-in-picture.idl | 35 + test/fixtures/wpt/interfaces/dom.idl | 650 +++ test/fixtures/wpt/interfaces/edit-context.idl | 101 + .../wpt/interfaces/element-capture.idl | 14 + .../wpt/interfaces/element-timing.idl | 24 + test/fixtures/wpt/interfaces/encoding.idl | 59 + .../wpt/interfaces/encrypted-media.idl | 132 + test/fixtures/wpt/interfaces/entries-api.idl | 71 + test/fixtures/wpt/interfaces/event-timing.idl | 29 + .../wpt/interfaces/eyedropper-api.idl | 18 + test/fixtures/wpt/interfaces/fedcm.idl | 121 + test/fixtures/wpt/interfaces/fenced-frame.idl | 88 + test/fixtures/wpt/interfaces/fetch.idl | 118 + test/fixtures/wpt/interfaces/fido.idl | 47 + .../wpt/interfaces/file-system-access.idl | 72 + .../wpt/interfaces/filter-effects.idl | 341 ++ .../wpt/interfaces/font-metrics-api.idl | 42 + test/fixtures/wpt/interfaces/fs.idl | 97 + test/fixtures/wpt/interfaces/fullscreen.idl | 35 + .../wpt/interfaces/gamepad-extensions.idl | 43 + test/fixtures/wpt/interfaces/gamepad.idl | 79 + .../wpt/interfaces/generic-sensor.idl | 30 + .../wpt/interfaces/geolocation-sensor.idl | 37 + test/fixtures/wpt/interfaces/geolocation.idl | 67 + test/fixtures/wpt/interfaces/geometry.idl | 290 + .../interfaces/get-installed-related-apps.idl | 16 + test/fixtures/wpt/interfaces/gpc.idl | 10 + test/fixtures/wpt/interfaces/gyroscope.idl | 18 + .../interfaces/handwriting-recognition.idl | 100 + test/fixtures/wpt/interfaces/hr-time.idl | 19 + .../wpt/interfaces/html-media-capture.idl | 8 + test/fixtures/wpt/interfaces/html.idl | 3001 ++++++++++ .../wpt/interfaces/idle-detection.idl | 31 + .../fixtures/wpt/interfaces/image-capture.idl | 160 + .../wpt/interfaces/image-resource.idl | 11 + .../wpt/interfaces/ink-enhancement.idl | 31 + .../interfaces/input-device-capabilities.idl | 24 + test/fixtures/wpt/interfaces/input-events.idl | 14 + .../interest-invokers.tentative.idl | 7 + .../wpt/interfaces/intersection-observer.idl | 54 + .../wpt/interfaces/intervention-reporting.idl | 14 + .../wpt/interfaces/is-input-pending.idl | 16 + .../wpt/interfaces/js-self-profiling.idl | 44 + .../fixtures/wpt/interfaces/keyboard-lock.idl | 14 + test/fixtures/wpt/interfaces/keyboard-map.idl | 15 + .../interfaces/largest-contentful-paint.idl | 15 + .../wpt/interfaces/layout-instability.idl | 20 + .../wpt/interfaces/local-font-access.idl | 24 + test/fixtures/wpt/interfaces/login-status.idl | 19 + .../wpt/interfaces/long-animation-frames.idl | 56 + test/fixtures/wpt/interfaces/longtasks.idl | 31 + test/fixtures/wpt/interfaces/magnetometer.idl | 31 + .../wpt/interfaces/managed-configuration.idl | 20 + .../wpt/interfaces/manifest-incubations.idl | 24 + test/fixtures/wpt/interfaces/mathml-core.idl | 9 + .../wpt/interfaces/media-capabilities.idl | 115 + .../wpt/interfaces/media-playback-quality.idl | 18 + test/fixtures/wpt/interfaces/media-source.idl | 123 + .../interfaces/mediacapture-automation.idl | 36 + .../interfaces/mediacapture-fromelement.idl | 17 + .../mediacapture-handle-actions.idl | 31 + .../wpt/interfaces/mediacapture-region.idl | 15 + .../wpt/interfaces/mediacapture-streams.idl | 249 + .../mediacapture-surface-control.idl | 16 + .../wpt/interfaces/mediacapture-transform.idl | 23 + .../wpt/interfaces/mediacapture-viewport.idl | 9 + .../wpt/interfaces/mediaqueries-5.idl | 30 + test/fixtures/wpt/interfaces/mediasession.idl | 105 + .../wpt/interfaces/mediastream-recording.idl | 62 + .../fixtures/wpt/interfaces/model-element.idl | 9 + .../wpt/interfaces/mst-content-hint.idl | 18 + .../wpt/interfaces/navigation-timing.idl | 73 + test/fixtures/wpt/interfaces/netinfo.idl | 43 + .../fixtures/wpt/interfaces/notifications.idl | 101 + test/fixtures/wpt/interfaces/observable.idl | 106 + .../wpt/interfaces/observable.tentative.idl | 31 + .../wpt/interfaces/orientation-event.idl | 78 + .../wpt/interfaces/orientation-sensor.idl | 28 + .../wpt/interfaces/page-lifecycle.idl | 19 + test/fixtures/wpt/interfaces/paint-timing.idl | 14 + .../wpt/interfaces/parakeet.tentative.idl | 32 + .../wpt/interfaces/payment-handler.idl | 96 + .../wpt/interfaces/payment-request.idl | 172 + .../interfaces/performance-measure-memory.idl | 30 + .../wpt/interfaces/performance-timeline.idl | 51 + .../interfaces/periodic-background-sync.idl | 34 + .../wpt/interfaces/permissions-policy.idl | 30 + .../wpt/interfaces/permissions-request.idl | 8 + .../wpt/interfaces/permissions-revoke.idl | 8 + test/fixtures/wpt/interfaces/permissions.idl | 41 + .../wpt/interfaces/picture-in-picture.idl | 41 + .../fixtures/wpt/interfaces/pointerevents.idl | 66 + test/fixtures/wpt/interfaces/pointerlock.idl | 32 + test/fixtures/wpt/interfaces/portals.idl | 50 + .../wpt/interfaces/prefer-current-tab.idl | 8 + .../wpt/interfaces/prerendering-revamped.idl | 15 + .../wpt/interfaces/presentation-api.idl | 95 + .../interfaces/private-aggregation-api.idl | 21 + .../interfaces/private-click-measurement.idl | 8 + .../wpt/interfaces/private-network-access.idl | 19 + test/fixtures/wpt/interfaces/proximity.idl | 12 + test/fixtures/wpt/interfaces/push-api.idl | 94 + .../wpt/interfaces/raw-camera-access.idl | 18 + .../wpt/interfaces/real-world-meshing.idl | 21 + .../wpt/interfaces/referrer-policy.idl | 16 + .../wpt/interfaces/remote-playback.idl | 32 + test/fixtures/wpt/interfaces/reporting.idl | 39 + .../interfaces/requestStorageAccessFor.idl | 12 + .../wpt/interfaces/requestidlecallback.idl | 20 + .../wpt/interfaces/resize-observer.idl | 37 + .../wpt/interfaces/resource-timing.idl | 43 + .../wpt/interfaces/saa-non-cookie-storage.idl | 45 + .../fixtures/wpt/interfaces/sanitizer-api.idl | 64 + .../interfaces/sanitizer-api.tentative.idl | 17 + test/fixtures/wpt/interfaces/savedata.idl | 10 + .../wpt/interfaces/scheduling-apis.idl | 64 + .../wpt/interfaces/screen-capture.idl | 92 + .../wpt/interfaces/screen-orientation.idl | 35 + .../wpt/interfaces/screen-wake-lock.idl | 24 + .../wpt/interfaces/scroll-animations.idl | 37 + .../interfaces/scroll-to-text-fragment.idl | 12 + .../secure-payment-confirmation.idl | 56 + .../fixtures/wpt/interfaces/selection-api.idl | 50 + test/fixtures/wpt/interfaces/serial.idl | 89 + .../fixtures/wpt/interfaces/server-timing.idl | 17 + .../wpt/interfaces/service-workers.idl | 276 + .../wpt/interfaces/shape-detection-api.idl | 69 + .../wpt/interfaces/shared-storage.idl | 116 + test/fixtures/wpt/interfaces/speech-api.idl | 202 + .../wpt/interfaces/storage-access.idl | 9 + .../wpt/interfaces/storage-buckets.idl | 45 + test/fixtures/wpt/interfaces/storage.idl | 25 + test/fixtures/wpt/interfaces/streams.idl | 230 + .../wpt/interfaces/svg-animations.idl | 68 + test/fixtures/wpt/interfaces/testutils.idl | 9 + .../wpt/interfaces/text-detection-api.idl | 18 + test/fixtures/wpt/interfaces/touch-events.idl | 79 + .../wpt/interfaces/trust-token-api.idl | 34 + .../fixtures/wpt/interfaces/trusted-types.idl | 65 + test/fixtures/wpt/interfaces/turtledove.idl | 395 ++ .../wpt/interfaces/ua-client-hints.idl | 45 + test/fixtures/wpt/interfaces/uievents.idl | 237 + test/fixtures/wpt/interfaces/url.idl | 47 + test/fixtures/wpt/interfaces/urlpattern.idl | 63 + test/fixtures/wpt/interfaces/user-timing.idl | 34 + test/fixtures/wpt/interfaces/vibration.idl | 10 + test/fixtures/wpt/interfaces/video-rvfc.idl | 27 + .../wpt/interfaces/virtual-keyboard.idl | 21 + .../interfaces/virtual-keyboard.tentative.idl | 15 + test/fixtures/wpt/interfaces/wai-aria.idl | 60 + test/fixtures/wpt/interfaces/wasm-js-api.idl | 112 + test/fixtures/wpt/interfaces/wasm-web-api.idl | 11 + .../wpt/interfaces/web-animations-2.idl | 113 + .../wpt/interfaces/web-animations.idl | 150 + .../wpt/interfaces/web-app-launch.idl | 19 + .../wpt/interfaces/web-bluetooth-scanning.idl | 67 + .../fixtures/wpt/interfaces/web-bluetooth.idl | 252 + test/fixtures/wpt/interfaces/web-locks.idl | 50 + test/fixtures/wpt/interfaces/web-nfc.idl | 81 + test/fixtures/wpt/interfaces/web-otp.idl | 21 + test/fixtures/wpt/interfaces/web-share.idl | 16 + test/fixtures/wpt/interfaces/webaudio.idl | 684 +++ test/fixtures/wpt/interfaces/webauthn.idl | 385 ++ .../webcodecs-aac-codec-registration.idl | 17 + .../webcodecs-av1-codec-registration.idl | 12 + .../webcodecs-avc-codec-registration.idl | 25 + .../webcodecs-flac-codec-registration.idl | 13 + .../webcodecs-hevc-codec-registration.idl | 25 + .../webcodecs-opus-codec-registration.idl | 36 + .../webcodecs-vp9-codec-registration.idl | 12 + test/fixtures/wpt/interfaces/webcodecs.idl | 536 ++ .../interfaces/webcrypto-secure-curves.idl | 8 + test/fixtures/wpt/interfaces/webdriver.idl | 9 + test/fixtures/wpt/interfaces/webgl1.idl | 753 +++ test/fixtures/wpt/interfaces/webgl2.idl | 581 ++ test/fixtures/wpt/interfaces/webgpu.idl | 1336 +++++ test/fixtures/wpt/interfaces/webhid.idl | 127 + test/fixtures/wpt/interfaces/webidl.idl | 49 + test/fixtures/wpt/interfaces/webmidi.idl | 91 + test/fixtures/wpt/interfaces/webnn.idl | 944 ++++ .../interfaces/webrtc-encoded-transform.idl | 150 + test/fixtures/wpt/interfaces/webrtc-ice.idl | 24 + .../wpt/interfaces/webrtc-identity.idl | 97 + .../wpt/interfaces/webrtc-priority.idl | 24 + test/fixtures/wpt/interfaces/webrtc-stats.idl | 290 + test/fixtures/wpt/interfaces/webrtc-svc.idl | 8 + test/fixtures/wpt/interfaces/webrtc.idl | 631 +++ test/fixtures/wpt/interfaces/websockets.idl | 48 + test/fixtures/wpt/interfaces/webtransport.idl | 166 + test/fixtures/wpt/interfaces/webusb.idl | 258 + .../wpt/interfaces/webvr.tentative.idl | 204 + test/fixtures/wpt/interfaces/webvtt.idl | 40 + .../wpt/interfaces/webxr-ar-module.idl | 29 + .../wpt/interfaces/webxr-depth-sensing.idl | 61 + .../wpt/interfaces/webxr-dom-overlays.idl | 31 + .../wpt/interfaces/webxr-gamepads-module.idl | 8 + .../wpt/interfaces/webxr-hand-input.idl | 66 + .../wpt/interfaces/webxr-hit-test.idl | 69 + .../interfaces/webxr-lighting-estimation.idl | 39 + .../wpt/interfaces/webxr-plane-detection.idl | 32 + test/fixtures/wpt/interfaces/webxr.idl | 298 + test/fixtures/wpt/interfaces/webxrlayers.idl | 221 + .../interfaces/window-controls-overlay.idl | 28 + .../wpt/interfaces/window-management.idl | 42 + test/fixtures/wpt/interfaces/xhr.idl | 99 + test/fixtures/wpt/mimesniff/META.yml | 4 + test/fixtures/wpt/mimesniff/README.md | 4 + .../wpt/mimesniff/media/media-sniff.window.js | 32 + .../wpt/mimesniff/media/resources/flac.flac | Bin 0 -> 8493 bytes .../mimesniff/media/resources/make-vectors.sh | 10 + .../wpt/mimesniff/media/resources/mp3-raw.mp3 | Bin 0 -> 417 bytes .../media/resources/mp3-with-id3.mp3 | Bin 0 -> 644 bytes .../wpt/mimesniff/media/resources/mp4.mp4 | Bin 0 -> 1231 bytes .../wpt/mimesniff/media/resources/ogg.ogg | Bin 0 -> 3594 bytes .../wpt/mimesniff/media/resources/wav.wav | Bin 0 -> 486 bytes .../wpt/mimesniff/media/resources/webm.webm | Bin 0 -> 877 bytes .../wpt/mimesniff/mime-types/README.md | 47 + .../mime-types/charset-parameter.window.js | 61 + .../wpt/mimesniff/mime-types/parsing.any.js | 57 + .../resources/generated-mime-types.json | 3526 ++++++++++++ .../resources/generated-mime-types.py | 48 + .../mime-types/resources/mime-charset.py | 19 + .../mime-types/resources/mime-groups.json | 159 + .../resources/mime-types-minimized.json | 130 + .../mime-types/resources/mime-types.json | 471 ++ .../wpt/mimesniff/sniffing/html.window.js | 12 + .../wpt/mimesniff/sniffing/support/atom.html | 3 + .../wpt/mimesniff/sniffing/support/rss.html | 3 + test/fixtures/wpt/resources/.htaccess | 2 + test/fixtures/wpt/resources/META.yml | 2 + .../SVGAnimationTestCase-testharness.js | 102 + test/fixtures/wpt/resources/accesskey.js | 34 + test/fixtures/wpt/resources/blank.html | 16 + test/fixtures/wpt/resources/channel.sub.js | 1097 ++++ .../fixtures/wpt/resources/check-layout-th.js | 253 + .../fixtures/wpt/resources/chromium/README.md | 7 + .../chromium/contacts_manager_mock.js | 90 + .../chromium/content-index-helpers.js | 9 + .../chromium/enable-hyperlink-auditing.js | 2 + .../wpt/resources/chromium/fake-hid.js | 297 + .../wpt/resources/chromium/fake-serial.js | 469 ++ .../chromium/mock-barcodedetection.js | 136 + .../chromium/mock-barcodedetection.js.headers | 1 + .../chromium/mock-battery-monitor.headers | 1 + .../chromium/mock-battery-monitor.js | 61 + .../resources/chromium/mock-facedetection.js | 130 + .../chromium/mock-facedetection.js.headers | 1 + .../resources/chromium/mock-idle-detection.js | 80 + .../resources/chromium/mock-imagecapture.js | 309 + .../resources/chromium/mock-managed-config.js | 91 + .../wpt/resources/chromium/mock-subapps.js | 89 + .../resources/chromium/mock-textdetection.js | 92 + .../chromium/mock-textdetection.js.headers | 1 + .../wpt/resources/chromium/nfc-mock.js | 437 ++ .../resources/chromium/web-bluetooth-test.js | 629 +++ .../chromium/web-bluetooth-test.js.headers | 1 + .../resources/chromium/webusb-child-test.js | 47 + .../chromium/webusb-child-test.js.headers | 1 + .../wpt/resources/chromium/webusb-test.js | 583 ++ .../resources/chromium/webusb-test.js.headers | 1 + .../chromium/webxr-test-math-helper.js | 298 + .../webxr-test-math-helper.js.headers | 1 + .../wpt/resources/chromium/webxr-test.js | 2150 +++++++ .../resources/chromium/webxr-test.js.headers | 1 + test/fixtures/wpt/resources/idlharness.js | 3568 ++++++++++++ .../wpt/resources/idlharness.js.headers | 2 + .../wpt/resources/out-of-scope-test.js | 5 + test/fixtures/wpt/resources/readme.md | 14 + test/fixtures/wpt/resources/sriharness.js | 226 + test/fixtures/wpt/resources/test-only-api.js | 31 + .../wpt/resources/test-only-api.js.headers | 2 + .../fixtures/wpt/resources/test-only-api.m.js | 5 + .../wpt/resources/test-only-api.m.js.headers | 2 + test/fixtures/wpt/resources/test/README.md | 83 + test/fixtures/wpt/resources/test/conftest.py | 266 + test/fixtures/wpt/resources/test/harness.html | 26 + .../fixtures/wpt/resources/test/idl-helper.js | 24 + .../wpt/resources/test/nested-testharness.js | 80 + .../wpt/resources/test/requirements.txt | 1 + .../test/tests/functional/abortsignal.html | 49 + .../test/tests/functional/add_cleanup.html | 91 + .../tests/functional/add_cleanup_async.html | 85 + .../add_cleanup_async_bad_return.html | 50 + .../add_cleanup_async_rejection.html | 94 + ...dd_cleanup_async_rejection_after_load.html | 52 + .../functional/add_cleanup_async_timeout.html | 57 + .../functional/add_cleanup_bad_return.html | 61 + .../tests/functional/add_cleanup_count.html | 39 + .../tests/functional/add_cleanup_err.html | 45 + .../functional/add_cleanup_err_multi.html | 52 + .../functional/add_cleanup_sync_queue.html | 55 + .../test/tests/functional/api-tests-1.html | 991 ++++ .../test/tests/functional/api-tests-2.html | 62 + .../test/tests/functional/api-tests-3.html | 34 + .../tests/functional/assert-array-equals.html | 162 + .../tests/functional/assert-throws-dom.html | 55 + .../test/tests/functional/force_timeout.html | 60 + .../tests/functional/generate-callback.html | 153 + .../test_partial_interface_of.html | 89 + .../IdlInterface/test_exposed_wildcard.html | 233 + .../test_immutable_prototype.html | 298 + .../IdlInterface/test_interface_mixin.html | 131 + .../test_partial_interface_of.html | 187 + .../test_primary_interface_of.html | 116 + .../IdlInterface/test_to_json_operation.html | 177 + .../IdlNamespace/test_attribute.html | 100 + .../IdlNamespace/test_operation.html | 253 + .../IdlNamespace/test_partial_namespace.html | 125 + .../tests/functional/iframe-callback.html | 116 + .../functional/iframe-consolidate-errors.html | 50 + .../functional/iframe-consolidate-tests.html | 85 + .../test/tests/functional/iframe-msg.html | 84 + .../test/tests/functional/log-insertion.html | 46 + .../test/tests/functional/no-title.html | 146 + .../test/tests/functional/order.html | 36 + .../test/tests/functional/promise-async.html | 172 + .../tests/functional/promise-with-sync.html | 79 + .../test/tests/functional/promise.html | 219 + .../test/tests/functional/queue.html | 130 + .../tests/functional/setup-function-worker.js | 14 + .../functional/setup-worker-service.html | 86 + .../functional/single-page-test-fail.html | 28 + .../single-page-test-no-assertions.html | 25 + .../functional/single-page-test-no-body.html | 26 + .../functional/single-page-test-pass.html | 28 + .../test/tests/functional/step_wait.html | 79 + .../test/tests/functional/step_wait_func.html | 49 + .../task-scheduling-promise-test.html | 241 + .../functional/task-scheduling-test.html | 141 + .../functional/uncaught-exception-handle.html | 33 + .../functional/uncaught-exception-ignore.html | 35 + .../worker-dedicated-uncaught-allow.html | 45 + .../worker-dedicated-uncaught-single.html | 56 + .../functional/worker-dedicated.sub.html | 88 + .../test/tests/functional/worker-error.js | 8 + .../test/tests/functional/worker-service.html | 115 + .../test/tests/functional/worker-shared.html | 73 + .../tests/functional/worker-uncaught-allow.js | 19 + .../functional/worker-uncaught-single.js | 8 + .../resources/test/tests/functional/worker.js | 34 + .../tests/unit/IdlArray/is_json_type.html | 193 + .../get_reverse_inheritance_stack.html | 47 + .../test_partial_dictionary.html | 39 + .../tests/unit/IdlInterface/constructors.html | 26 + .../default_to_json_operation.html | 114 + .../do_member_unscopable_asserts.html | 56 + .../IdlInterface/get_interface_object.html | 22 + .../get_interface_object_owner.html | 21 + .../IdlInterface/get_legacy_namespace.html | 20 + .../unit/IdlInterface/get_qualified_name.html | 20 + .../get_reverse_inheritance_stack.html | 49 + ...has_default_to_json_regular_operation.html | 47 + .../has_to_json_regular_operation.html | 31 + .../should_have_interface_object.html | 30 + .../test_primary_interface_of_undefined.html | 29 + .../is_to_json_regular_operation.html | 42 + .../unit/IdlInterfaceMember/toString.html | 36 + .../test/tests/unit/assert_implements.html | 43 + .../unit/assert_implements_optional.html | 43 + .../test/tests/unit/assert_object_equals.html | 152 + .../unit/async-test-return-restrictions.html | 135 + .../wpt/resources/test/tests/unit/basic.html | 48 + .../unit/exceptional-cases-timeouts.html | 120 + .../test/tests/unit/exceptional-cases.html | 392 ++ .../test/tests/unit/format-value.html | 123 + .../wpt/resources/test/tests/unit/helpers.js | 21 + .../resources/test/tests/unit/late-test.html | 56 + .../tests/unit/promise_setup-timeout.html | 28 + .../test/tests/unit/promise_setup.html | 333 ++ .../test/tests/unit/single_test.html | 94 + .../tests/unit/test-return-restrictions.html | 156 + .../test/tests/unit/throwing-assertions.html | 268 + .../test/tests/unit/unpaired-surrogates.html | 143 + test/fixtures/wpt/resources/test/tox.ini | 13 + test/fixtures/wpt/resources/test/wptserver.py | 58 + .../wpt/resources/testdriver-actions.js | 599 ++ .../wpt/resources/testdriver-vendor.js | 0 .../resources/testdriver-vendor.js.headers | 2 + test/fixtures/wpt/resources/testdriver.js | 1570 ++++++ .../wpt/resources/testdriver.js.headers | 2 + ...rness-shadowrealm-audioworkletprocessor.js | 52 + .../testharness-shadowrealm-inner.js | 38 + .../testharness-shadowrealm-outer.js | 151 + test/fixtures/wpt/resources/testharness.js | 4971 +++++++++++++++++ .../wpt/resources/testharness.js.headers | 2 + .../wpt/resources/testharnessreport.js | 32 + .../resources/testharnessreport.js.headers | 2 + test/fixtures/wpt/resources/webidl2/build.sh | 12 + .../wpt/resources/webidl2/lib/README.md | 4 + .../wpt/resources/webidl2/lib/VERSION.md | 1 + .../wpt/resources/webidl2/lib/webidl2.js | 3824 +++++++++++++ .../resources/webidl2/lib/webidl2.js.headers | 1 + test/fixtures/wpt/service-workers/META.yml | 6 + .../service-workers/cache-storage/META.yml | 3 + .../cache-storage/cache-abort.https.any.js | 81 + .../cache-storage/cache-add.https.any.js | 368 ++ .../cache-storage/cache-delete.https.any.js | 164 + ...s-attributes-for-service-worker.https.html | 75 + .../cache-storage/cache-keys.https.any.js | 212 + .../cache-storage/cache-match.https.any.js | 437 ++ .../cache-storage/cache-matchAll.https.any.js | 244 + .../cache-storage/cache-put.https.any.js | 411 ++ .../cache-storage-buckets.https.any.js | 64 + .../cache-storage-keys.https.any.js | 35 + .../cache-storage-match.https.any.js | 245 + .../cache-storage/cache-storage.https.any.js | 239 + .../cache-storage/common.https.window.js | 44 + .../cache-response-clone.https.html | 17 + .../cache-storage/credentials.https.html | 46 + .../cross-partition.https.tentative.html | 269 + .../cache-storage/resources/blank.html | 2 + ...ache-keys-attributes-for-service-worker.js | 22 + .../cache-storage/resources/common-worker.js | 15 + .../resources/credentials-iframe.html | 38 + .../resources/credentials-worker.js | 59 + .../cache-storage/resources/fetch-status.py | 2 + .../cache-storage/resources/iframe.html | 18 + .../cache-storage/resources/simple.txt | 1 + .../cache-storage/resources/test-helpers.js | 272 + .../cache-storage/resources/vary.py | 25 + .../sandboxed-iframes.https.html | 66 + .../service-workers/idlharness.https.any.js | 53 + .../Service-Worker-Allowed-header.https.html | 88 + .../ServiceWorkerGlobalScope/close.https.html | 11 + .../error-message-event-worker.js | 2 + .../error-message-event.https.html | 47 + ...dable-message-event-constructor.https.html | 10 + .../extendable-message-event.https.html | 226 + .../fetch-on-the-right-interface.https.any.js | 14 + .../isSecureContext.https.html | 32 + .../isSecureContext.serviceworker.js | 5 + .../message-event-ports-worker.js | 3 + .../message-event-ports.https.html | 43 + .../postmessage.https.html | 83 + .../registration-attribute.https.html | 107 + .../resources/close-worker.js | 5 + .../resources/error-worker.js | 12 + ...ndable-message-event-constructor-worker.js | 197 + ...xtendable-message-event-loopback-worker.js | 36 + .../extendable-message-event-ping-worker.js | 23 + .../extendable-message-event-pong-worker.js | 18 + .../extendable-message-event-utils.js | 78 + .../extendable-message-event-worker.js | 5 + .../resources/postmessage-loopback-worker.js | 15 + .../resources/postmessage-ping-worker.js | 15 + .../resources/postmessage-pong-worker.js | 4 + .../registration-attribute-newer-worker.js | 33 + .../registration-attribute-worker.js | 139 + .../unregister-controlling-worker.html | 0 .../resources/unregister-worker.js | 25 + .../resources/update-worker.js | 22 + .../resources/update-worker.py | 16 + .../service-worker-error-event.https.html | 31 + .../unregister.https.html | 139 + .../update.https.html | 48 + .../service-worker/WEB_FEATURES.yml | 5 + .../about-blank-replacement.https.html | 181 + ...vent-after-install-state-change.https.html | 33 + .../activation-after-registration.https.html | 28 + .../service-worker/activation.https.html | 168 + .../service-worker/active.https.html | 50 + ...claim-affect-other-registration.https.html | 136 + .../service-worker/claim-fetch.https.html | 90 + .../claim-not-using-registration.https.html | 131 + .../claim-shared-worker-fetch.https.html | 71 + .../claim-using-registration.https.html | 103 + .../claim-with-redirect.https.html | 59 + .../claim-worker-fetch.https.html | 83 + .../service-worker/client-id.https.html | 60 + .../service-worker/client-navigate.https.html | 107 + .../client-url-of-blob-url-worker.https.html | 29 + .../clients-get-client-types.https.html | 108 + .../clients-get-cross-origin.https.html | 69 + .../clients-get-resultingClientId.https.html | 177 + .../service-worker/clients-get.https.html | 154 + ...lients-matchall-blob-url-worker.https.html | 85 + .../clients-matchall-client-types.https.html | 92 + ...ients-matchall-exact-controller.https.html | 67 + .../clients-matchall-frozen.https.html | 64 + ...s-matchall-include-uncontrolled.https.html | 117 + .../clients-matchall-on-evaluation.https.html | 24 + .../clients-matchall-order.https.html | 427 ++ .../clients-matchall.https.html | 50 + ...led-dedicatedworker-postMessage.https.html | 44 + .../controlled-iframe-postMessage.https.html | 67 + .../controller-on-disconnect.https.html | 40 + .../controller-on-load.https.html | 46 + .../controller-on-reload.https.html | 58 + ...ler-with-no-fetch-event-handler.https.html | 56 + .../service-worker/credentials.https.html | 100 + .../service-worker/data-iframe.html | 25 + .../data-transfer-files.https.html | 41 + ...ker-service-worker-interception.https.html | 40 + .../detached-context.https.html | 124 + ...-and-object-are-not-intercepted.https.html | 104 + ...xtendable-event-async-waituntil.https.html | 120 + .../extendable-event-waituntil.https.html | 140 + .../fetch-audio-tainting.https.html | 47 + ...ch-canvas-tainting-double-write.https.html | 57 + ...tch-canvas-tainting-image-cache.https.html | 16 + .../fetch-canvas-tainting-image.https.html | 16 + ...tch-canvas-tainting-video-cache.https.html | 17 + ...inting-video-with-range-request.https.html | 115 + .../fetch-canvas-tainting-video.https.html | 17 + ...fetch-cors-exposed-header-names.https.html | 30 + .../service-worker/fetch-cors-xhr.https.html | 49 + .../service-worker/fetch-csp.https.html | 138 + .../service-worker/fetch-error.https.html | 29 + .../fetch-event-add-async.https.html | 11 + ...nt-after-navigation-within-page.https.html | 71 + .../fetch-event-async-respond-with.https.html | 73 + .../fetch-event-handled.https.html | 86 + ...tory-backward-navigation-manual.https.html | 8 + ...story-forward-navigation-manual.https.html | 8 + ...reload-iframe-navigation-manual.https.html | 31 + ...ent-is-reload-navigation-manual.https.html | 8 + .../fetch-event-network-error.https.html | 44 + .../fetch-event-redirect.https.html | 1038 ++++ .../fetch-event-referrer-policy.https.html | 274 + ...tch-event-respond-with-argument.https.html | 44 + ...spond-with-body-loaded-in-chunk.https.html | 24 + ...nt-respond-with-custom-response.https.html | 82 + ...ent-respond-with-partial-stream.https.html | 62 + ...pond-with-readable-stream-chunk.https.html | 23 + ...nt-respond-with-readable-stream.https.html | 88 + ...esponse-body-with-invalid-chunk.https.html | 46 + ...-respond-with-stops-propagation.https.html | 37 + ...event-throws-after-respond-with.https.html | 37 + .../fetch-event-within-sw-manual.https.html | 122 + .../fetch-event-within-sw.https.html | 53 + .../service-worker/fetch-event.https.h2.html | 112 + .../service-worker/fetch-event.https.html | 1000 ++++ .../fetch-frame-resource.https.html | 236 + .../fetch-header-visibility.https.html | 54 + .../fetch-mixed-content-to-inscope.https.html | 21 + ...fetch-mixed-content-to-outscope.https.html | 21 + .../fetch-request-css-base-url.https.html | 87 + .../fetch-request-css-cross-origin.https.html | 81 + .../fetch-request-css-images.https.html | 214 + .../fetch-request-fallback.https.html | 282 + ...ch-request-no-freshness-headers.https.html | 55 + .../fetch-request-redirect.https.html | 385 ++ .../fetch-request-resources.https.html | 375 ++ ...tch-request-xhr-sync-error.https.window.js | 19 + ...etch-request-xhr-sync-on-worker.https.html | 41 + .../fetch-request-xhr-sync.https.html | 53 + .../fetch-request-xhr.https.html | 75 + .../fetch-response-taint.https.html | 223 + .../fetch-response-xhr.https.html | 50 + .../fetch-waits-for-activate.https.html | 128 + .../service-worker/fetch-with-body.https.html | 44 + .../service-worker/getregistration.https.html | 108 + .../getregistrations.https.html | 134 + .../global-serviceworker.https.any.js | 53 + .../service-worker/historical.https.any.js | 5 + ...-to-https-redirect-and-register.https.html | 49 + ...mutable-prototype-serviceworker.https.html | 23 + .../import-scripts-cross-origin.https.html | 18 + .../import-scripts-data-url.https.html | 18 + .../import-scripts-mime-types.https.html | 30 + .../import-scripts-redirect.https.html | 55 + .../import-scripts-resource-map.https.html | 34 + .../import-scripts-updated-flag.https.html | 83 + .../service-worker/indexeddb.https.html | 78 + .../install-event-type.https.html | 30 + .../service-worker/installing.https.html | 48 + .../interface-requirements-sw.https.html | 16 + .../invalid-blobtype.https.html | 40 + .../service-worker/invalid-header.https.html | 39 + .../iso-latin1-header.https.html | 40 + .../local-url-inherit-controller.https.html | 133 + .../service-worker/mime-sniffing.https.html | 24 + .../multi-globals/current/current.https.html | 2 + .../multi-globals/current/test-sw.js | 1 + .../incumbent/incumbent.https.html | 20 + .../multi-globals/incumbent/test-sw.js | 1 + .../relevant/relevant.https.html | 2 + .../multi-globals/relevant/test-sw.js | 1 + .../service-worker/multi-globals/test-sw.js | 1 + .../multi-globals/url-parsing.https.html | 73 + .../service-worker/multipart-image.https.html | 68 + .../multiple-register.https.html | 117 + .../service-worker/multiple-update.https.html | 94 + .../service-worker/navigate-window.https.html | 151 + .../navigation-headers.https.html | 819 +++ .../broken-chunked-encoding.https.html | 42 + .../chunked-encoding.https.html | 25 + .../empty-preload-response-body.https.html | 25 + .../navigation-preload/get-state.https.html | 217 + .../navigationPreload.https.html | 20 + .../navigation-preload/redirect.https.html | 93 + .../request-headers.https.html | 41 + .../resource-timing.https.html | 92 + .../broken-chunked-encoding-scope.asis | 6 + .../broken-chunked-encoding-worker.js | 11 + .../resources/chunked-encoding-scope.py | 19 + .../resources/chunked-encoding-worker.js | 8 + .../navigation-preload/resources/cookie.py | 20 + .../empty-preload-response-body-scope.html | 0 .../empty-preload-response-body-worker.js | 15 + .../resources/get-state-worker.js | 21 + .../navigation-preload/resources/helpers.js | 5 + .../resources/navigation-preload-worker.js | 3 + .../resources/redirect-redirected.html | 3 + .../resources/redirect-scope.py | 38 + .../resources/redirect-worker.js | 35 + .../resources/request-headers-scope.py | 14 + .../resources/request-headers-worker.js | 10 + .../resources/resource-timing-scope.py | 19 + .../resources/resource-timing-worker.js | 37 + .../resources/samesite-iframe.html | 10 + .../resources/samesite-sw-helper.html | 34 + .../resources/samesite-worker.js | 8 + .../resources/wait-for-activate-worker.js | 40 + .../samesite-cookies.https.html | 61 + .../samesite-iframe.https.html | 67 + .../navigation-redirect-body.https.html | 53 + .../navigation-redirect-resolution.https.html | 58 + .../navigation-redirect-to-http.https.html | 25 + .../navigation-redirect.https.html | 846 +++ .../navigation-sets-cookie.https.html | 133 + .../navigation-timing-extended.https.html | 55 + .../navigation-timing-sizes.https.html | 113 + .../navigation-timing.https.html | 77 + .../nested-blob-url-workers.https.html | 42 + .../next-hop-protocol.https.html | 49 + .../no-dynamic-import-in-module.any.js | 7 + .../service-worker/no-dynamic-import.any.js | 3 + .../onactivate-script-error.https.html | 74 + .../oninstall-script-error.https.html | 72 + .../opaque-response-preloaded.https.html | 50 + .../service-worker/opaque-script.https.html | 71 + .../partitioned-claim.tentative.https.html | 74 + .../partitioned-cookies.tentative.https.html | 100 + ...oned-getRegistrations.tentative.https.html | 99 + .../partitioned-matchAll.tentative.https.html | 65 + .../partitioned.tentative.https.html | 188 + .../performance-timeline.https.html | 49 + .../postMessage-client-worker.js | 23 + .../postmessage-blob-url.https.html | 33 + ...sage-from-waiting-serviceworker.https.html | 50 + .../postmessage-msgport-to-client.https.html | 43 + ...message-to-client-message-queue.https.html | 212 + .../postmessage-to-client.https.html | 42 + .../service-worker/postmessage.https.html | 202 + .../service-worker/ready.https.window.js | 223 + .../redirected-response.https.html | 471 ++ .../service-worker/referer.https.html | 40 + .../referrer-policy-header.https.html | 67 + .../referrer-toplevel-script-fetch.https.html | 64 + .../register-closed-window.https.html | 35 + .../register-default-scope.https.html | 69 + ...same-scope-different-script-url.https.html | 233 + ...-wait-forever-in-install-worker.https.html | 57 + .../registration-basic.https.html | 39 + .../registration-end-to-end.https.html | 88 + .../registration-events.https.html | 42 + .../registration-iframe.https.html | 116 + .../registration-mime-types.https.html | 10 + .../registration-schedule-job.https.html | 107 + ...tion-scope-module-static-import.https.html | 41 + .../registration-scope.https.html | 9 + .../registration-script-module.https.html | 13 + .../registration-script-url.https.html | 9 + .../registration-script.https.html | 12 + .../registration-security-error.https.html | 9 + ...ation-service-worker-attributes.https.html | 72 + .../registration-updateviacache.https.html | 204 + .../service-worker/rejections.https.html | 21 + .../request-end-to-end.https.html | 40 + .../resource-timing-bodySize.https.html | 55 + .../resource-timing-cross-origin.https.html | 46 + .../resource-timing-fetch-variants.https.html | 121 + .../resource-timing.sub.https.html | 150 + .../service-worker/resources/404.py | 5 + ...eplacement-blank-dynamic-nested-frame.html | 21 + ...-blank-replacement-blank-nested-frame.html | 21 + .../about-blank-replacement-frame.py | 31 + .../about-blank-replacement-ping-frame.py | 49 + .../about-blank-replacement-popup-frame.py | 32 + ...blank-replacement-srcdoc-nested-frame.html | 22 + ...replacement-uncontrolled-nested-frame.html | 22 + .../about-blank-replacement-worker.js | 95 + .../resources/basic-module-2.js | 1 + .../service-worker/resources/basic-module.js | 1 + .../service-worker/resources/blank.html | 2 + .../bytecheck-worker-imported-script.py | 20 + .../resources/bytecheck-worker.py | 38 + .../claim-blob-url-worker-fetch-iframe.html | 21 + .../claim-nested-worker-fetch-iframe.html | 16 + ...claim-nested-worker-fetch-parent-worker.js | 12 + .../claim-shared-worker-fetch-iframe.html | 13 + .../claim-shared-worker-fetch-worker.js | 8 + .../resources/claim-with-redirect-iframe.html | 48 + .../resources/claim-worker-fetch-iframe.html | 13 + .../resources/claim-worker-fetch-worker.js | 5 + .../service-worker/resources/claim-worker.js | 19 + .../resources/classic-worker.js | 1 + .../resources/client-id-worker.js | 27 + .../resources/client-navigate-frame.html | 12 + .../resources/client-navigate-worker.js | 92 + .../resources/client-navigated-frame.html | 3 + .../client-url-of-blob-url-worker.html | 26 + .../client-url-of-blob-url-worker.js | 10 + .../resources/clients-frame-freeze.html | 15 + .../clients-get-client-types-frame-worker.js | 11 + .../clients-get-client-types-frame.html | 17 + .../clients-get-client-types-shared-worker.js | 10 + .../clients-get-client-types-worker.js | 11 + .../clients-get-cross-origin-frame.html | 50 + .../resources/clients-get-frame.html | 12 + .../resources/clients-get-other-origin.html | 64 + .../clients-get-resultingClientId-worker.js | 60 + .../resources/clients-get-worker.js | 41 + .../clients-matchall-blob-url-worker.html | 20 + ...-matchall-client-types-dedicated-worker.js | 3 + .../clients-matchall-client-types-iframe.html | 8 + ...nts-matchall-client-types-shared-worker.js | 4 + .../clients-matchall-on-evaluation-worker.js | 11 + .../resources/clients-matchall-worker.js | 40 + .../controlled-frame-postMessage.html | 39 + .../controlled-worker-late-postMessage.js | 6 + .../controlled-worker-postMessage.js | 4 + .../resources/cors-approved.txt | 1 + .../resources/cors-approved.txt.headers | 3 + .../service-worker/resources/cors-denied.txt | 2 + .../resources/create-blob-url-worker.js | 22 + .../resources/create-out-of-scope-worker.html | 19 + .../service-worker/resources/direct.css | 3 + .../service-worker/resources/direct.html | 5 + .../service-worker/resources/direct.js | 1 + .../service-worker/resources/direct.py | 11 + .../service-worker/resources/direct.txt | 1 + .../service-worker/resources/echo-content.py | 16 + .../resources/echo-cookie-worker.py | 24 + .../echo-message-to-source-worker.js | 3 + ...d-and-object-are-not-intercepted-worker.js | 14 + ...embed-image-is-not-intercepted-iframe.html | 21 + .../embed-is-not-intercepted-iframe.html | 17 + ...-navigation-is-not-intercepted-iframe.html | 23 + .../embedded-content-from-server.html | 6 + .../embedded-content-from-service-worker.html | 7 + .../resources/empty-but-slow-worker.js | 8 + .../service-worker/resources/empty-worker.js | 1 + .../service-worker/resources/empty.h2.js | 0 .../service-worker/resources/empty.html | 6 + .../service-worker/resources/empty.js | 0 .../enable-client-message-queue.html | 39 + .../resources/end-to-end-worker.js | 7 + .../service-worker/resources/events-worker.js | 12 + .../extendable-event-async-waituntil.js | 210 + .../resources/extendable-event-waituntil.js | 87 + .../resources/fail-on-fetch-worker.js | 5 + .../resources/fetch-access-control-login.html | 16 + .../resources/fetch-access-control.py | 114 + ...tch-canvas-tainting-double-write-worker.js | 7 + .../fetch-canvas-tainting-iframe.html | 70 + .../resources/fetch-canvas-tainting-tests.js | 241 + .../fetch-cors-exposed-header-names-worker.js | 3 + .../resources/fetch-cors-xhr-iframe.html | 170 + .../resources/fetch-csp-iframe.html | 16 + .../fetch-csp-iframe.html.sub.headers | 1 + .../resources/fetch-error-worker.js | 22 + .../resources/fetch-event-add-async-worker.js | 6 + ...t-after-navigation-within-page-iframe.html | 22 + .../fetch-event-async-respond-with-worker.js | 66 + .../resources/fetch-event-handled-worker.js | 37 + ...event-network-error-controllee-iframe.html | 60 + .../fetch-event-network-error-worker.js | 49 + .../fetch-event-network-fallback-worker.js | 3 + ...ch-event-respond-with-argument-iframe.html | 55 + ...etch-event-respond-with-argument-worker.js | 14 + ...espond-with-body-loaded-in-chunk-worker.js | 7 + ...ent-respond-with-custom-response-worker.js | 45 + ...vent-respond-with-partial-stream-worker.js | 28 + ...spond-with-readable-stream-chunk-worker.js | 40 + ...ent-respond-with-readable-stream-worker.js | 81 + ...sponse-body-with-invalid-chunk-iframe.html | 15 + ...response-body-with-invalid-chunk-worker.js | 12 + ...t-respond-with-stops-propagation-worker.js | 15 + .../resources/fetch-event-test-worker.js | 224 + .../resources/fetch-event-within-sw-worker.js | 48 + .../fetch-header-visibility-iframe.html | 66 + ...xed-content-iframe-inscope-to-inscope.html | 71 + ...ed-content-iframe-inscope-to-outscope.html | 80 + .../resources/fetch-mixed-content-iframe.html | 71 + .../fetch-request-css-base-url-iframe.html | 20 + .../fetch-request-css-base-url-style.css | 1 + .../fetch-request-css-base-url-worker.js | 45 + ...uest-css-cross-origin-mime-check-cross.css | 1 + ...est-css-cross-origin-mime-check-cross.html | 1 + ...st-css-cross-origin-mime-check-iframe.html | 17 + ...quest-css-cross-origin-mime-check-same.css | 1 + ...uest-css-cross-origin-mime-check-same.html | 1 + ...equest-css-cross-origin-read-contents.html | 15 + .../fetch-request-css-cross-origin-worker.js | 65 + .../fetch-request-fallback-iframe.html | 32 + .../fetch-request-fallback-worker.js | 13 + .../fetch-request-html-imports-iframe.html | 13 + .../fetch-request-html-imports-worker.js | 30 + ...h-request-no-freshness-headers-iframe.html | 1 + ...tch-request-no-freshness-headers-script.py | 6 + ...tch-request-no-freshness-headers-worker.js | 18 + .../fetch-request-redirect-iframe.html | 35 + .../fetch-request-resources-iframe.https.html | 126 + .../fetch-request-resources-worker.js | 26 + .../fetch-request-xhr-iframe.https.html | 208 + .../fetch-request-xhr-sync-error-worker.js | 19 + .../fetch-request-xhr-sync-iframe.html | 13 + ...fetch-request-xhr-sync-on-worker-worker.js | 41 + .../fetch-request-xhr-sync-worker.js | 7 + .../resources/fetch-request-xhr-worker.js | 22 + .../fetch-response-taint-iframe.html | 2 + .../fetch-response-xhr-iframe.https.html | 53 + .../resources/fetch-response-xhr-worker.js | 12 + .../resources/fetch-response.html | 29 + .../resources/fetch-response.js | 35 + .../fetch-rewrite-worker-referrer-policy.js | 4 + ...-rewrite-worker-referrer-policy.js.headers | 2 + .../resources/fetch-rewrite-worker.js | 166 + .../resources/fetch-rewrite-worker.js.headers | 2 + .../resources/fetch-variants-worker.js | 35 + .../fetch-waits-for-activate-worker.js | 31 + .../resources/fetch-with-body-worker.js | 4 + .../resources/fetch-with-body-worker.py | 4 + .../service-worker/resources/form-poster.html | 13 + .../resources/frame-for-getregistrations.html | 19 + .../resources/get-resultingClientId-worker.js | 107 + ...to-https-redirect-and-register-iframe.html | 25 + .../resources/iframe-with-fetch-variants.html | 14 + .../resources/iframe-with-image.html | 2 + .../immutable-prototype-serviceworker.js | 19 + .../import-echo-cookie-worker-module.py | 6 + .../resources/import-echo-cookie-worker.js | 1 + .../resources/import-mime-type-worker.py | 10 + .../resources/import-relative.xsl | 5 + ...pts-404-after-update-plus-update-worker.js | 8 + .../import-scripts-404-after-update.js | 6 + .../resources/import-scripts-404.js | 1 + .../import-scripts-cross-origin-worker.sub.js | 1 + .../import-scripts-data-url-worker.js | 1 + ...import-scripts-diff-resource-map-worker.js | 10 + .../resources/import-scripts-echo.py | 6 + .../resources/import-scripts-get.py | 6 + .../import-scripts-mime-types-worker.js | 49 + .../import-scripts-redirect-import.js | 1 + ...-scripts-redirect-on-second-time-worker.js | 7 + .../import-scripts-redirect-worker.js | 1 + .../import-scripts-resource-map-worker.js | 15 + .../import-scripts-updated-flag-worker.js | 31 + .../resources/import-scripts-version.py | 17 + .../resources/imported-classic-script.js | 1 + .../resources/imported-module-script.js | 1 + .../service-worker/resources/imported-sw.js | 13 + .../resources/indexeddb-worker.js | 57 + .../resources/install-event-type-worker.js | 9 + .../resources/install-worker.html | 22 + .../interface-requirements-worker.sub.js | 59 + .../invalid-blobtype-iframe.https.html | 28 + .../resources/invalid-blobtype-worker.js | 10 + .../invalid-chunked-encoding-with-flush.py | 9 + .../resources/invalid-chunked-encoding.py | 2 + .../invalid-header-iframe.https.html | 25 + .../resources/invalid-header-worker.js | 12 + .../resources/iso-latin1-header-iframe.html | 23 + .../resources/iso-latin1-header-worker.js | 12 + .../service-worker/resources/load_worker.js | 29 + .../service-worker/resources/loaded.html | 9 + .../local-url-inherit-controller-frame.html | 189 + .../local-url-inherit-controller-worker.js | 5 + .../resources/location-setter.html | 10 + .../resources/malformed-http-response.asis | 1 + .../resources/malformed-worker.py | 14 + .../resources/message-vs-microtask.html | 18 + .../resources/mime-sniffing-worker.js | 9 + .../resources/mime-type-worker.py | 4 + .../resources/mint-new-worker.py | 27 + .../service-worker/resources/missing.asis | 4 + .../service-worker/resources/module-worker.js | 1 + .../resources/multipart-image-iframe.html | 19 + .../resources/multipart-image-worker.js | 21 + .../resources/multipart-image.py | 23 + .../resources/navigate-window-worker.js | 21 + .../resources/navigation-headers-server.py | 19 + .../navigation-redirect-body-worker.js | 11 + .../resources/navigation-redirect-body.py | 11 + .../navigation-redirect-other-origin.html | 89 + .../navigation-redirect-out-scope.py | 22 + .../resources/navigation-redirect-scope1.py | 22 + .../resources/navigation-redirect-scope2.py | 22 + .../navigation-redirect-to-http-iframe.html | 42 + .../navigation-redirect-to-http-worker.js | 22 + .../navigation-timing-worker-extended.js | 22 + .../resources/navigation-timing-worker.js | 15 + ...d-blob-url-worker-created-from-worker.html | 16 + .../resources/nested-blob-url-workers.html | 38 + .../resources/nested-iframe-parent.html | 5 + .../resources/nested-parent.html | 18 + ...d-worker-created-from-blob-url-worker.html | 33 + .../resources/nested_load_worker.js | 23 + .../resources/no-dynamic-import.js | 18 + .../resources/notification_icon.py | 11 + ...bject-image-is-not-intercepted-iframe.html | 21 + .../object-is-not-intercepted-iframe.html | 18 + ...-navigation-is-not-intercepted-iframe.html | 24 + ...te-throw-error-from-nested-event-worker.js | 13 + ...activate-throw-error-then-cancel-worker.js | 3 + ...throw-error-then-prevent-default-worker.js | 7 + ...e-throw-error-with-empty-onerror-worker.js | 2 + .../onactivate-throw-error-worker.js | 7 + .../resources/onactivate-waituntil-forever.js | 8 + .../resources/onfetch-waituntil-forever.js | 10 + ...ll-throw-error-from-nested-event-worker.js | 12 + ...ninstall-throw-error-then-cancel-worker.js | 3 + ...throw-error-then-prevent-default-worker.js | 7 + ...l-throw-error-with-empty-onerror-worker.js | 2 + .../resources/oninstall-throw-error-worker.js | 7 + .../resources/oninstall-waituntil-forever.js | 8 + .../oninstall-waituntil-throw-error-worker.js | 5 + .../resources/onparse-infiniteloop-worker.js | 8 + .../opaque-response-being-preloaded-xhr.html | 33 + .../opaque-response-preloaded-worker.js | 12 + .../opaque-response-preloaded-xhr.html | 35 + .../resources/opaque-script-frame.html | 21 + .../resources/opaque-script-large.js | 41 + .../resources/opaque-script-small.js | 3 + .../resources/opaque-script-sw.js | 37 + .../resources/or-test/direct1.text | 1 + .../resources/or-test/direct1.text.headers | 1 + .../resources/or-test/direct2.text | 1 + .../resources/or-test/direct2.text.headers | 1 + .../service-worker/resources/other.html | 3 + .../override_assert_object_equals.js | 58 + ...ioned-cookies-3p-credentialless-frame.html | 114 + .../partitioned-cookies-3p-frame.html | 77 + .../resources/partitioned-cookies-3p-sw.js | 3 + .../partitioned-cookies-3p-window.html | 35 + .../resources/partitioned-cookies-sw.js | 53 + .../partitioned-cookies-test-helpers.js | 31 + ...rtitioned-service-worker-iframe-claim.html | 59 + ...ed-service-worker-nested-iframe-child.html | 44 + ...d-service-worker-nested-iframe-parent.html | 30 + ...r-third-party-iframe-getRegistrations.html | 40 + ...ce-worker-third-party-iframe-matchAll.html | 27 + ...ned-service-worker-third-party-iframe.html | 36 + ...ned-service-worker-third-party-window.html | 41 + .../resources/partitioned-storage-sw.js | 81 + .../resources/partitioned-utils.js | 110 + .../resources/pass-through-worker.js | 3 + .../service-worker/resources/pass.txt | 1 + .../resources/performance-timeline-worker.js | 62 + .../resources/postmessage-blob-url.js | 5 + ...message-dictionary-transferables-worker.js | 24 + .../resources/postmessage-echo-worker.js | 3 + .../resources/postmessage-fetched-text.js | 5 + .../postmessage-msgport-to-client-worker.js | 19 + .../resources/postmessage-on-load-worker.js | 9 + .../resources/postmessage-to-client-worker.js | 10 + .../postmessage-transferables-worker.js | 24 + .../resources/postmessage-worker.js | 19 + ...nge-request-to-different-origins-worker.js | 40 + ...equest-with-different-cors-modes-worker.js | 60 + .../range-request-with-synth-head-worker.js | 36 + .../resources/redirect-worker.js | 145 + .../service-worker/resources/redirect.py | 27 + .../resources/referer-iframe.html | 39 + .../resources/referrer-policy-iframe.html | 32 + .../register-closed-window-iframe.html | 19 + .../resources/register-iframe.html | 4 + .../resources/register-rewrite-worker.html | 32 + .../registration-tests-mime-types.js | 96 + .../resources/registration-tests-scope.js | 120 + .../registration-tests-script-url.js | 82 + .../resources/registration-tests-script.js | 121 + .../registration-tests-security-error.js | 78 + .../resources/registration-worker.js | 1 + .../resources/reject-install-worker.js | 3 + .../resources/reply-to-message.html | 7 + .../resources/request-end-to-end-worker.js | 34 + .../resources/request-headers.py | 8 + .../resources/resource-timing-iframe.sub.html | 10 + .../resources/resource-timing-worker.js | 12 + .../resources/respond-then-throw-worker.js | 40 + ...nd-with-body-accessed-response-iframe.html | 20 + ...pond-with-body-accessed-response-worker.js | 93 + .../respond-with-body-accessed-response.jsonp | 1 + .../service-worker/resources/router-rules.js | 109 + .../resources/sample-worker-interceptor.js | 62 + .../service-worker/resources/sample.html | 2 + .../service-worker/resources/sample.js | 1 + .../service-worker/resources/sample.txt | 1 + .../sandboxed-iframe-fetch-event-iframe.html | 63 + .../sandboxed-iframe-fetch-event-iframe.py | 18 + .../sandboxed-iframe-fetch-event-worker.js | 20 + ...iframe-navigator-serviceworker-iframe.html | 25 + ...ule-worker-importing-redirect-to-scope2.js | 1 + .../scope1/module-worker-importing-scope2.js | 1 + .../resources/scope1/redirect.py | 8 + .../resources/scope2/import-scripts-echo.py | 6 + .../scope2/imported-module-script.js | 4 + .../resources/scope2/simple.txt | 1 + .../worker_interception_redirect_webworker.py | 8 + .../secure-context-service-worker.js | 21 + .../resources/secure-context/sender.html | 1 + .../resources/secure-context/window.html | 15 + .../resources/service-worker-csp-worker.py | 183 + .../resources/service-worker-header.py | 20 + ...rker-interception-dynamic-import-worker.js | 1 + ...vice-worker-interception-network-worker.js | 1 + ...vice-worker-interception-service-worker.js | 9 + ...orker-interception-static-import-worker.js | 1 + ...adowrealm-promise-rejection-test-worker.js | 11 + .../service-worker/resources/silence.oga | Bin 0 -> 12983 bytes .../resources/simple-intercept-worker.js | 5 + .../simple-intercept-worker.js.headers | 1 + ...mple-test-for-condition-main-resource.html | 3 + .../service-worker/resources/simple.csv | 1 + .../service-worker/resources/simple.html | 3 + .../service-worker/resources/simple.txt | 1 + .../skip-waiting-installed-worker.js | 33 + .../resources/skip-waiting-worker.js | 21 + .../service-worker/resources/square.png | Bin 0 -> 18299 bytes .../resources/square.png.sub.headers | 2 + .../resources/stalling-service-worker.js | 54 + .../resources/static-router-helpers.sub.js | 82 + .../static-router-no-fetch-handler-sw.js | 35 + ...outer-race-network-and-fetch-handler-sw.js | 57 + .../resources/static-router-sw.js | 45 + .../resources/static-router-sw.sub.js | 29 + .../resources/subdir/blank.html | 2 + .../resources/subdir/import-scripts-echo.py | 6 + .../resources/subdir/simple.txt | 1 + .../worker_interception_redirect_webworker.py | 8 + .../service-worker/resources/success.py | 8 + .../svg-target-reftest-001-frame.html | 3 + .../resources/svg-target-reftest-001.html | 5 + .../resources/svg-target-reftest-frame.html | 2 + .../resources/test-helpers.sub.js | 300 + .../resources/test-request-headers-worker.js | 10 + .../resources/test-request-headers-worker.py | 21 + .../resources/test-request-mode-worker.js | 10 + .../resources/test-request-mode-worker.py | 22 + .../resources/testharness-helpers.js | 136 + .../service-worker/resources/trickle.py | 14 + .../resources/type-check-worker.js | 10 + .../resources/unregister-controller-page.html | 16 + .../unregister-immediately-helpers.js | 19 + .../resources/unregister-rewrite-worker.html | 18 + .../resources/update-claim-worker.py | 24 + .../update-during-installation-worker.js | 61 + .../update-during-installation-worker.py | 11 + .../resources/update-fetch-worker.py | 18 + .../update-max-aged-worker-imported-script.py | 14 + .../resources/update-max-aged-worker.py | 30 + ...-missing-import-scripts-imported-worker.py | 9 + ...date-missing-import-scripts-main-worker.py | 15 + .../resources/update-nocookie-worker.py | 14 + .../resources/update-recovery-worker.py | 25 + .../update-registration-with-type.py | 33 + ...update-smaller-body-after-update-worker.js | 1 + ...pdate-smaller-body-before-update-worker.js | 2 + .../resources/update-worker-from-file.py | 33 + .../service-worker/resources/update-worker.py | 62 + .../update/update-after-oneday.https.html | 8 + .../service-worker/resources/update_shell.py | 32 + .../service-worker/resources/vtt-frame.html | 6 + .../wait-forever-in-install-worker.js | 12 + .../resources/websocket-worker.js | 35 + .../service-worker/resources/websocket.js | 7 + .../resources/window-opener.html | 17 + .../resources/windowclient-navigate-worker.js | 75 + .../resources/worker-client-id-worker.js | 25 + .../resources/worker-fetching-cross-origin.js | 12 + ...ker-interception-redirect-serviceworker.js | 53 + .../worker-interception-redirect-webworker.js | 56 + .../resources/worker-load-interceptor.js | 16 + .../resources/worker-testharness.js | 49 + .../worker_interception_redirect_webworker.py | 20 + .../resources/xhr-content-length-worker.js | 22 + .../service-worker/resources/xhr-iframe.html | 23 + .../resources/xhr-response-url-worker.js | 32 + .../resources/xsl-base-url-iframe.xml | 5 + .../resources/xsl-base-url-worker.js | 12 + .../service-worker/resources/xslt-pass.xsl | 11 + ...ond-with-body-accessed-response.https.html | 54 + .../same-site-cookies.https.html | 496 ++ .../sandboxed-iframe-fetch-event.https.html | 536 ++ ...-iframe-navigator-serviceworker.https.html | 120 + .../service-worker/secure-context.https.html | 57 + .../service-worker-csp-connect.https.html | 10 + .../service-worker-csp-default.https.html | 10 + .../service-worker-csp-script.https.html | 10 + .../service-worker-header.https.html | 23 + ...worker-message-event-historical.https.html | 45 + .../serviceworkerobject-scripturl.https.html | 26 + .../shadowrealm-promise-rejection.https.html | 21 + .../skip-waiting-installed.https.html | 70 + ...skip-waiting-using-registration.https.html | 66 + .../skip-waiting-without-client.https.html | 12 + ...ting-without-using-registration.https.html | 44 + .../service-worker/skip-waiting.https.html | 58 + .../service-worker/state.https.html | 74 + .../static-router-fetch-event.https.html | 39 + .../static-router-invalid-rules.https.html | 46 + .../static-router-main-resource.https.html | 93 + ...r-multiple-router-registrations.https.html | 57 + ...tatic-router-mutiple-conditions.https.html | 112 + .../static-router-no-fetch-handler.https.html | 48 + ...-race-network-and-fetch-handler.https.html | 91 + ...atic-router-request-destination.https.html | 77 + .../static-router-request-method.https.html | 59 + .../static-router-subresource.https.html | 202 + .../svg-target-reftest.https.html | 28 + .../service-worker/synced-state.https.html | 93 + .../tentative/static-router/README.md | 4 + .../static-router-resource-timing.https.html | 332 ++ .../uncontrolled-page.https.html | 39 + .../unregister-controller.https.html | 108 + ...er-immediately-before-installed.https.html | 57 + ...iately-during-extendable-events.https.html | 50 + .../unregister-immediately.https.html | 134 + ...gister-then-register-new-script.https.html | 136 + .../unregister-then-register.https.html | 107 + .../service-worker/unregister.https.html | 40 + ...te-after-navigation-fetch-event.https.html | 91 + ...pdate-after-navigation-redirect.https.html | 74 + .../update-after-oneday.https.html | 51 + .../update-bytecheck-cors-import.https.html | 92 + .../update-bytecheck.https.html | 92 + .../update-import-scripts.https.html | 135 + .../update-missing-import-scripts.https.html | 33 + .../update-module-request-mode.https.html | 45 + ...update-no-cache-request-headers.https.html | 48 + .../update-not-allowed.https.html | 140 + .../update-on-navigation.https.html | 20 + .../service-worker/update-recovery.https.html | 73 + .../update-registration-with-type.https.html | 208 + .../service-worker/update-result.https.html | 23 + .../service-worker/update.https.html | 164 + .../service-worker/waiting.https.html | 47 + .../websocket-in-service-worker.https.html | 27 + .../service-worker/websocket.https.html | 45 + .../webvtt-cross-origin.https.html | 175 + .../windowclient-navigate.https.html | 190 + .../worker-client-id.https.html | 58 + ...boxed-iframe-by-csp-fetch-event.https.html | 132 + .../worker-interception-redirect.https.html | 212 + .../worker-interception.https.html | 244 + .../xhr-content-length.https.window.js | 55 + .../xhr-response-url.https.html | 103 + .../service-worker/xsl-base-url.https.html | 32 + test/fixtures/wpt/storage/META.yml | 4 + test/fixtures/wpt/storage/README.md | 7 + test/fixtures/wpt/storage/buckets/META.yml | 5 + .../wpt/storage/buckets/WEB_FEATURES.yml | 3 + ...ket-quota-indexeddb.tentative.https.any.js | 41 + ...cket-storage-policy.tentative.https.any.js | 21 + .../bucket_names.tentative.https.any.js | 93 + .../buckets_basic.tentative.https.any.js | 50 + ...kets_storage_policy.tentative.https.any.js | 32 + .../buckets/detached-iframe.https.html | 68 + .../buckets/idlharness-worker.https.any.js | 22 + .../buckets/opaque-origin.https.window.js | 58 + .../buckets/resources/cached-resource.txt | 1 + .../resources/opaque-origin-sandbox.html | 52 + .../wpt/storage/buckets/resources/util.js | 57 + ...orage_bucket_object.tentative.https.any.js | 143 + .../storage/estimate-indexeddb.https.any.js | 48 + .../storage/estimate-parallel.https.any.js | 13 + ...sage-details-caches.https.tentative.any.js | 20 + ...e-details-indexeddb.https.tentative.any.js | 59 + ...-service-workers.https.tentative.window.js | 38 + ...imate-usage-details.https.tentative.any.js | 12 + test/fixtures/wpt/storage/helpers.js | 46 + .../wpt/storage/idlharness.https.any.js | 18 + .../wpt/storage/opaque-origin.https.window.js | 87 + ...ge-details-caches.tentative.https.sub.html | 74 + ...details-indexeddb.tentative.https.sub.html | 84 + ...s-service-workers.tentative.https.sub.html | 88 + .../wpt/storage/permission-query.https.any.js | 10 + .../persist-permission-manual.https.html | 27 + .../wpt/storage/persisted.https.any.js | 14 + .../fixtures/wpt/storage/resources/helpers.js | 9 + ...ate-usage-details-caches-helper-frame.html | 30 + ...-usage-details-indexeddb-helper-frame.html | 28 + ...-details-service-workers-helper-frame.html | 30 + test/fixtures/wpt/storage/resources/worker.js | 3 + .../storagemanager-estimate.https.any.js | 16 + ...er-persist-persisted-match.https.window.js | 16 + .../storagemanager-persist.https.window.js | 17 + .../storagemanager-persist.https.worker.js | 8 + .../storagemanager-persisted.https.any.js | 10 + .../wpt/websockets/Close-1000-reason.any.js | 21 + .../websockets/Close-1000-verify-code.any.js | 21 + .../fixtures/wpt/websockets/Close-1000.any.js | 21 + .../websockets/Close-1005-verify-code.any.js | 21 + .../fixtures/wpt/websockets/Close-1005.any.js | 18 + .../wpt/websockets/Close-2999-reason.any.js | 17 + .../wpt/websockets/Close-3000-reason.any.js | 21 + .../websockets/Close-3000-verify-code.any.js | 20 + .../wpt/websockets/Close-4999-reason.any.js | 21 + .../websockets/Close-Reason-124Bytes.any.js | 20 + .../wpt/websockets/Close-delayed.any.js | 27 + .../wpt/websockets/Close-onlyReason.any.js | 17 + .../websockets/Close-readyState-Closed.any.js | 21 + .../Close-readyState-Closing.any.js | 20 + .../Close-reason-unpaired-surrogates.any.js | 22 + .../Close-server-initiated-close.any.js | 21 + .../wpt/websockets/Close-undefined.any.js | 19 + .../Create-asciiSep-protocol-string.any.js | 12 + .../wpt/websockets/Create-blocked-port.any.js | 99 + .../websockets/Create-extensions-empty.any.js | 20 + .../wpt/websockets/Create-http-urls.any.js | 19 + .../wpt/websockets/Create-invalid-urls.any.js | 14 + .../websockets/Create-non-absolute-url.any.js | 14 + .../Create-nonAscii-protocol-string.any.js | 12 + .../Create-on-worker-shutdown.any.js | 26 + .../Create-protocol-with-space.any.js | 11 + ...protocols-repeated-case-insensitive.any.js | 11 + .../Create-protocols-repeated.any.js | 11 + .../websockets/Create-url-with-space.any.js | 12 + ...Create-url-with-windows-1252-encoding.html | 20 + .../Create-valid-url-array-protocols.any.js | 21 + .../Create-valid-url-binaryType-blob.any.js | 21 + .../Create-valid-url-protocol-empty.any.js | 10 + ...ate-valid-url-protocol-setCorrectly.any.js | 21 + .../Create-valid-url-protocol-string.any.js | 21 + .../Create-valid-url-protocol.any.js | 21 + .../wpt/websockets/Create-valid-url.any.js | 21 + test/fixtures/wpt/websockets/META.yml | 5 + test/fixtures/wpt/websockets/README.md | 1 + .../wpt/websockets/Send-0byte-data.any.js | 30 + .../wpt/websockets/Send-65K-data.any.js | 33 + .../wpt/websockets/Send-before-open.any.js | 11 + .../Send-binary-65K-arraybuffer.any.js | 33 + .../websockets/Send-binary-arraybuffer.any.js | 33 + ...Send-binary-arraybufferview-float16.any.js | 40 + ...Send-binary-arraybufferview-float32.any.js | 40 + ...Send-binary-arraybufferview-float64.any.js | 40 + ...binary-arraybufferview-int16-offset.any.js | 40 + .../Send-binary-arraybufferview-int32.any.js | 40 + .../Send-binary-arraybufferview-int8.any.js | 40 + ...rraybufferview-uint16-offset-length.any.js | 40 + ...inary-arraybufferview-uint32-offset.any.js | 40 + ...arraybufferview-uint8-offset-length.any.js | 40 + ...binary-arraybufferview-uint8-offset.any.js | 40 + .../wpt/websockets/Send-binary-blob.any.js | 36 + test/fixtures/wpt/websockets/Send-data.any.js | 30 + .../wpt/websockets/Send-data.worker.js | 26 + test/fixtures/wpt/websockets/Send-null.any.js | 32 + .../websockets/Send-paired-surrogates.any.js | 30 + .../wpt/websockets/Send-unicode-data.any.js | 30 + .../Send-unpaired-surrogates.any.js | 30 + ...socket-connection-ccns.tentative.window.js | 31 + ...with-closed-websocket-connection.window.js | 20 + ...socket-connection-ccns.tentative.window.js | 32 + ...e-with-open-websocket-connection.window.js | 21 + .../fixtures/wpt/websockets/basic-auth.any.js | 17 + test/fixtures/wpt/websockets/binary/001.html | 27 + test/fixtures/wpt/websockets/binary/002.html | 28 + test/fixtures/wpt/websockets/binary/004.html | 27 + test/fixtures/wpt/websockets/binary/005.html | 26 + .../websockets/binaryType-wrong-value.any.js | 23 + ...ufferedAmount-unchanged-by-sync-xhr.any.js | 25 + .../wpt/websockets/close-invalid.any.js | 21 + .../wpt/websockets/closing-handshake/002.html | 23 + .../wpt/websockets/closing-handshake/003.html | 24 + .../wpt/websockets/closing-handshake/004.html | 25 + test/fixtures/wpt/websockets/constants.sub.js | 94 + .../wpt/websockets/constructor.any.js | 10 + .../wpt/websockets/constructor/001.html | 14 + .../wpt/websockets/constructor/004.html | 36 + .../wpt/websockets/constructor/005.html | 14 + .../wpt/websockets/constructor/006.html | 29 + .../wpt/websockets/constructor/007.html | 17 + .../wpt/websockets/constructor/008.html | 15 + .../wpt/websockets/constructor/009.html | 24 + .../wpt/websockets/constructor/010.html | 22 + .../wpt/websockets/constructor/011.html | 28 + .../wpt/websockets/constructor/012.html | 20 + .../wpt/websockets/constructor/013.html | 42 + .../wpt/websockets/constructor/014.html | 39 + .../wpt/websockets/constructor/016.html | 20 + .../wpt/websockets/constructor/017.html | 19 + .../wpt/websockets/constructor/018.html | 20 + .../wpt/websockets/constructor/019.html | 21 + .../wpt/websockets/constructor/020.html | 21 + .../wpt/websockets/constructor/021.html | 12 + .../wpt/websockets/constructor/022.html | 23 + test/fixtures/wpt/websockets/cookies/001.html | 28 + test/fixtures/wpt/websockets/cookies/002.html | 26 + test/fixtures/wpt/websockets/cookies/003.html | 34 + test/fixtures/wpt/websockets/cookies/004.html | 31 + test/fixtures/wpt/websockets/cookies/005.html | 35 + test/fixtures/wpt/websockets/cookies/006.html | 35 + test/fixtures/wpt/websockets/cookies/007.html | 36 + .../websockets/cookies/support/set-cookie.py | 7 + .../support/websocket-cookies-helper.sub.js | 57 + .../third-party-cookie-accepted.https.html | 25 + .../wpt/websockets/eventhandlers.any.js | 15 + .../websockets/extended-payload-length.html | 72 + .../wpt/websockets/handlers/basic_auth_wsh.py | 26 + .../handlers/delayed-passive-close_wsh.py | 27 + .../websockets/handlers/echo-cookie_wsh.py | 12 + .../websockets/handlers/echo-query_v13_wsh.py | 11 + .../wpt/websockets/handlers/echo-query_wsh.py | 9 + .../handlers/echo_close_data_wsh.py | 20 + .../wpt/websockets/handlers/echo_exit_wsh.py | 19 + .../wpt/websockets/handlers/echo_raw_wsh.py | 16 + .../wpt/websockets/handlers/echo_wsh.py | 36 + .../websockets/handlers/empty-message_wsh.py | 13 + .../handlers/handshake_no_extensions_wsh.py | 9 + .../handlers/handshake_no_protocol_wsh.py | 8 + .../handlers/handshake_protocol_wsh.py | 7 + .../handlers/handshake_sleep_2_wsh.py | 9 + .../wpt/websockets/handlers/invalid_wsh.py | 8 + .../websockets/handlers/msg_channel_wsh.py | 234 + .../wpt/websockets/handlers/origin_wsh.py | 11 + .../handlers/passive-close-abort_wsh.py | 24 + .../websockets/handlers/protocol_array_wsh.py | 14 + .../wpt/websockets/handlers/protocol_wsh.py | 12 + .../handlers/receive-backpressure_wsh.py | 14 + .../receive-many-with-backpressure_wsh.py | 23 + .../wpt/websockets/handlers/referrer_wsh.py | 12 + .../websockets/handlers/remote-close_wsh.py | 44 + .../handlers/send-backpressure_wsh.py | 39 + .../handlers/set-cookie-secure_wsh.py | 11 + .../handlers/set-cookie_http_wsh.py | 11 + .../wpt/websockets/handlers/set-cookie_wsh.py | 11 + .../handlers/set-cookies-samesite_wsh.py | 25 + .../handlers/simple_handshake_wsh.py | 35 + .../websockets/handlers/sleep_10_v13_wsh.py | 15 + .../handlers/stash_responder_blocking_wsh.py | 45 + .../handlers/stash_responder_wsh.py | 45 + .../handlers/wrong_accept_key_wsh.py | 19 + .../fixtures/wpt/websockets/idlharness.any.js | 17 + .../interfaces/CloseEvent/clean-close.html | 24 + .../interfaces/CloseEvent/constructor.html | 35 + .../interfaces/CloseEvent/historical.html | 12 + .../bufferedAmount-arraybuffer.html | 27 + .../bufferedAmount/bufferedAmount-blob.html | 28 + .../bufferedAmount-defineProperty-getter.html | 18 + .../bufferedAmount-defineProperty-setter.html | 20 + .../bufferedAmount-deleting.html | 23 + .../bufferedAmount-getting.html | 54 + .../bufferedAmount-initial.html | 15 + .../bufferedAmount/bufferedAmount-large.html | 29 + .../bufferedAmount-readonly.html | 16 + .../bufferedAmount-unicode.html | 25 + .../WebSocket/close/close-basic.html | 26 + .../close/close-connecting-async.any.js | 31 + .../WebSocket/close/close-connecting.html | 25 + .../WebSocket/close/close-multiple.html | 29 + .../WebSocket/close/close-nested.html | 28 + .../WebSocket/close/close-replace.html | 15 + .../WebSocket/close/close-return.html | 14 + .../interfaces/WebSocket/constants/001.html | 17 + .../interfaces/WebSocket/constants/002.html | 24 + .../interfaces/WebSocket/constants/003.html | 22 + .../interfaces/WebSocket/constants/004.html | 21 + .../interfaces/WebSocket/constants/005.html | 20 + .../interfaces/WebSocket/constants/006.html | 20 + .../interfaces/WebSocket/events/001.html | 18 + .../interfaces/WebSocket/events/002.html | 20 + .../interfaces/WebSocket/events/003.html | 21 + .../interfaces/WebSocket/events/004.html | 16 + .../interfaces/WebSocket/events/006.html | 17 + .../interfaces/WebSocket/events/007.html | 22 + .../interfaces/WebSocket/events/008.html | 24 + .../interfaces/WebSocket/events/009.html | 21 + .../interfaces/WebSocket/events/010.html | 21 + .../interfaces/WebSocket/events/011.html | 18 + .../interfaces/WebSocket/events/012.html | 18 + .../interfaces/WebSocket/events/013.html | 20 + .../interfaces/WebSocket/events/014.html | 21 + .../interfaces/WebSocket/events/015.html | 36 + .../interfaces/WebSocket/events/016.html | 39 + .../interfaces/WebSocket/events/017.html | 56 + .../interfaces/WebSocket/events/018.html | 52 + .../interfaces/WebSocket/events/019.html | 31 + .../interfaces/WebSocket/events/020.html | 17 + .../interfaces/WebSocket/extensions/001.html | 14 + .../WebSocket/protocol/protocol-initial.html | 14 + .../interfaces/WebSocket/readyState/001.html | 13 + .../interfaces/WebSocket/readyState/002.html | 15 + .../interfaces/WebSocket/readyState/003.html | 18 + .../interfaces/WebSocket/readyState/004.html | 17 + .../interfaces/WebSocket/readyState/005.html | 19 + .../interfaces/WebSocket/readyState/006.html | 19 + .../interfaces/WebSocket/readyState/007.html | 19 + .../interfaces/WebSocket/readyState/008.html | 21 + .../interfaces/WebSocket/send/001.html | 15 + .../interfaces/WebSocket/send/002.html | 15 + .../interfaces/WebSocket/send/003.html | 15 + .../interfaces/WebSocket/send/004.html | 25 + .../interfaces/WebSocket/send/005.html | 19 + .../interfaces/WebSocket/send/006.html | 28 + .../interfaces/WebSocket/send/007.html | 27 + .../interfaces/WebSocket/send/008.html | 25 + .../interfaces/WebSocket/send/009.html | 27 + .../interfaces/WebSocket/send/010.html | 42 + .../interfaces/WebSocket/send/011.html | 28 + .../interfaces/WebSocket/send/012.html | 28 + .../interfaces/WebSocket/url/001.html | 13 + .../interfaces/WebSocket/url/002.html | 15 + .../interfaces/WebSocket/url/003.html | 17 + .../interfaces/WebSocket/url/004.html | 17 + .../interfaces/WebSocket/url/005.html | 17 + .../interfaces/WebSocket/url/006.html | 19 + .../interfaces/WebSocket/url/resolve.html | 14 + .../keeping-connection-open/001.html | 29 + .../wpt/websockets/mixed-content.https.any.js | 7 + .../multi-globals/message-received.html | 33 + .../multi-globals/support/incumbent.sub.html | 24 + .../multi-globals/support/relevant.html | 2 + .../url-parsing/current/current.html | 2 + .../url-parsing/incumbent/incumbent.html | 13 + .../url-parsing/url-parsing.html | 22 + .../wpt/websockets/opening-handshake/001.html | 20 + .../wpt/websockets/opening-handshake/002.html | 24 + .../003-sets-origin.worker.js | 17 + .../wpt/websockets/opening-handshake/003.html | 27 + .../wpt/websockets/opening-handshake/005.html | 25 + .../wpt/websockets/opening-handshake/006.html | 58 + test/fixtures/wpt/websockets/referrer.any.js | 13 + ...remove-own-iframe-during-onerror.window.js | 23 + .../resources/websockets-test-helpers.sub.js | 25 + .../fixtures/wpt/websockets/security/001.html | 16 + .../fixtures/wpt/websockets/security/002.html | 20 + .../fixtures/wpt/websockets/security/check.py | 2 + ...many-64K-messages-with-backpressure.any.js | 49 + .../wpt/websockets/stream/tentative/README.md | 9 + .../websockets/stream/tentative/abort.any.js | 50 + .../tentative/backpressure-receive.any.js | 40 + .../stream/tentative/backpressure-send.any.js | 25 + .../websockets/stream/tentative/close.any.js | 193 + .../stream/tentative/constructor.any.js | 71 + .../stream/tentative/remote-close.any.js | 74 + .../tentative/resources/url-constants.js | 8 + .../stream/tentative/websocket-error.any.js | 50 + .../websockets/unload-a-document/001-1.html | 25 + .../websockets/unload-a-document/001-2.html | 4 + .../wpt/websockets/unload-a-document/001.html | 26 + .../websockets/unload-a-document/002-1.html | 32 + .../websockets/unload-a-document/002-2.html | 4 + .../wpt/websockets/unload-a-document/002.html | 27 + .../wpt/websockets/unload-a-document/003.html | 14 + .../wpt/websockets/unload-a-document/004.html | 16 + .../websockets/unload-a-document/005-1.html | 22 + .../wpt/websockets/unload-a-document/005.html | 21 + test/fixtures/wpt/xhr/META.yml | 7 + test/fixtures/wpt/xhr/README.md | 7 + .../xhr/XMLHttpRequest-withCredentials.any.js | 40 + .../wpt/xhr/abort-after-receive.any.js | 30 + test/fixtures/wpt/xhr/abort-after-send.any.js | 29 + .../wpt/xhr/abort-after-stop.window.js | 22 + .../wpt/xhr/abort-after-timeout.any.js | 43 + .../wpt/xhr/abort-during-done.window.js | 78 + .../abort-during-headers-received.window.js | 41 + .../wpt/xhr/abort-during-loading.window.js | 41 + .../fixtures/wpt/xhr/abort-during-open.any.js | 18 + .../xhr/abort-during-readystatechange.any.js | 19 + .../wpt/xhr/abort-during-unsent.any.js | 19 + .../wpt/xhr/abort-during-upload.any.js | 17 + .../fixtures/wpt/xhr/abort-event-abort.any.js | 32 + .../wpt/xhr/abort-event-listeners.any.js | 13 + .../wpt/xhr/abort-event-loadend.any.js | 30 + test/fixtures/wpt/xhr/abort-event-order.htm | 52 + .../wpt/xhr/abort-upload-event-abort.any.js | 31 + .../wpt/xhr/abort-upload-event-loadend.any.js | 31 + test/fixtures/wpt/xhr/abort-with-error.any.js | 16 + ...rol-and-redirects-async-same-origin.any.js | 61 + .../access-control-and-redirects-async.any.js | 79 + .../xhr/access-control-and-redirects.any.js | 50 + ...-access-control-origin-header-data-url.htm | 43 + ...-allow-access-control-origin-header.any.js | 13 + .../access-control-basic-allow-async.any.js | 19 + ...ow-non-cors-safelisted-method-async.any.js | 17 + ...ic-allow-non-cors-safelisted-method.any.js | 14 + ...flight-cache-invalidation-by-header.any.js | 38 + ...flight-cache-invalidation-by-method.any.js | 37 + ...basic-allow-preflight-cache-timeout.any.js | 37 + ...control-basic-allow-preflight-cache.any.js | 35 + .../access-control-basic-allow-star.any.js | 12 + .../wpt/xhr/access-control-basic-allow.any.js | 12 + ...-basic-cors-safelisted-request-headers.htm | 31 + ...basic-cors-safelisted-response-headers.htm | 32 + .../wpt/xhr/access-control-basic-denied.htm | 30 + ...cess-control-basic-get-fail-non-simple.htm | 26 + ...basic-non-cors-safelisted-content-type.htm | 30 + ...rol-basic-post-success-no-content-type.htm | 26 + ...-with-non-cors-safelisted-content-type.htm | 37 + .../access-control-basic-preflight-denied.htm | 31 + ...ss-control-expose-headers-on-redirect.html | 33 + ...-control-preflight-async-header-denied.htm | 39 + ...-control-preflight-async-method-denied.htm | 38 + ...-control-preflight-async-not-supported.htm | 37 + ...ess-control-preflight-credential-async.htm | 29 + ...cess-control-preflight-credential-sync.htm | 24 + ...access-control-preflight-headers-async.htm | 35 + .../access-control-preflight-headers-sync.htm | 29 + ...-request-allow-headers-returns-star.any.js | 26 + ...rol-preflight-request-header-lowercase.htm | 29 + ...light-request-header-returns-origin.any.js | 26 + ...ontrol-preflight-request-header-sorted.htm | 28 + ...ntrol-preflight-request-headers-origin.htm | 29 + ...l-preflight-request-invalid-status-301.htm | 28 + ...l-preflight-request-invalid-status-400.htm | 28 + ...l-preflight-request-invalid-status-501.htm | 28 + ...flight-request-must-not-contain-cookie.htm | 57 + ...s-control-preflight-sync-header-denied.htm | 34 + ...s-control-preflight-sync-method-denied.htm | 33 + ...s-control-preflight-sync-not-supported.htm | 33 + ...ccess-control-recursive-failed-request.htm | 38 + ...access-control-response-with-body-sync.htm | 25 + .../xhr/access-control-response-with-body.htm | 29 + ...-control-response-with-exposed-headers.htm | 38 + ...rol-sandboxed-iframe-allow-origin-null.htm | 32 + .../access-control-sandboxed-iframe-allow.htm | 32 + ...ndboxed-iframe-denied-without-wildcard.htm | 43 + ...access-control-sandboxed-iframe-denied.htm | 41 + .../xhr/allow-lists-starting-with-comma.htm | 33 + .../wpt/xhr/anonymous-mode-unsupported.htm | 40 + test/fixtures/wpt/xhr/blob-range.any.js | 246 + .../close-worker-with-xhr-in-progress.html | 26 + .../wpt/xhr/content-type-unmodified.any.js | 16 + test/fixtures/wpt/xhr/cookies.http.html | 41 + .../wpt/xhr/cors-expose-star.sub.any.js | 52 + test/fixtures/wpt/xhr/cors-upload.any.js | 59 + test/fixtures/wpt/xhr/data-uri.htm | 41 + test/fixtures/wpt/xhr/event-abort.any.js | 15 + .../wpt/xhr/event-error-order.sub.html | 35 + test/fixtures/wpt/xhr/event-error.sub.any.js | 28 + test/fixtures/wpt/xhr/event-load.any.js | 21 + test/fixtures/wpt/xhr/event-loadend.any.js | 19 + .../wpt/xhr/event-loadstart-upload.any.js | 19 + test/fixtures/wpt/xhr/event-loadstart.any.js | 17 + test/fixtures/wpt/xhr/event-progress.any.js | 18 + .../wpt/xhr/event-readystate-sync-open.any.js | 23 + .../xhr/event-readystatechange-loaded.any.js | 23 + .../wpt/xhr/event-timeout-order.any.js | 21 + test/fixtures/wpt/xhr/event-timeout.any.js | 18 + .../event-upload-progress-crossorigin.any.js | 26 + .../wpt/xhr/event-upload-progress.any.js | 30 + .../firing-events-http-content-length.html | 32 + .../firing-events-http-no-content-length.html | 35 + test/fixtures/wpt/xhr/folder.txt | 1 + test/fixtures/wpt/xhr/formdata.html | 90 + .../wpt/xhr/formdata/append-formelement.html | 52 + test/fixtures/wpt/xhr/formdata/append.any.js | 37 + .../xhr/formdata/constructor-formelement.html | 150 + .../constructor-submitter-coordinate.html | 39 + .../xhr/formdata/constructor-submitter.html | 100 + .../wpt/xhr/formdata/constructor.any.js | 6 + .../wpt/xhr/formdata/delete-formelement.html | 41 + test/fixtures/wpt/xhr/formdata/delete.any.js | 26 + test/fixtures/wpt/xhr/formdata/foreach.any.js | 56 + .../wpt/xhr/formdata/get-formelement.html | 34 + test/fixtures/wpt/xhr/formdata/get.any.js | 28 + .../wpt/xhr/formdata/has-formelement.html | 25 + test/fixtures/wpt/xhr/formdata/has.any.js | 19 + .../wpt/xhr/formdata/iteration.any.js | 65 + .../fixtures/wpt/xhr/formdata/set-blob.any.js | 61 + .../wpt/xhr/formdata/set-formelement.html | 51 + test/fixtures/wpt/xhr/formdata/set.any.js | 36 + .../formdata/submitter-coordinate-value.html | 55 + .../wpt/xhr/getallresponseheaders-cookies.htm | 38 + .../wpt/xhr/getallresponseheaders-status.htm | 33 + .../wpt/xhr/getallresponseheaders.htm | 35 + .../getresponseheader-case-insensitive.htm | 34 + .../xhr/getresponseheader-chunked-trailer.htm | 32 + .../getresponseheader-cookies-and-more.htm | 36 + .../wpt/xhr/getresponseheader-error-state.htm | 36 + .../wpt/xhr/getresponseheader-server-date.htm | 29 + .../getresponseheader-special-characters.htm | 34 + .../getresponseheader-unsent-opened-state.htm | 32 + .../fixtures/wpt/xhr/getresponseheader.any.js | 18 + .../wpt/xhr/header-user-agent-async.htm | 26 + .../wpt/xhr/header-user-agent-sync.htm | 20 + .../wpt/xhr/headers-normalize-response.htm | 43 + test/fixtures/wpt/xhr/historical.html | 15 + test/fixtures/wpt/xhr/idlharness.any.js | 28 + test/fixtures/wpt/xhr/json.any.js | 23 + .../fixtures/wpt/xhr/loadstart-and-state.html | 40 + test/fixtures/wpt/xhr/open-after-abort.htm | 77 + .../wpt/xhr/open-after-setrequestheader.htm | 33 + .../wpt/xhr/open-after-stop.window.js | 43 + .../wpt/xhr/open-during-abort-event.htm | 56 + .../wpt/xhr/open-during-abort-processing.htm | 62 + test/fixtures/wpt/xhr/open-during-abort.htm | 33 + test/fixtures/wpt/xhr/open-method-bogus.htm | 28 + .../wpt/xhr/open-method-case-insensitive.htm | 29 + .../wpt/xhr/open-method-case-sensitive.htm | 31 + .../fixtures/wpt/xhr/open-method-insecure.htm | 29 + .../xhr/open-method-responsetype-set-sync.htm | 32 + test/fixtures/wpt/xhr/open-open-send.htm | 33 + test/fixtures/wpt/xhr/open-open-sync-send.htm | 31 + .../wpt/xhr/open-parameters-toString.htm | 54 + test/fixtures/wpt/xhr/open-referer.htm | 20 + .../wpt/xhr/open-send-during-abort.htm | 27 + test/fixtures/wpt/xhr/open-send-open.htm | 33 + test/fixtures/wpt/xhr/open-sync-open-send.htm | 41 + .../wpt/xhr/open-url-about-blank-window.htm | 23 + .../xhr/open-url-base-inserted-after-open.htm | 24 + .../wpt/xhr/open-url-base-inserted.htm | 24 + test/fixtures/wpt/xhr/open-url-base.htm | 22 + test/fixtures/wpt/xhr/open-url-encoding.htm | 26 + test/fixtures/wpt/xhr/open-url-fragment.htm | 38 + .../wpt/xhr/open-url-javascript-window-2.htm | 19 + .../wpt/xhr/open-url-javascript-window.htm | 28 + .../wpt/xhr/open-url-multi-window-2.htm | 25 + .../wpt/xhr/open-url-multi-window-3.htm | 25 + .../wpt/xhr/open-url-multi-window-4.htm | 50 + .../wpt/xhr/open-url-multi-window-5.htm | 32 + .../wpt/xhr/open-url-multi-window-6.htm | 41 + .../wpt/xhr/open-url-multi-window.htm | 31 + ...pen-url-redirected-sharedworker-origin.htm | 11 + .../xhr/open-url-redirected-worker-origin.htm | 11 + .../wpt/xhr/open-url-worker-origin.htm | 9 + .../wpt/xhr/open-url-worker-simple.htm | 25 + .../open-user-password-non-same-origin.htm | 25 + test/fixtures/wpt/xhr/over-1-meg.any.js | 16 + .../wpt/xhr/overridemimetype-blob.html | 57 + .../xhr/overridemimetype-done-state.any.js | 20 + .../xhr/overridemimetype-edge-cases.window.js | 50 + ...-headers-received-state-force-shiftjis.htm | 34 + .../overridemimetype-invalid-mime-type.htm | 41 + .../xhr/overridemimetype-loading-state.htm | 32 + ...verridemimetype-open-state-force-utf-8.htm | 27 + .../overridemimetype-open-state-force-xml.htm | 34 + ...imetype-unsent-state-force-shiftjis.any.js | 12 + .../xhr/preserve-ua-header-on-redirect.htm | 43 + .../progress-events-response-data-gzip.htm | 83 + .../wpt/xhr/progressevent-constructor.html | 47 + .../wpt/xhr/progressevent-interface.html | 49 + .../wpt/xhr/request-content-length.any.js | 32 + .../wpt/xhr/resources/accept-language.py | 3 + test/fixtures/wpt/xhr/resources/accept.py | 2 + .../resources/access-control-allow-lists.py | 26 + .../access-control-allow-with-body.py | 15 + .../resources/access-control-auth-basic.py | 17 + ...cess-control-basic-allow-no-credentials.py | 5 + .../access-control-basic-allow-star.py | 5 + .../resources/access-control-basic-allow.py | 6 + ...l-basic-cors-safelisted-request-headers.py | 16 + ...-basic-cors-safelisted-response-headers.py | 19 + .../resources/access-control-basic-denied.py | 5 + ...ess-control-basic-options-not-supported.py | 12 + ...trol-basic-preflight-cache-invalidation.py | 49 + ...s-control-basic-preflight-cache-timeout.py | 50 + .../access-control-basic-preflight-cache.py | 50 + .../access-control-basic-put-allow.py | 22 + .../xhr/resources/access-control-cookie.py | 16 + .../resources/access-control-origin-header.py | 8 + .../access-control-preflight-denied.py | 49 + ...ight-request-allow-headers-returns-star.py | 12 + ...trol-preflight-request-header-lowercase.py | 16 + ...preflight-request-header-returns-origin.py | 12 + ...control-preflight-request-header-sorted.py | 18 + ...ontrol-preflight-request-headers-origin.py | 12 + ...ontrol-preflight-request-invalid-status.py | 16 + ...eflight-request-must-not-contain-cookie.py | 12 + .../access-control-sandboxed-iframe.html | 24 + test/fixtures/wpt/xhr/resources/auth1/auth.py | 13 + .../fixtures/wpt/xhr/resources/auth10/auth.py | 14 + .../fixtures/wpt/xhr/resources/auth11/auth.py | 13 + test/fixtures/wpt/xhr/resources/auth2/auth.py | 14 + .../wpt/xhr/resources/auth2/corsenabled.py | 19 + test/fixtures/wpt/xhr/resources/auth3/auth.py | 13 + test/fixtures/wpt/xhr/resources/auth4/auth.py | 13 + test/fixtures/wpt/xhr/resources/auth5/auth.py | 15 + test/fixtures/wpt/xhr/resources/auth6/auth.py | 15 + .../wpt/xhr/resources/auth7/corsenabled.py | 21 + .../auth8/corsenabled-no-authorize.py | 21 + test/fixtures/wpt/xhr/resources/auth9/auth.py | 13 + .../wpt/xhr/resources/authentication.py | 24 + .../wpt/xhr/resources/bad-chunk-encoding.py | 17 + test/fixtures/wpt/xhr/resources/base.xml | 1 + test/fixtures/wpt/xhr/resources/chunked.py | 17 + .../fixtures/wpt/xhr/resources/conditional.py | 29 + test/fixtures/wpt/xhr/resources/content.py | 20 + .../fixtures/wpt/xhr/resources/corsenabled.py | 25 + test/fixtures/wpt/xhr/resources/delay.py | 7 + .../wpt/xhr/resources/echo-content-cors.py | 23 + .../wpt/xhr/resources/echo-content-type.py | 6 + .../wpt/xhr/resources/echo-headers.py | 9 + .../fixtures/wpt/xhr/resources/echo-method.py | 16 + .../wpt/xhr/resources/empty-div-utf8-html.py | 5 + test/fixtures/wpt/xhr/resources/folder.txt | 1 + test/fixtures/wpt/xhr/resources/form.py | 2 + .../wpt/xhr/resources/get-set-cookie.py | 18 + test/fixtures/wpt/xhr/resources/gzip.py | 24 + .../header-content-length-twice.asis | 3 + .../xhr/resources/header-content-length.asis | 2 + .../wpt/xhr/resources/header-user-agent.py | 15 + .../wpt/xhr/resources/headers-basic.asis | 4 + .../xhr/resources/headers-double-empty.asis | 3 + .../xhr/resources/headers-some-are-empty.asis | 7 + .../resources/headers-www-authenticate.asis | 4 + test/fixtures/wpt/xhr/resources/headers.asis | 6 + test/fixtures/wpt/xhr/resources/headers.py | 12 + test/fixtures/wpt/xhr/resources/image.gif | Bin 0 -> 167145 bytes .../wpt/xhr/resources/img-utf8-html.py | 5 + test/fixtures/wpt/xhr/resources/img.jpg | Bin 0 -> 108761 bytes .../wpt/xhr/resources/infinite-redirects.py | 24 + test/fixtures/wpt/xhr/resources/init.htm | 20 + .../wpt/xhr/resources/inspect-headers.py | 36 + .../wpt/xhr/resources/invalid-utf8-html.py | 5 + .../wpt/xhr/resources/last-modified.py | 9 + .../no-custom-header-on-preflight.py | 27 + .../wpt/xhr/resources/nocors/folder.txt | 1 + .../fixtures/wpt/xhr/resources/over-1-meg.txt | 1 + .../wpt/xhr/resources/parse-headers.py | 6 + test/fixtures/wpt/xhr/resources/pass.txt | 1 + .../wpt/xhr/resources/redirect-cors.py | 20 + test/fixtures/wpt/xhr/resources/redirect.py | 16 + test/fixtures/wpt/xhr/resources/requri.py | 5 + .../fixtures/wpt/xhr/resources/reset-token.py | 5 + .../responseType-document-in-worker.js | 9 + .../responseXML-unavailable-in-worker.js | 9 + ...after-setting-document-domain-window-1.htm | 23 + ...after-setting-document-domain-window-2.htm | 20 + ...r-setting-document-domain-window-helper.js | 32 + .../wpt/xhr/resources/shift-jis-html.py | 6 + test/fixtures/wpt/xhr/resources/status.py | 11 + test/fixtures/wpt/xhr/resources/top.txt | 1 + test/fixtures/wpt/xhr/resources/trickle.py | 15 + test/fixtures/wpt/xhr/resources/upload.py | 17 + .../fixtures/wpt/xhr/resources/utf16-bom.json | Bin 0 -> 30 bytes test/fixtures/wpt/xhr/resources/utf16.txt | Bin 0 -> 18 bytes .../wpt/xhr/resources/well-formed.xml | 4 + .../wpt/xhr/resources/win-1252-html.py | 5 + .../wpt/xhr/resources/win-1252-xml.py | 5 + .../resources/workerxhr-origin-referrer.js | 63 + .../wpt/xhr/resources/workerxhr-simple.js | 9 + .../resources/xmlhttprequest-event-order.js | 83 + .../xmlhttprequest-timeout-aborted.js | 15 + .../xmlhttprequest-timeout-abortedonmain.js | 8 + .../xmlhttprequest-timeout-overrides.js | 12 + ...xmlhttprequest-timeout-overridesexpires.js | 12 + .../xmlhttprequest-timeout-runner.js | 21 + .../xmlhttprequest-timeout-simple.js | 6 + .../xmlhttprequest-timeout-synconmain.js | 2 + .../xmlhttprequest-timeout-synconworker.js | 11 + .../resources/xmlhttprequest-timeout-twice.js | 6 + .../xhr/resources/xmlhttprequest-timeout.js | 333 ++ test/fixtures/wpt/xhr/resources/zlib.py | 19 + .../wpt/xhr/response-body-errors.any.js | 23 + .../wpt/xhr/response-data-arraybuffer.htm | 54 + test/fixtures/wpt/xhr/response-data-blob.htm | 55 + .../wpt/xhr/response-data-deflate.htm | 42 + test/fixtures/wpt/xhr/response-data-gzip.htm | 42 + .../wpt/xhr/response-data-progress.htm | 52 + .../wpt/xhr/response-invalid-responsetype.htm | 38 + test/fixtures/wpt/xhr/response-json.htm | 61 + test/fixtures/wpt/xhr/response-method.htm | 21 + .../fixtures/wpt/xhr/responseText-status.html | 33 + .../xhr/responseType-document-in-worker.html | 13 + .../responseXML-unavailable-in-worker.html | 13 + .../wpt/xhr/responsedocument-decoding.htm | 39 + .../wpt/xhr/responsetext-decoding.htm | 93 + test/fixtures/wpt/xhr/responsetype.any.js | 135 + test/fixtures/wpt/xhr/responseurl.html | 37 + test/fixtures/wpt/xhr/responsexml-basic.htm | 33 + .../xhr/responsexml-document-properties.htm | 120 + .../wpt/xhr/responsexml-get-twice.htm | 66 + .../wpt/xhr/responsexml-invalid-type.html | 21 + .../wpt/xhr/responsexml-media-type.htm | 41 + .../xhr/responsexml-non-document-types.htm | 45 + .../wpt/xhr/responsexml-non-well-formed.htm | 30 + .../wpt/xhr/security-consideration.sub.html | 36 + .../fixtures/wpt/xhr/send-accept-language.htm | 27 + test/fixtures/wpt/xhr/send-accept.htm | 24 + .../send-after-setting-document-domain.htm | 39 + ...-authentication-basic-cors-not-enabled.htm | 29 + .../xhr/send-authentication-basic-cors.htm | 35 + ...nd-authentication-basic-repeat-no-args.htm | 33 + ...n-basic-setrequestheader-and-arguments.htm | 36 + ...asic-setrequestheader-existing-session.htm | 53 + ...-authentication-basic-setrequestheader.htm | 36 + .../wpt/xhr/send-authentication-basic.htm | 27 + ...thentication-competing-names-passwords.htm | 50 + ...entication-cors-basic-setrequestheader.htm | 31 + ...tication-cors-setrequestheader-no-cred.htm | 62 + ...authentication-existing-session-manual.htm | 33 + .../send-authentication-prompt-2-manual.htm | 25 + .../xhr/send-authentication-prompt-manual.htm | 25 + .../wpt/xhr/send-blob-with-no-mime-type.html | 61 + .../wpt/xhr/send-conditional-cors.htm | 42 + test/fixtures/wpt/xhr/send-conditional.htm | 34 + .../wpt/xhr/send-content-type-charset.htm | 115 + .../wpt/xhr/send-content-type-string.htm | 26 + .../wpt/xhr/send-data-arraybuffer.any.js | 31 + .../wpt/xhr/send-data-arraybufferview.any.js | 18 + test/fixtures/wpt/xhr/send-data-blob.htm | 62 + .../wpt/xhr/send-data-es-object.any.js | 58 + .../wpt/xhr/send-data-formdata.any.js | 21 + .../xhr/send-data-sharedarraybuffer.any.js | 27 + .../send-data-string-invalid-unicode.any.js | 46 + .../wpt/xhr/send-data-unexpected-tostring.htm | 56 + .../wpt/xhr/send-entity-body-basic.htm | 28 + .../xhr/send-entity-body-document-bogus.htm | 26 + .../wpt/xhr/send-entity-body-document.htm | 92 + .../wpt/xhr/send-entity-body-empty.htm | 26 + .../xhr/send-entity-body-get-head-async.htm | 39 + .../wpt/xhr/send-entity-body-get-head.htm | 36 + .../wpt/xhr/send-entity-body-none.htm | 40 + .../send-network-error-async-events.sub.htm | 47 + .../send-network-error-sync-events.sub.htm | 45 + .../xhr/send-no-response-event-loadend.htm | 48 + .../xhr/send-no-response-event-loadstart.htm | 48 + .../wpt/xhr/send-no-response-event-order.htm | 45 + .../fixtures/wpt/xhr/send-non-same-origin.htm | 33 + test/fixtures/wpt/xhr/send-receive-utf16.htm | 37 + .../wpt/xhr/send-redirect-bogus-sync.htm | 26 + test/fixtures/wpt/xhr/send-redirect-bogus.htm | 36 + .../wpt/xhr/send-redirect-infinite-sync.htm | 24 + .../wpt/xhr/send-redirect-infinite.htm | 35 + .../wpt/xhr/send-redirect-no-location.htm | 40 + .../wpt/xhr/send-redirect-post-upload.htm | 140 + .../wpt/xhr/send-redirect-to-cors.htm | 92 + .../wpt/xhr/send-redirect-to-non-cors.htm | 37 + test/fixtures/wpt/xhr/send-redirect.htm | 45 + .../wpt/xhr/send-response-event-order.htm | 40 + .../send-response-upload-event-loadend.htm | 40 + .../send-response-upload-event-loadstart.htm | 39 + .../send-response-upload-event-progress.htm | 39 + test/fixtures/wpt/xhr/send-send.any.js | 7 + .../wpt/xhr/send-sync-blocks-async.htm | 53 + .../xhr/send-sync-no-response-event-load.htm | 38 + .../send-sync-no-response-event-loadend.htm | 38 + .../xhr/send-sync-no-response-event-order.htm | 51 + .../xhr/send-sync-response-event-order.htm | 35 + test/fixtures/wpt/xhr/send-sync-timeout.htm | 29 + test/fixtures/wpt/xhr/send-timeout-events.htm | 62 + test/fixtures/wpt/xhr/send-usp.any.js | 46 + .../wpt/xhr/setrequestheader-after-send.htm | 27 + .../setrequestheader-allow-empty-value.htm | 26 + ...equestheader-allow-whitespace-in-value.htm | 27 + .../wpt/xhr/setrequestheader-before-open.htm | 18 + .../wpt/xhr/setrequestheader-bogus-name.htm | 59 + .../wpt/xhr/setrequestheader-bogus-value.htm | 36 + .../xhr/setrequestheader-case-insensitive.htm | 34 + .../xhr/setrequestheader-combining.window.js | 12 + .../wpt/xhr/setrequestheader-content-type.htm | 220 + .../xhr/setrequestheader-header-allowed.htm | 34 + .../xhr/setrequestheader-header-forbidden.htm | 95 + ...setrequestheader-open-setrequestheader.htm | 53 + test/fixtures/wpt/xhr/status-async.htm | 62 + test/fixtures/wpt/xhr/status-basic.htm | 51 + test/fixtures/wpt/xhr/status-error.htm | 87 + test/fixtures/wpt/xhr/status.h2.window.js | 21 + test/fixtures/wpt/xhr/sync-no-progress.any.js | 13 + test/fixtures/wpt/xhr/sync-no-timeout.any.js | 16 + .../wpt/xhr/sync-xhr-and-window-onload.html | 25 + .../sync-xhr-supported-by-feature-policy.html | 11 + test/fixtures/wpt/xhr/template-element.html | 36 + .../wpt/xhr/thrown-error-in-events.html | 60 + test/fixtures/wpt/xhr/timeout-cors-async.htm | 43 + .../wpt/xhr/timeout-multiple-fetches.html | 32 + test/fixtures/wpt/xhr/timeout-sync.htm | 25 + .../wpt/xhr/xhr-authorization-redirect.any.js | 28 + .../wpt/xhr/xhr-timeout-longtask.any.js | 14 + .../fixtures/wpt/xhr/xmlhttprequest-basic.htm | 45 + .../wpt/xhr/xmlhttprequest-eventtarget.htm | 48 + .../xhr/xmlhttprequest-network-error-sync.htm | 34 + .../wpt/xhr/xmlhttprequest-network-error.htm | 39 + ...est-sync-block-defer-scripts-subframe.html | 17 + ...lhttprequest-sync-block-defer-scripts.html | 15 + .../xmlhttprequest-sync-block-scripts.html | 22 + ...quest-sync-default-feature-policy.sub.html | 32 + ...t-sync-not-hang-scriptloader-subframe.html | 17 + ...ttprequest-sync-not-hang-scriptloader.html | 16 + .../xhr/xmlhttprequest-timeout-aborted.html | 29 + .../xmlhttprequest-timeout-abortedonmain.html | 25 + .../xhr/xmlhttprequest-timeout-overrides.html | 26 + ...lhttprequest-timeout-overridesexpires.html | 26 + .../xhr/xmlhttprequest-timeout-reused.html | 49 + .../xhr/xmlhttprequest-timeout-simple.html | 27 + .../xmlhttprequest-timeout-synconmain.html | 23 + .../wpt/xhr/xmlhttprequest-timeout-twice.html | 28 + ...xmlhttprequest-timeout-worker-aborted.html | 31 + ...lhttprequest-timeout-worker-overrides.html | 27 + ...quest-timeout-worker-overridesexpires.html | 28 + .../xmlhttprequest-timeout-worker-simple.html | 29 + ...tprequest-timeout-worker-synconworker.html | 28 + .../xmlhttprequest-timeout-worker-twice.html | 29 + .../wpt/xhr/xmlhttprequest-unsent.htm | 36 + test/fuzzing/client/client-fuzz-body.js | 26 + test/fuzzing/client/client-fuzz-headers.js | 26 + test/fuzzing/client/client-fuzz-options.js | 36 + test/fuzzing/client/index.js | 7 + test/fuzzing/fuzzing.test.js | 64 + test/fuzzing/server/index.js | 15 + .../fuzzing/server/server-fuzz-append-data.js | 7 + test/fuzzing/server/server-fuzz-split-data.js | 17 + test/gc.js | 111 + test/get-head-body.js | 191 + test/headers-as-array.js | 155 + test/headers-crlf.js | 41 + test/http-100.js | 155 + test/http-req-destroy.js | 72 + test/http2-alpn.js | 278 + test/http2.js | 1805 ++++++ test/https.js | 79 + test/imports/undici-import.ts | 16 + test/inflight-and-close.js | 37 + test/interceptors/cache-fastimers-fix.js | 47 + test/interceptors/cache.js | 1117 ++++ test/interceptors/dns.js | 1938 +++++++ test/interceptors/dump-interceptor.js | 525 ++ test/interceptors/redirect-issue-3803.js | 40 + test/interceptors/redirect.js | 795 +++ test/interceptors/response-error.js | 236 + test/interceptors/retry.js | 574 ++ test/invalid-headers.js | 109 + test/issue-1757.js | 55 + test/issue-2065.js | 28 + test/issue-2078.js | 29 + test/issue-2283.js | 26 + test/issue-2349.js | 44 + test/issue-2590.js | 39 + test/issue-3356.js | 61 + test/issue-3410.js | 88 + test/issue-3934.js | 32 + test/issue-803.js | 57 + test/issue-810.js | 146 + test/jest/instanceof-error.test.js | 40 + test/jest/issue-1757.test.js | 58 + test/jest/mock-agent.test.js | 46 + test/jest/mock-scope.test.js | 31 + test/jest/test.js | 36 + test/max-headers.js | 45 + test/max-response-size.js | 112 + test/mock-agent.js | 2583 +++++++++ test/mock-client.js | 442 ++ test/mock-errors.js | 27 + test/mock-interceptor-unused-assertions.js | 285 + test/mock-interceptor.js | 381 ++ test/mock-pool.js | 363 ++ test/mock-scope.js | 68 + test/mock-utils.js | 238 + test/no-strict-content-length.js | 365 ++ test/node-fetch/LICENSE | 22 + test/node-fetch/headers.js | 308 + test/node-fetch/main.js | 1660 ++++++ test/node-fetch/mock.js | 112 + test/node-fetch/request.js | 277 + test/node-fetch/response.js | 240 + test/node-fetch/utils/dummy.txt | 1 + test/node-fetch/utils/read-stream.js | 9 + test/node-fetch/utils/server.js | 469 ++ test/node-platform-objects.js | 33 + test/node-test/abort-controller.js | 253 + test/node-test/abort-event-emitter.js | 273 + test/node-test/agent.js | 810 +++ test/node-test/async_hooks.js | 214 + test/node-test/autoselectfamily.js | 202 + test/node-test/balanced-pool.js | 557 ++ test/node-test/ca-fingerprint.js | 131 + test/node-test/client-abort.js | 226 + test/node-test/client-connect.js | 294 + test/node-test/client-dispatch.js | 1110 ++++ test/node-test/client-errors.js | 1391 +++++ test/node-test/debug.js | 134 + .../diagnostics-channel/connect-error.js | 56 + test/node-test/diagnostics-channel/error.js | 48 + test/node-test/diagnostics-channel/get-h2.js | 101 + test/node-test/diagnostics-channel/get.js | 140 + .../diagnostics-channel/post-stream.js | 146 + test/node-test/diagnostics-channel/post.js | 145 + test/node-test/large-body.js | 45 + test/node-test/tree.js | 77 + test/node-test/unix.js | 159 + test/node-test/util.js | 120 + test/node-test/validations.js | 60 + test/parser-issues.js | 125 + test/pipeline-pipelining.js | 118 + test/pool.js | 1152 ++++ test/promises.js | 295 + test/proxy-agent.js | 919 +++ test/proxy.js | 167 + test/readable.js | 191 + test/redirect-pipeline.js | 54 + test/redirect-request.js | 628 +++ test/redirect-stream.js | 423 ++ test/request-crlf.js | 36 + test/request-signal.js | 76 + test/request-timeout.js | 846 +++ test/request-timeout2.js | 53 + test/request.js | 406 ++ test/retry-agent.js | 67 + test/retry-handler.js | 1549 +++++ test/socket-back-pressure.js | 58 + test/socket-timeout.js | 98 + test/stream-compat.js | 80 + test/timers.js | 258 + test/tls-session-reuse.js | 180 + test/tls.js | 188 + test/trailers.js | 60 + test/types/agent.test-d.ts | 115 + test/types/api.test-d.ts | 38 + test/types/balanced-pool.test-d.ts | 112 + test/types/cache-interceptor.test-d.ts | 90 + test/types/cache-storage.test-d.ts | 39 + test/types/client.test-d.ts | 183 + test/types/connector.test-d.ts | 37 + test/types/diagnostics-channel.test-d.ts | 72 + test/types/dispatcher.events.test-d.ts | 44 + test/types/dispatcher.test-d.ts | 212 + test/types/env-http-proxy-agent.test-d.ts | 110 + test/types/errors.test-d.ts | 133 + test/types/event-source-d.ts | 23 + test/types/fetch.test-d.ts | 187 + test/types/formdata.test-d.ts | 27 + test/types/global-dispatcher.test-d.ts | 10 + test/types/header.test-d.ts | 16 + test/types/index.test-d.ts | 25 + test/types/mock-agent.test-d.ts | 97 + test/types/mock-client.test-d.ts | 42 + test/types/mock-errors.test-d.ts | 19 + test/types/mock-interceptor.test-d.ts | 95 + test/types/mock-pool.test-d.ts | 41 + test/types/pool.test-d.ts | 112 + test/types/proxy-agent.test-d.ts | 43 + test/types/readable.test-d.ts | 43 + test/types/retry-agent.test-d.ts | 17 + test/types/retry-handler.test-d.ts | 49 + test/types/util.test-d.ts | 25 + test/util.js | 255 + test/utils/async-iterators.js | 25 + test/utils/date.js | 76 + test/utils/esm-wrapper.mjs | 105 + test/utils/event-loop-blocker.js | 10 + test/utils/formdata.js | 51 + test/utils/hello-world-server.js | 30 + test/utils/node-http.js | 24 + test/utils/redirecting-servers.js | 266 + test/utils/stream.js | 45 + test/webidl/converters.js | 249 + test/webidl/errors.js | 48 + test/webidl/helpers.js | 81 + test/webidl/util.js | 105 + .../websocket/client-received-masked-frame.js | 45 + test/websocket/close-invalid-status-code.js | 39 + test/websocket/close-invalid-utf-8.js | 49 + test/websocket/close.js | 154 + test/websocket/constructor.js | 47 + test/websocket/continuation-frames.js | 39 + test/websocket/custom-headers.js | 30 + test/websocket/diagnostics-channel.js | 64 + test/websocket/events.js | 209 + test/websocket/fragments.js | 42 + test/websocket/frame.js | 40 + test/websocket/issue-2679.js | 29 + test/websocket/issue-2844.js | 73 + test/websocket/issue-2859.js | 38 + test/websocket/issue-3202.js | 35 + test/websocket/issue-3506.js | 18 + test/websocket/issue-3546.js | 24 + test/websocket/issue-3697-2399493917.js | 15 + test/websocket/messageevent.js | 136 + test/websocket/opening-handshake.js | 217 + test/websocket/ping-pong.js | 47 + test/websocket/receive.js | 60 + test/websocket/send-mutable.js | 34 + test/websocket/send.js | 237 + test/websocket/util.js | 31 + test/websocket/websocketinit.js | 42 + test/wpt/runner/runner.mjs | 423 ++ test/wpt/runner/util.mjs | 172 + test/wpt/runner/worker.mjs | 193 + test/wpt/server/constants.mjs | 3 + test/wpt/server/lockedresource.mjs | 31 + .../server/routes/network-partition-key.mjs | 132 + test/wpt/server/routes/redirect.mjs | 104 + test/wpt/server/server.mjs | 499 ++ test/wpt/server/util.mjs | 290 + test/wpt/server/websocket.mjs | 79 + test/wpt/start-cacheStorage.mjs | 28 + test/wpt/start-eventsource.mjs | 28 + test/wpt/start-fetch.mjs | 33 + test/wpt/start-mimesniff.mjs | 33 + test/wpt/start-websockets.mjs | 49 + test/wpt/start-xhr.mjs | 12 + test/wpt/status/eventsource.status.json | 6 + test/wpt/status/fetch.status.json | 523 ++ test/wpt/status/mimesniff.status.json | 7 + .../service-workers/cache-storage.status.json | 32 + test/wpt/status/websockets.status.json | 96 + test/wpt/status/xhr/formdata.status.json | 1 + types/README.md | 6 + types/agent.d.ts | 31 + types/api.d.ts | 43 + types/balanced-pool.d.ts | 29 + types/cache-interceptor.d.ts | 172 + types/cache.d.ts | 36 + types/client.d.ts | 107 + types/connector.d.ts | 34 + types/content-type.d.ts | 21 + types/cookies.d.ts | 30 + types/diagnostics-channel.d.ts | 66 + types/dispatcher.d.ts | 279 + types/env-http-proxy-agent.d.ts | 21 + types/errors.d.ts | 171 + types/eventsource.d.ts | 61 + types/fetch.d.ts | 210 + types/formdata.d.ts | 108 + types/global-dispatcher.d.ts | 9 + types/global-origin.d.ts | 7 + types/handlers.d.ts | 15 + types/header.d.ts | 160 + types/index.d.ts | 70 + types/interceptors.d.ts | 34 + types/mock-agent.d.ts | 53 + types/mock-client.d.ts | 25 + types/mock-errors.d.ts | 12 + types/mock-interceptor.d.ts | 93 + types/mock-pool.d.ts | 25 + types/patch.d.ts | 29 + types/pool-stats.d.ts | 19 + types/pool.d.ts | 39 + types/proxy-agent.d.ts | 28 + types/readable.d.ts | 68 + types/retry-agent.d.ts | 8 + types/retry-handler.d.ts | 116 + types/util.d.ts | 18 + types/utility.d.ts | 7 + types/webidl.d.ts | 266 + types/websocket.d.ts | 183 + 3698 files changed, 323029 insertions(+) create mode 100644 .c8rc.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/autobahn.yml create mode 100644 .github/workflows/backport.yml create mode 100644 .github/workflows/bench.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/nightly.yml create mode 100644 .github/workflows/nodejs-shared.yml create mode 100644 .github/workflows/nodejs.yml create mode 100644 .github/workflows/release-create-pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 .github/workflows/test.yml create mode 100644 .github/workflows/triggered-autobahn.yml create mode 100644 .github/workflows/update-cache-tests.yml create mode 100644 .github/workflows/update-wpt.yml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .npmignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 GOVERNANCE.md create mode 100644 LICENSE create mode 100644 MAINTAINERS.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 benchmarks/_util/index.js create mode 100644 benchmarks/api/util.mjs create mode 100644 benchmarks/benchmark-http2.js create mode 100644 benchmarks/benchmark-https.js create mode 100644 benchmarks/benchmark.js create mode 100644 benchmarks/cache/date.mjs create mode 100644 benchmarks/cache/get-field-values.mjs create mode 100644 benchmarks/cookies/Is-ctl-excluding-htab.mjs create mode 100644 benchmarks/cookies/to-imf-date.mjs create mode 100644 benchmarks/cookies/validate-cookie-name.mjs create mode 100644 benchmarks/cookies/validate-cookie-value.mjs create mode 100644 benchmarks/core/is-blob-like.mjs create mode 100644 benchmarks/core/is-valid-header-char.mjs create mode 100644 benchmarks/core/is-valid-port.mjs create mode 100644 benchmarks/core/parse-headers.mjs create mode 100644 benchmarks/core/parse-raw-headers.mjs create mode 100644 benchmarks/core/request-instantiation.mjs create mode 100644 benchmarks/core/tree.mjs create mode 100644 benchmarks/fetch/body-arraybuffer.mjs create mode 100644 benchmarks/fetch/bytes-match.mjs create mode 100644 benchmarks/fetch/headers-length32.mjs create mode 100644 benchmarks/fetch/headers.mjs create mode 100644 benchmarks/fetch/is-valid-encoded-url.mjs create mode 100644 benchmarks/fetch/is-valid-header-value.mjs create mode 100644 benchmarks/fetch/isomorphic-encode.mjs create mode 100644 benchmarks/fetch/request-creation.mjs create mode 100644 benchmarks/fetch/url-has-https-scheme.mjs create mode 100644 benchmarks/fetch/webidl-is.mjs create mode 100644 benchmarks/package.json create mode 100644 benchmarks/post-benchmark.js create mode 100644 benchmarks/server-http2.js create mode 100644 benchmarks/server-https.js create mode 100644 benchmarks/server.js create mode 100644 benchmarks/timers/compare-timer-getters.mjs create mode 100644 benchmarks/wait.js create mode 100644 benchmarks/websocket/generate-mask.mjs create mode 100644 benchmarks/websocket/is-valid-subprotocol.mjs create mode 100644 benchmarks/websocket/messageevent.mjs create mode 100644 build/wasm.js create mode 100644 docs/.nojekyll create mode 100644 docs/CNAME create mode 120000 docs/README.md create mode 100644 docs/docs/api/Agent.md create mode 100644 docs/docs/api/BalancedPool.md create mode 100644 docs/docs/api/CacheStorage.md create mode 100644 docs/docs/api/CacheStore.md create mode 100644 docs/docs/api/Client.md create mode 100644 docs/docs/api/Connector.md create mode 100644 docs/docs/api/ContentType.md create mode 100644 docs/docs/api/Cookies.md create mode 100644 docs/docs/api/Debug.md create mode 100644 docs/docs/api/DiagnosticsChannel.md create mode 100644 docs/docs/api/Dispatcher.md create mode 100644 docs/docs/api/EnvHttpProxyAgent.md create mode 100644 docs/docs/api/Errors.md create mode 100644 docs/docs/api/EventSource.md create mode 100644 docs/docs/api/Fetch.md create mode 100644 docs/docs/api/MockAgent.md create mode 100644 docs/docs/api/MockClient.md create mode 100644 docs/docs/api/MockErrors.md create mode 100644 docs/docs/api/MockPool.md create mode 100644 docs/docs/api/Pool.md create mode 100644 docs/docs/api/PoolStats.md create mode 100644 docs/docs/api/ProxyAgent.md create mode 100644 docs/docs/api/RedirectHandler.md create mode 100644 docs/docs/api/RetryAgent.md create mode 100644 docs/docs/api/RetryHandler.md create mode 100644 docs/docs/api/Util.md create mode 100644 docs/docs/api/WebSocket.md create mode 100644 docs/docs/api/api-lifecycle.md create mode 100644 docs/docs/best-practices/client-certificate.md create mode 100644 docs/docs/best-practices/mocking-request.md create mode 100644 docs/docs/best-practices/proxy.md create mode 100644 docs/docs/best-practices/writing-tests.md create mode 100644 docs/docsify/sidebar.md create mode 100644 docs/examples/README.md create mode 100644 docs/examples/ca-fingerprint/index.js create mode 100644 docs/examples/eventsource.js create mode 100644 docs/examples/fetch.js create mode 100644 docs/examples/proxy-agent.js create mode 100644 docs/examples/proxy/fetch.mjs create mode 100644 docs/examples/proxy/index.js create mode 100644 docs/examples/proxy/proxy.js create mode 100644 docs/examples/proxy/websocket.js create mode 100644 docs/examples/request.js create mode 100644 docs/index.html create mode 100644 docs/package.json create mode 100644 eslint.config.js create mode 100644 index-fetch.js create mode 100644 index.d.ts create mode 100644 index.js create mode 100644 lib/api/abort-signal.js create mode 100644 lib/api/api-connect.js create mode 100644 lib/api/api-pipeline.js create mode 100644 lib/api/api-request.js create mode 100644 lib/api/api-stream.js create mode 100644 lib/api/api-upgrade.js create mode 100644 lib/api/index.js create mode 100644 lib/api/readable.js create mode 100644 lib/api/util.js create mode 100644 lib/cache/memory-cache-store.js create mode 100644 lib/cache/sqlite-cache-store.js create mode 100644 lib/core/connect.js create mode 100644 lib/core/constants.js create mode 100644 lib/core/diagnostics.js create mode 100644 lib/core/errors.js create mode 100644 lib/core/request.js create mode 100644 lib/core/symbols.js create mode 100644 lib/core/tree.js create mode 100644 lib/core/util.js create mode 100644 lib/dispatcher/agent.js create mode 100644 lib/dispatcher/balanced-pool.js create mode 100644 lib/dispatcher/client-h1.js create mode 100644 lib/dispatcher/client-h2.js create mode 100644 lib/dispatcher/client.js create mode 100644 lib/dispatcher/dispatcher-base.js create mode 100644 lib/dispatcher/dispatcher.js create mode 100644 lib/dispatcher/env-http-proxy-agent.js create mode 100644 lib/dispatcher/fixed-queue.js create mode 100644 lib/dispatcher/pool-base.js create mode 100644 lib/dispatcher/pool-stats.js create mode 100644 lib/dispatcher/pool.js create mode 100644 lib/dispatcher/proxy-agent.js create mode 100644 lib/dispatcher/retry-agent.js create mode 100644 lib/global.js create mode 100644 lib/handler/cache-handler.js create mode 100644 lib/handler/cache-revalidation-handler.js create mode 100644 lib/handler/decorator-handler.js create mode 100644 lib/handler/redirect-handler.js create mode 100644 lib/handler/retry-handler.js create mode 100644 lib/handler/unwrap-handler.js create mode 100644 lib/handler/wrap-handler.js create mode 100644 lib/interceptor/cache.js create mode 100644 lib/interceptor/dns.js create mode 100644 lib/interceptor/dump.js create mode 100644 lib/interceptor/redirect.js create mode 100644 lib/interceptor/response-error.js create mode 100644 lib/interceptor/retry.js create mode 100644 lib/llhttp/.gitkeep create mode 100644 lib/llhttp/constants.d.ts create mode 100644 lib/llhttp/constants.js create mode 100644 lib/llhttp/utils.d.ts create mode 100644 lib/llhttp/utils.js create mode 100644 lib/mock/mock-agent.js create mode 100644 lib/mock/mock-client.js create mode 100644 lib/mock/mock-errors.js create mode 100644 lib/mock/mock-interceptor.js create mode 100644 lib/mock/mock-pool.js create mode 100644 lib/mock/mock-symbols.js create mode 100644 lib/mock/mock-utils.js create mode 100644 lib/mock/pending-interceptors-formatter.js create mode 100644 lib/util/cache.js create mode 100644 lib/util/date.js create mode 100644 lib/util/timers.js create mode 100644 lib/web/cache/cache.js create mode 100644 lib/web/cache/cachestorage.js create mode 100644 lib/web/cache/util.js create mode 100644 lib/web/cookies/constants.js create mode 100644 lib/web/cookies/index.js create mode 100644 lib/web/cookies/parse.js create mode 100644 lib/web/cookies/util.js create mode 100644 lib/web/eventsource/eventsource-stream.js create mode 100644 lib/web/eventsource/eventsource.js create mode 100644 lib/web/eventsource/util.js create mode 100644 lib/web/fetch/LICENSE create mode 100644 lib/web/fetch/body.js create mode 100644 lib/web/fetch/constants.js create mode 100644 lib/web/fetch/data-url.js create mode 100644 lib/web/fetch/dispatcher-weakref.js create mode 100644 lib/web/fetch/formdata-parser.js create mode 100644 lib/web/fetch/formdata.js create mode 100644 lib/web/fetch/global.js create mode 100644 lib/web/fetch/headers.js create mode 100644 lib/web/fetch/index.js create mode 100644 lib/web/fetch/request.js create mode 100644 lib/web/fetch/response.js create mode 100644 lib/web/fetch/util.js create mode 100644 lib/web/fetch/webidl.js create mode 100644 lib/web/websocket/connection.js create mode 100644 lib/web/websocket/constants.js create mode 100644 lib/web/websocket/events.js create mode 100644 lib/web/websocket/frame.js create mode 100644 lib/web/websocket/permessage-deflate.js create mode 100644 lib/web/websocket/receiver.js create mode 100644 lib/web/websocket/sender.js create mode 100644 lib/web/websocket/stream/websocketerror.js create mode 100644 lib/web/websocket/stream/websocketstream.js create mode 100644 lib/web/websocket/util.js create mode 100644 lib/web/websocket/websocket.js create mode 100644 package.json create mode 100644 scripts/clean-coverage.js create mode 100644 scripts/generate-pem.js create mode 100644 scripts/generate-undici-types-package-json.js create mode 100644 scripts/platform-shell.js create mode 100644 scripts/release.js create mode 100644 scripts/strip-comments.js create mode 100644 scripts/verifyVersion.js create mode 100644 test/autobahn/.gitignore create mode 100644 test/autobahn/client.js create mode 100644 test/autobahn/config/fuzzingserver.json create mode 100644 test/autobahn/report.js create mode 100755 test/autobahn/run.sh create mode 100644 test/busboy/LICENSE create mode 100644 test/busboy/issue-3676.js create mode 100644 test/busboy/issue-3760.js create mode 100644 test/busboy/test-types-multipart-charsets.js create mode 100644 test/busboy/test-types-multipart.js create mode 100644 test/cache-interceptor/cache-store-test-utils.js create mode 100644 test/cache-interceptor/cache-tests-worker.mjs create mode 100644 test/cache-interceptor/cache-tests.mjs create mode 100644 test/cache-interceptor/memory-cache-store-tests.js create mode 100644 test/cache-interceptor/sqlite-cache-store-tests.js create mode 100644 test/cache-interceptor/utils.js create mode 100644 test/cache/cache.js create mode 100644 test/cache/cachestorage.js create mode 100644 test/cache/get-field-values.js create mode 100644 test/client-connect.js create mode 100644 test/client-head-reset-override.js create mode 100644 test/client-idempotent-body.js create mode 100644 test/client-keep-alive.js create mode 100644 test/client-node-max-header-size.js create mode 100644 test/client-pipeline.js create mode 100644 test/client-pipelining.js create mode 100644 test/client-post.js create mode 100644 test/client-reconnect.js create mode 100644 test/client-request.js create mode 100644 test/client-stream.js create mode 100644 test/client-timeout.js create mode 100644 test/client-unref.js create mode 100644 test/client-upgrade.js create mode 100644 test/client-wasm.js create mode 100644 test/client-write-max-listeners.js create mode 100644 test/client.js create mode 100644 test/close-and-destroy.js create mode 100644 test/connect-abort.js create mode 100644 test/connect-errconnect.js create mode 100644 test/connect-pre-shared-session.js create mode 100644 test/connect-timeout.js create mode 100644 test/content-length.js create mode 100644 test/cookie/cookies.js create mode 100644 test/cookie/global-headers.js create mode 100644 test/cookie/is-ctl-excluding-htab.js create mode 100644 test/cookie/npm-cookie.js create mode 100644 test/cookie/to-imf-date.js create mode 100644 test/cookie/validate-cookie-name.js create mode 100644 test/cookie/validate-cookie-path.js create mode 100644 test/cookie/validate-cookie-value.js create mode 100644 test/decorator-handler.js create mode 100644 test/dispatcher.js create mode 100644 test/env-http-proxy-agent.js create mode 100644 test/errors.js create mode 100644 test/esm-wrapper.js create mode 100644 test/eventsource/eventsource-attributes.js create mode 100644 test/eventsource/eventsource-close.js create mode 100644 test/eventsource/eventsource-connect.js create mode 100644 test/eventsource/eventsource-constructor-stringify.js create mode 100644 test/eventsource/eventsource-constructor.js create mode 100644 test/eventsource/eventsource-custom-dispatcher.js create mode 100644 test/eventsource/eventsource-message.js create mode 100644 test/eventsource/eventsource-properties.js create mode 100644 test/eventsource/eventsource-reconnect.js create mode 100644 test/eventsource/eventsource-redirecting.js create mode 100644 test/eventsource/eventsource-request-status-error.js create mode 100644 test/eventsource/eventsource-stream-bom.js create mode 100644 test/eventsource/eventsource-stream-parse-line.js create mode 100644 test/eventsource/eventsource-stream-process-event.js create mode 100644 test/eventsource/eventsource-stream.js create mode 100644 test/eventsource/eventsource.js create mode 100644 test/eventsource/util.js create mode 100644 test/examples.js create mode 100644 test/fetch/407-statuscode-window-null.js create mode 100644 test/fetch/abort.js create mode 100644 test/fetch/abort2.js create mode 100644 test/fetch/about-uri.js create mode 100644 test/fetch/blob-uri.js create mode 100644 test/fetch/bundle.js create mode 100644 test/fetch/client-error-stack-trace.js create mode 100644 test/fetch/client-fetch.js create mode 100644 test/fetch/client-node-max-header-size.js create mode 100644 test/fetch/content-length.js create mode 100644 test/fetch/cookies.js create mode 100644 test/fetch/data-uri.js create mode 100644 test/fetch/encoding.js create mode 100644 test/fetch/exiting.js create mode 100644 test/fetch/export-env-proxy-agent.js create mode 100644 test/fetch/fetch-leak.js create mode 100644 test/fetch/fetch-timeouts.js create mode 100644 test/fetch/fetch-url-after-redirect.js create mode 100644 test/fetch/fire-and-forget.js create mode 100644 test/fetch/formdata-inspect-custom.js create mode 100644 test/fetch/formdata.js create mode 100644 test/fetch/general.js create mode 100644 test/fetch/headers-case.js create mode 100644 test/fetch/headers-inspect-custom.js create mode 100644 test/fetch/headers.js create mode 100644 test/fetch/headerslist-sortedarray.js create mode 100644 test/fetch/http2.js create mode 100644 test/fetch/integrity.js create mode 100644 test/fetch/issue-1447.js create mode 100644 test/fetch/issue-1711.js create mode 100644 test/fetch/issue-2009.js create mode 100644 test/fetch/issue-2021.js create mode 100644 test/fetch/issue-2171.js create mode 100644 test/fetch/issue-2242.js create mode 100644 test/fetch/issue-2294-patch-method.js create mode 100644 test/fetch/issue-2318.js create mode 100644 test/fetch/issue-2828.js create mode 100644 test/fetch/issue-2898-comment.js create mode 100644 test/fetch/issue-2898.js create mode 100644 test/fetch/issue-3267.js create mode 100644 test/fetch/issue-3334.js create mode 100644 test/fetch/issue-3616.js create mode 100644 test/fetch/issue-3624.js create mode 100644 test/fetch/issue-3630.js create mode 100644 test/fetch/issue-3767.js create mode 100644 test/fetch/issue-node-46525.js create mode 100644 test/fetch/issue-node-56474.js create mode 100644 test/fetch/issue-rsshub-15532.js create mode 100644 test/fetch/iterators.js create mode 100644 test/fetch/long-lived-abort-controller.js create mode 100644 test/fetch/max-listeners.js create mode 100644 test/fetch/pull-dont-push.js create mode 100644 test/fetch/redirect-cross-origin-header.js create mode 100644 test/fetch/redirect.js create mode 100644 test/fetch/referrrer-policy.js create mode 100644 test/fetch/relative-url.js create mode 100644 test/fetch/request-inspect-custom.js create mode 100644 test/fetch/request.js create mode 100644 test/fetch/resource-timing.js create mode 100644 test/fetch/response-inspect-custom.js create mode 100644 test/fetch/response-json.js create mode 100644 test/fetch/response.js create mode 100644 test/fetch/spread.js create mode 100644 test/fetch/user-agent.js create mode 100644 test/fetch/util.js create mode 100644 test/fixed-queue.js create mode 100644 test/fixtures/ca.pem create mode 100644 test/fixtures/cache-tests/LICENSE create mode 100644 test/fixtures/cache-tests/results/apache.json create mode 100644 test/fixtures/cache-tests/results/caddy.json create mode 100644 test/fixtures/cache-tests/results/chrome.json create mode 100644 test/fixtures/cache-tests/results/fastly.json create mode 100644 test/fixtures/cache-tests/results/firefox.json create mode 100644 test/fixtures/cache-tests/results/index.mjs create mode 100644 test/fixtures/cache-tests/results/nginx.json create mode 100644 test/fixtures/cache-tests/results/safari.json create mode 100644 test/fixtures/cache-tests/results/squid.json create mode 100644 test/fixtures/cache-tests/results/trafficserver.json create mode 100644 test/fixtures/cache-tests/results/varnish.json create mode 100644 test/fixtures/cache-tests/test-engine/cli.mjs create mode 100644 test/fixtures/cache-tests/test-engine/client/config.mjs create mode 100644 test/fixtures/cache-tests/test-engine/client/fetching.mjs create mode 100644 test/fixtures/cache-tests/test-engine/client/runner.mjs create mode 100644 test/fixtures/cache-tests/test-engine/client/test.mjs create mode 100644 test/fixtures/cache-tests/test-engine/client/utils.mjs create mode 100644 test/fixtures/cache-tests/test-engine/export.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/defines.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/display.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/header-fixup.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/modal.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/results.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/summary.mjs create mode 100644 test/fixtures/cache-tests/test-engine/lib/testsuite-schema.json create mode 100644 test/fixtures/cache-tests/test-engine/lib/tpl/checks.liquid create mode 100644 test/fixtures/cache-tests/test-engine/lib/tpl/explain-test.liquid create mode 100644 test/fixtures/cache-tests/test-engine/lib/tpl/header-list.liquid create mode 100644 test/fixtures/cache-tests/test-engine/lib/tpl/header-magic.liquid create mode 100644 test/fixtures/cache-tests/test-engine/lib/utils.mjs create mode 100644 test/fixtures/cache-tests/test-engine/server/handle-config.mjs create mode 100644 test/fixtures/cache-tests/test-engine/server/handle-file.mjs create mode 100644 test/fixtures/cache-tests/test-engine/server/handle-state.mjs create mode 100644 test/fixtures/cache-tests/test-engine/server/handle-test.mjs create mode 100644 test/fixtures/cache-tests/test-engine/server/server.mjs create mode 100644 test/fixtures/cache-tests/test-engine/server/utils.mjs create mode 100644 test/fixtures/cache-tests/tests/age-parse.mjs create mode 100644 test/fixtures/cache-tests/tests/authorization.mjs create mode 100644 test/fixtures/cache-tests/tests/cc-freshness.mjs create mode 100644 test/fixtures/cache-tests/tests/cc-parse.mjs create mode 100644 test/fixtures/cache-tests/tests/cc-request.mjs create mode 100644 test/fixtures/cache-tests/tests/cc-response.mjs create mode 100644 test/fixtures/cache-tests/tests/cdn-cache-control.mjs create mode 100644 test/fixtures/cache-tests/tests/conditional-etag.mjs create mode 100644 test/fixtures/cache-tests/tests/conditional-lm.mjs create mode 100644 test/fixtures/cache-tests/tests/expires-freshness.mjs create mode 100644 test/fixtures/cache-tests/tests/expires-parse.mjs create mode 100644 test/fixtures/cache-tests/tests/headers.mjs create mode 100644 test/fixtures/cache-tests/tests/heuristic-freshness.mjs create mode 100644 test/fixtures/cache-tests/tests/index.mjs create mode 100644 test/fixtures/cache-tests/tests/invalidation.mjs create mode 100644 test/fixtures/cache-tests/tests/lib/header-list.mjs create mode 100644 test/fixtures/cache-tests/tests/lib/templates.mjs create mode 100644 test/fixtures/cache-tests/tests/lib/utils.mjs create mode 100644 test/fixtures/cache-tests/tests/method.mjs create mode 100644 test/fixtures/cache-tests/tests/other.mjs create mode 100644 test/fixtures/cache-tests/tests/partial.mjs create mode 100644 test/fixtures/cache-tests/tests/pragma.mjs create mode 100644 test/fixtures/cache-tests/tests/stale.mjs create mode 100644 test/fixtures/cache-tests/tests/status.mjs create mode 100644 test/fixtures/cache-tests/tests/update304.mjs create mode 100644 test/fixtures/cache-tests/tests/updateHead.mjs create mode 100644 test/fixtures/cache-tests/tests/vary-parse.mjs create mode 100644 test/fixtures/cache-tests/tests/vary.mjs create mode 100644 test/fixtures/cert.pem create mode 100644 test/fixtures/fetch.js create mode 100644 test/fixtures/interceptors/retry-event-loop.js create mode 100644 test/fixtures/key.pem create mode 100644 test/fixtures/undici.js create mode 100644 test/fixtures/websocket.js create mode 100644 test/fixtures/wpt/LICENSE.md create mode 100644 test/fixtures/wpt/common/CustomCorsResponse.py create mode 100644 test/fixtures/wpt/common/META.yml create mode 100644 test/fixtures/wpt/common/PrefixedLocalStorage.js create mode 100644 test/fixtures/wpt/common/PrefixedLocalStorage.js.headers create mode 100644 test/fixtures/wpt/common/PrefixedPostMessage.js create mode 100644 test/fixtures/wpt/common/PrefixedPostMessage.js.headers create mode 100644 test/fixtures/wpt/common/README.md create mode 100644 test/fixtures/wpt/common/__init__.py create mode 100644 test/fixtures/wpt/common/arrays.js create mode 100644 test/fixtures/wpt/common/blank-with-cors.html create mode 100644 test/fixtures/wpt/common/blank-with-cors.html.headers create mode 100644 test/fixtures/wpt/common/blank.html create mode 100644 test/fixtures/wpt/common/custom-cors-response.js create mode 100644 test/fixtures/wpt/common/dispatcher/README.md create mode 100644 test/fixtures/wpt/common/dispatcher/dispatcher.js create mode 100644 test/fixtures/wpt/common/dispatcher/dispatcher.py create mode 100644 test/fixtures/wpt/common/dispatcher/executor-service-worker.js create mode 100644 test/fixtures/wpt/common/dispatcher/executor-worker.js create mode 100644 test/fixtures/wpt/common/dispatcher/executor.html create mode 100644 test/fixtures/wpt/common/dispatcher/remote-executor.html create mode 100644 test/fixtures/wpt/common/domain-setter.sub.html create mode 100644 test/fixtures/wpt/common/dummy.json create mode 100644 test/fixtures/wpt/common/dummy.xhtml create mode 100644 test/fixtures/wpt/common/dummy.xml create mode 100644 test/fixtures/wpt/common/echo.py create mode 100644 test/fixtures/wpt/common/gc.js create mode 100644 test/fixtures/wpt/common/get-host-info.sub.js create mode 100644 test/fixtures/wpt/common/get-host-info.sub.js.headers create mode 100644 test/fixtures/wpt/common/media.js create mode 100644 test/fixtures/wpt/common/media.js.headers create mode 100644 test/fixtures/wpt/common/object-association.js create mode 100644 test/fixtures/wpt/common/object-association.js.headers create mode 100644 test/fixtures/wpt/common/performance-timeline-utils.js create mode 100644 test/fixtures/wpt/common/performance-timeline-utils.js.headers create mode 100644 test/fixtures/wpt/common/proxy-all.sub.pac create mode 100644 test/fixtures/wpt/common/redirect-opt-in.py create mode 100644 test/fixtures/wpt/common/redirect.py create mode 100644 test/fixtures/wpt/common/refresh.py create mode 100644 test/fixtures/wpt/common/reftest-wait.js create mode 100644 test/fixtures/wpt/common/reftest-wait.js.headers create mode 100644 test/fixtures/wpt/common/rendering-utils.js create mode 100644 test/fixtures/wpt/common/sab.js create mode 100644 test/fixtures/wpt/common/security-features/README.md create mode 100644 test/fixtures/wpt/common/security-features/__init__.py create mode 100644 test/fixtures/wpt/common/security-features/resources/common.sub.js create mode 100644 test/fixtures/wpt/common/security-features/resources/common.sub.js.headers create mode 100644 test/fixtures/wpt/common/security-features/scope/__init__.py create mode 100644 test/fixtures/wpt/common/security-features/scope/document.py create mode 100644 test/fixtures/wpt/common/security-features/scope/template/document.html.template create mode 100644 test/fixtures/wpt/common/security-features/scope/template/worker.js.template create mode 100644 test/fixtures/wpt/common/security-features/scope/util.py create mode 100644 test/fixtures/wpt/common/security-features/scope/worker.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/__init__.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/audio.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/document.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/empty.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/font.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/image.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/referrer.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/script.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/shared-worker.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/static-import.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/stylesheet.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/subresource.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/svg.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/document.html.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/font.css.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/image.css.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/script.js.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/svg.css.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/template/worker.js.template create mode 100644 test/fixtures/wpt/common/security-features/subresource/video.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/worker.py create mode 100644 test/fixtures/wpt/common/security-features/subresource/xhr.py create mode 100644 test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py create mode 100755 test/fixtures/wpt/common/security-features/tools/generate.py create mode 100644 test/fixtures/wpt/common/security-features/tools/spec.src.json create mode 100755 test/fixtures/wpt/common/security-features/tools/spec_validator.py create mode 100644 test/fixtures/wpt/common/security-features/tools/template/disclaimer.template create mode 100644 test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template create mode 100644 test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template create mode 100644 test/fixtures/wpt/common/security-features/tools/template/test.release.html.template create mode 100644 test/fixtures/wpt/common/security-features/tools/util.py create mode 100644 test/fixtures/wpt/common/security-features/types.md create mode 100644 test/fixtures/wpt/common/slow-redirect.py create mode 100644 test/fixtures/wpt/common/slow.py create mode 100644 test/fixtures/wpt/common/square.png create mode 100644 test/fixtures/wpt/common/stringifiers.js create mode 100644 test/fixtures/wpt/common/stringifiers.js.headers create mode 100644 test/fixtures/wpt/common/subset-tests-by-key.js create mode 100644 test/fixtures/wpt/common/subset-tests.js create mode 100644 test/fixtures/wpt/common/test-setting-immutable-prototype.js create mode 100644 test/fixtures/wpt/common/test-setting-immutable-prototype.js.headers create mode 100644 test/fixtures/wpt/common/text-plain.txt create mode 100644 test/fixtures/wpt/common/third_party/reftest-analyzer.xhtml create mode 100644 test/fixtures/wpt/common/top-layer.js create mode 100644 test/fixtures/wpt/common/utils.js create mode 100644 test/fixtures/wpt/common/utils.js.headers create mode 100644 test/fixtures/wpt/common/window-name-setter.html create mode 100644 test/fixtures/wpt/common/worklet-reftest.js create mode 100644 test/fixtures/wpt/common/worklet-reftest.js.headers create mode 100644 test/fixtures/wpt/eventsource/META.yml create mode 100644 test/fixtures/wpt/eventsource/README.md create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-close.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-close.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-close2.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-close2.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-constructor-no-new.any.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-constructor-non-same-origin.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-constructor-url-bogus.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-eventtarget.worker.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-onmesage.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-onmessage.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-onopen.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-onopen.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-prototype.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-prototype.js create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-url.htm create mode 100644 test/fixtures/wpt/eventsource/dedicated-worker/eventsource-url.js create mode 100644 test/fixtures/wpt/eventsource/event-data.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-close.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-constructor-document-domain.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-constructor-empty-url.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-constructor-non-same-origin.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-constructor-stringify.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-constructor-url-bogus.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-constructor-url-multi-window.htm create mode 100644 test/fixtures/wpt/eventsource/eventsource-cross-origin.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-eventtarget.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-onmessage-realm.htm create mode 100644 test/fixtures/wpt/eventsource/eventsource-onmessage-trusted.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-onmessage.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-onopen.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-prototype.any.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-reconnect.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-request-cancellation.window.js create mode 100644 test/fixtures/wpt/eventsource/eventsource-url.any.js create mode 100644 test/fixtures/wpt/eventsource/format-bom-2.any.js create mode 100644 test/fixtures/wpt/eventsource/format-bom.any.js create mode 100644 test/fixtures/wpt/eventsource/format-comments.any.js create mode 100644 test/fixtures/wpt/eventsource/format-data-before-final-empty-line.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-data.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-event-empty.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-event.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-id-2.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-id-3.window.js create mode 100644 test/fixtures/wpt/eventsource/format-field-id-null.window.js create mode 100644 test/fixtures/wpt/eventsource/format-field-id.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-parsing.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-retry-bogus.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-retry-empty.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-retry.any.js create mode 100644 test/fixtures/wpt/eventsource/format-field-unknown.any.js create mode 100644 test/fixtures/wpt/eventsource/format-leading-space.any.js create mode 100644 test/fixtures/wpt/eventsource/format-mime-bogus.any.js create mode 100644 test/fixtures/wpt/eventsource/format-mime-trailing-semicolon.any.js create mode 100644 test/fixtures/wpt/eventsource/format-mime-valid-bogus.any.js create mode 100644 test/fixtures/wpt/eventsource/format-newlines.any.js create mode 100644 test/fixtures/wpt/eventsource/format-null-character.any.js create mode 100644 test/fixtures/wpt/eventsource/format-utf-8.any.js create mode 100644 test/fixtures/wpt/eventsource/request-accept.any.js create mode 100644 test/fixtures/wpt/eventsource/request-cache-control.any.js create mode 100644 test/fixtures/wpt/eventsource/request-credentials.window.js create mode 100644 test/fixtures/wpt/eventsource/request-redirect.window.js create mode 100644 test/fixtures/wpt/eventsource/request-status-error.window.js create mode 100644 test/fixtures/wpt/eventsource/resources/accept.event_stream create mode 100644 test/fixtures/wpt/eventsource/resources/cache-control.event_stream create mode 100644 test/fixtures/wpt/eventsource/resources/cors-cookie.py create mode 100644 test/fixtures/wpt/eventsource/resources/cors.py create mode 100644 test/fixtures/wpt/eventsource/resources/eventsource-onmessage-realm.htm create mode 100644 test/fixtures/wpt/eventsource/resources/init.htm create mode 100644 test/fixtures/wpt/eventsource/resources/last-event-id.py create mode 100644 test/fixtures/wpt/eventsource/resources/last-event-id2.py create mode 100644 test/fixtures/wpt/eventsource/resources/message.py create mode 100644 test/fixtures/wpt/eventsource/resources/message2.py create mode 100644 test/fixtures/wpt/eventsource/resources/reconnect-fail.py create mode 100644 test/fixtures/wpt/eventsource/resources/status-error.py create mode 100644 test/fixtures/wpt/eventsource/resources/status-reconnect.py create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-close.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-close.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-url-bogus.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-onmesage.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-onmessage.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.js create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-url.htm create mode 100644 test/fixtures/wpt/eventsource/shared-worker/eventsource-url.js create mode 100644 test/fixtures/wpt/fetch/META.yml create mode 100644 test/fixtures/wpt/fetch/README.md create mode 100644 test/fixtures/wpt/fetch/api/abort/cache.https.any.js create mode 100644 test/fixtures/wpt/fetch/api/abort/destroyed-context.html create mode 100644 test/fixtures/wpt/fetch/api/abort/general.any.js create mode 100644 test/fixtures/wpt/fetch/api/abort/keepalive.html create mode 100644 test/fixtures/wpt/fetch/api/abort/request.any.js create mode 100644 test/fixtures/wpt/fetch/api/abort/serviceworker-intercepted.https.html create mode 100644 test/fixtures/wpt/fetch/api/basic/accept-header.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/block-mime-as-script.html create mode 100644 test/fixtures/wpt/fetch/api/basic/conditional-get.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/error-after-response.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/header-value-combining.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/header-value-null-byte.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/historical.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/http-response-code.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/integrity.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/keepalive.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/mediasource.window.js create mode 100644 test/fixtures/wpt/fetch/api/basic/mode-no-cors.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/mode-same-origin.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/referrer.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-forbidden-headers.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-head.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-headers-case.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-headers-nonascii.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-headers.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-private-network-headers.tentative.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-referrer-redirected-worker.html create mode 100644 test/fixtures/wpt/fetch/api/basic/request-referrer.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-upload.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/request-upload.h2.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/response-null-body.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/response-url.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/scheme-about.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/scheme-blob.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/scheme-data.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/scheme-others.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/status.h2.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/stream-response.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/stream-safe-creation.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/text-utf8.any.js create mode 100644 test/fixtures/wpt/fetch/api/basic/url-parsing.sub.html create mode 100644 test/fixtures/wpt/fetch/api/body/cloned-any.js create mode 100644 test/fixtures/wpt/fetch/api/body/formdata.any.js create mode 100644 test/fixtures/wpt/fetch/api/body/mime-type.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-basic.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-cookies-redirect.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-cookies.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-expose-star.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-filtering.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-keepalive.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-multiple-origins.sub.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-no-preflight.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-origin.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-cache.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-redirect.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-referrer.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-response-validation.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-star.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight-status.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-preflight.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-redirect-credentials.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-redirect-preflight.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/cors-redirect.any.js create mode 100644 test/fixtures/wpt/fetch/api/cors/data-url-iframe.html create mode 100644 test/fixtures/wpt/fetch/api/cors/data-url-shared-worker.html create mode 100644 test/fixtures/wpt/fetch/api/cors/data-url-worker.html create mode 100644 test/fixtures/wpt/fetch/api/cors/resources/corspreflight.js create mode 100644 test/fixtures/wpt/fetch/api/cors/resources/not-cors-safelisted.json create mode 100644 test/fixtures/wpt/fetch/api/cors/sandboxed-iframe.html create mode 100644 test/fixtures/wpt/fetch/api/crashtests/aborted-fetch-response.https.html create mode 100644 test/fixtures/wpt/fetch/api/crashtests/body-window-destroy.html create mode 100644 test/fixtures/wpt/fetch/api/crashtests/huge-fetch.any.js create mode 100644 test/fixtures/wpt/fetch/api/crashtests/request.html create mode 100644 test/fixtures/wpt/fetch/api/credentials/authentication-basic.any.js create mode 100644 test/fixtures/wpt/fetch/api/credentials/authentication-redirection.any.js create mode 100644 test/fixtures/wpt/fetch/api/credentials/cookies.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/header-setcookie.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/header-values-normalize.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/header-values.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-basic.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-casing.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-combine.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-errors.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-no-cors.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-normalize.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-record.any.js create mode 100644 test/fixtures/wpt/fetch/api/headers/headers-structure.any.js create mode 100644 test/fixtures/wpt/fetch/api/idlharness.any.js create mode 100644 test/fixtures/wpt/fetch/api/policies/csp-blocked-worker.html create mode 100644 test/fixtures/wpt/fetch/api/policies/csp-blocked.html create mode 100644 test/fixtures/wpt/fetch/api/policies/csp-blocked.html.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/csp-blocked.js create mode 100644 test/fixtures/wpt/fetch/api/policies/csp-blocked.js.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/nested-policy.js create mode 100644 test/fixtures/wpt/fetch/api/policies/nested-policy.js.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-service-worker.https.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-worker.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-service-worker.https.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-worker.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin-worker.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin.html.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin.js create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-origin.js.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-service-worker.https.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-worker.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html.headers create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js create mode 100644 test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js.headers create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-back-to-original-origin.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-count.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-empty-location.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.https.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-location-escape.tentative.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-location.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-method.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-mode.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-origin.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-referrer-override.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-referrer.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-schemes.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-to-dataurl.any.js create mode 100644 test/fixtures/wpt/fetch/api/redirect/redirect-upload.h2.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/fetch-destination-frame.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/fetch-destination-iframe.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/fetch-destination-no-load-event.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/fetch-destination-prefetch.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/fetch-destination-worker.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/fetch-destination.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.css create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es.headers create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.json create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.png create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy.ttf create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy_audio.mp3 create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy_audio.oga create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy_video.mp4 create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/dummy_video.webm create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/empty.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-frame.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-css.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-json.js create mode 100644 test/fixtures/wpt/fetch/api/request/destination/resources/importer.js create mode 100644 test/fixtures/wpt/fetch/api/request/forbidden-method.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/multi-globals/construct-in-detached-frame.window.js create mode 100644 test/fixtures/wpt/fetch/api/request/multi-globals/current/current.html create mode 100644 test/fixtures/wpt/fetch/api/request/multi-globals/incumbent/incumbent.html create mode 100644 test/fixtures/wpt/fetch/api/request/multi-globals/url-parsing.html create mode 100644 test/fixtures/wpt/fetch/api/request/request-bad-port.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-default-conditional.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-default.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-force-cache.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-no-cache.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-no-store.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-only-if-cached.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache-reload.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-cache.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-clone.sub.html create mode 100644 test/fixtures/wpt/fetch/api/request/request-constructor-init-body-override.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-consume-empty.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-consume.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-disturbed.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-error.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-error.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-headers.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-init-001.sub.html create mode 100644 test/fixtures/wpt/fetch/api/request/request-init-002.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-init-003.sub.html create mode 100644 test/fixtures/wpt/fetch/api/request/request-init-contenttype.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-init-priority.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-init-stream.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-keepalive-quota.html create mode 100644 test/fixtures/wpt/fetch/api/request/request-keepalive.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/request-reset-attributes.https.html create mode 100644 test/fixtures/wpt/fetch/api/request/request-structure.any.js create mode 100644 test/fixtures/wpt/fetch/api/request/resources/cache.py create mode 100644 test/fixtures/wpt/fetch/api/request/resources/hello.txt create mode 100644 test/fixtures/wpt/fetch/api/request/resources/request-reset-attributes-worker.js create mode 100644 test/fixtures/wpt/fetch/api/request/url-encoding.html create mode 100644 test/fixtures/wpt/fetch/api/resources/authentication.py create mode 100644 test/fixtures/wpt/fetch/api/resources/bad-chunk-encoding.py create mode 100644 test/fixtures/wpt/fetch/api/resources/basic.html create mode 100644 test/fixtures/wpt/fetch/api/resources/cache.py create mode 100644 test/fixtures/wpt/fetch/api/resources/clean-stash.py create mode 100644 test/fixtures/wpt/fetch/api/resources/cors-top.txt create mode 100644 test/fixtures/wpt/fetch/api/resources/cors-top.txt.headers create mode 100644 test/fixtures/wpt/fetch/api/resources/data.json create mode 100644 test/fixtures/wpt/fetch/api/resources/dump-authorization-header.py create mode 100644 test/fixtures/wpt/fetch/api/resources/echo-content.h2.py create mode 100644 test/fixtures/wpt/fetch/api/resources/echo-content.py create mode 100644 test/fixtures/wpt/fetch/api/resources/empty.txt create mode 100644 test/fixtures/wpt/fetch/api/resources/huge-response.py create mode 100644 test/fixtures/wpt/fetch/api/resources/infinite-slow-response.py create mode 100644 test/fixtures/wpt/fetch/api/resources/inspect-headers.py create mode 100644 test/fixtures/wpt/fetch/api/resources/keepalive-helper.js create mode 100644 test/fixtures/wpt/fetch/api/resources/keepalive-iframe.html create mode 100644 test/fixtures/wpt/fetch/api/resources/keepalive-redirect-iframe.html create mode 100644 test/fixtures/wpt/fetch/api/resources/keepalive-redirect-window.html create mode 100644 test/fixtures/wpt/fetch/api/resources/keepalive-worker.js create mode 100644 test/fixtures/wpt/fetch/api/resources/method.py create mode 100644 test/fixtures/wpt/fetch/api/resources/preflight.py create mode 100644 test/fixtures/wpt/fetch/api/resources/redirect-empty-location.py create mode 100644 test/fixtures/wpt/fetch/api/resources/redirect.h2.py create mode 100644 test/fixtures/wpt/fetch/api/resources/redirect.py create mode 100644 test/fixtures/wpt/fetch/api/resources/sandboxed-iframe.html create mode 100644 test/fixtures/wpt/fetch/api/resources/script-with-header.py create mode 100644 test/fixtures/wpt/fetch/api/resources/stash-put.py create mode 100644 test/fixtures/wpt/fetch/api/resources/stash-take.py create mode 100644 test/fixtures/wpt/fetch/api/resources/status.py create mode 100644 test/fixtures/wpt/fetch/api/resources/sw-intercept-abort.js create mode 100644 test/fixtures/wpt/fetch/api/resources/sw-intercept.js create mode 100644 test/fixtures/wpt/fetch/api/resources/top.txt create mode 100644 test/fixtures/wpt/fetch/api/resources/trickle.py create mode 100644 test/fixtures/wpt/fetch/api/resources/utils.js create mode 100644 test/fixtures/wpt/fetch/api/response/json.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/many-empty-chunks-crash.html create mode 100644 test/fixtures/wpt/fetch/api/response/multi-globals/current/current.html create mode 100644 test/fixtures/wpt/fetch/api/response/multi-globals/incumbent/incumbent.html create mode 100644 test/fixtures/wpt/fetch/api/response/multi-globals/relevant/relevant.html create mode 100644 test/fixtures/wpt/fetch/api/response/multi-globals/url-parsing.html create mode 100644 test/fixtures/wpt/fetch/api/response/response-arraybuffer-realm.window.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-blob-realm.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-body-read-task-handling.html create mode 100644 test/fixtures/wpt/fetch/api/response/response-cancel-stream.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-clone-iframe.window.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-clone.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-consume-empty.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-consume-stream.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-consume.html create mode 100644 test/fixtures/wpt/fetch/api/response/response-error-from-stream.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-error.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-from-stream.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-headers-guard.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-init-001.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-init-002.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-init-contenttype.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-static-error.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-static-json.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-static-redirect.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-bad-chunk.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-1.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-2.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-3.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-4.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-5.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-6.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-by-pipe.any.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-disturbed-util.js create mode 100644 test/fixtures/wpt/fetch/api/response/response-stream-with-broken-then.any.js create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cache.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cookies.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-storage.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-decompression.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-element.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-header.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/dictionary-registration.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/resources/clear-site-data.py create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/resources/compressed-data.py create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/resources/compression-dictionary-util.js create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/resources/echo-headers.py create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/resources/empty.html create mode 100644 test/fixtures/wpt/fetch/compression-dictionary/resources/register-dictionary.py create mode 100644 test/fixtures/wpt/fetch/connection-pool/network-partition-key.html create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-about-blank-checker.html create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-checker.html create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-iframe-checker.html create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.js create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.py create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker-checker.html create mode 100644 test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/bad-br-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/big-br-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/br-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/bad-br-body.py create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br create mode 100644 test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/bad-gzip-body.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/big-gzip-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/gzip-body.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/bad-gzip-body.py create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/big.text.gz create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/big.text.gz.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.octetstream.gz create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.octetstream.gz.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.text.gz create mode 100644 test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.text.gz.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/bad-zstd-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/big-window-zstd-body.tentative.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/big-zstd-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/bad-zstd-body.py create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.text.zst create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.text.zst.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.window.zst create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.window.zst.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst.headers create mode 100644 test/fixtures/wpt/fetch/content-encoding/zstd/zstd-body.https.any.js create mode 100644 test/fixtures/wpt/fetch/content-length/api-and-duplicate-headers.any.js create mode 100644 test/fixtures/wpt/fetch/content-length/content-length.html create mode 100644 test/fixtures/wpt/fetch/content-length/content-length.html.headers create mode 100644 test/fixtures/wpt/fetch/content-length/parsing.window.js create mode 100644 test/fixtures/wpt/fetch/content-length/resources/content-length.py create mode 100644 test/fixtures/wpt/fetch/content-length/resources/content-lengths.json create mode 100644 test/fixtures/wpt/fetch/content-length/resources/identical-duplicates.asis create mode 100644 test/fixtures/wpt/fetch/content-length/too-long.window.js create mode 100644 test/fixtures/wpt/fetch/content-type/README.md create mode 100644 test/fixtures/wpt/fetch/content-type/multipart-malformed.any.js create mode 100644 test/fixtures/wpt/fetch/content-type/multipart.window.js create mode 100644 test/fixtures/wpt/fetch/content-type/resources/content-type.py create mode 100644 test/fixtures/wpt/fetch/content-type/resources/content-types.json create mode 100644 test/fixtures/wpt/fetch/content-type/resources/script-content-types.json create mode 100644 test/fixtures/wpt/fetch/content-type/response.window.js create mode 100644 test/fixtures/wpt/fetch/content-type/script.window.js create mode 100644 test/fixtures/wpt/fetch/corb/README.md create mode 100644 test/fixtures/wpt/fetch/corb/img-html-correctly-labeled.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/corb/img-html-correctly-labeled.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-mime-types-coverage.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg-invalid.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg-labeled-as-dash.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg-labeled-as-svg-xml.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg-xml-decl.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/img-svg.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css create mode 100644 test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css create mode 100644 test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/css-with-json-parser-breaker.css create mode 100644 test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png create mode 100644 test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html create mode 100644 test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js create mode 100644 test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js create mode 100644 test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js create mode 100644 test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js create mode 100644 test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png create mode 100644 test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png create mode 100644 test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png create mode 100644 test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/response_block_probe.js create mode 100644 test/fixtures/wpt/fetch/corb/resources/response_block_probe.js.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/sniffable-resource.py create mode 100644 test/fixtures/wpt/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg-xml-decl.svg create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg.svg create mode 100644 test/fixtures/wpt/fetch/corb/resources/svg.svg.headers create mode 100644 test/fixtures/wpt/fetch/corb/response_block.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/script-html-js-polyglot.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/script-html-via-cross-origin-blob-url.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/style-css-with-json-parser-breaker.sub.html create mode 100644 test/fixtures/wpt/fetch/corb/style-html-correctly-labeled.sub.html create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch-in-iframe.html create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.any.js create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.https.any.js create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/iframe-loads.html create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/image-loads.html create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/green.png create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/hello.py create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/iframe.py create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/iframeFetch.html create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/image.py create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/redirect.py create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/script.py create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.any.js create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/script-loads.html create mode 100644 test/fixtures/wpt/fetch/cross-origin-resource-policy/syntax.any.js create mode 100644 test/fixtures/wpt/fetch/data-urls/README.md create mode 100644 test/fixtures/wpt/fetch/data-urls/base64.any.js create mode 100644 test/fixtures/wpt/fetch/data-urls/navigate.window.js create mode 100644 test/fixtures/wpt/fetch/data-urls/processing.any.js create mode 100644 test/fixtures/wpt/fetch/data-urls/resources/base64.json create mode 100644 test/fixtures/wpt/fetch/data-urls/resources/data-urls.json create mode 100644 test/fixtures/wpt/fetch/fetch-later/META.yml create mode 100644 test/fixtures/wpt/fetch/fetch-later/README.md create mode 100644 test/fixtures/wpt/fetch/fetch-later/activate-after.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/basic.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/basic.tentative.https.worker.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-no-referrer-when-downgrade.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-no-referrer.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin-when-cross-origin.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-same-origin.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin-when-cross-origin.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-unsafe-url.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/iframe.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/new-window.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/non-secure.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/README.md create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute-redirect.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js.headers create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-default-permissions-policy.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-supported-by-permissions-policy.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/helper.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/permissions-policy-deferred-fetch.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/policies/csp-allowed.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/policies/csp-blocked.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/policies/csp-redirect-to-blocked.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/quota.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/resources/fetch-later-helper.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/resources/fetch-later.html create mode 100644 test/fixtures/wpt/fetch/fetch-later/resources/get_beacon.py create mode 100644 test/fixtures/wpt/fetch/fetch-later/resources/header-referrer-helper.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/resources/set_beacon.py create mode 100644 test/fixtures/wpt/fetch/fetch-later/send-on-deactivate-with-background-sync.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/send-on-deactivate.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/send-on-discard/not-send-after-abort.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple-with-activate-after.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/h1-parsing/README.md create mode 100644 test/fixtures/wpt/fetch/h1-parsing/lone-cr.window.js create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources-with-0x00-in-header.window.js create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources/README.md create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources/blue-with-0x00-in-a-header.asis create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources/document-with-0x00-in-header.py create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources/message.py create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources/script-with-0x00-in-header.py create mode 100644 test/fixtures/wpt/fetch/h1-parsing/resources/status-code.py create mode 100644 test/fixtures/wpt/fetch/h1-parsing/status-code.window.js create mode 100644 test/fixtures/wpt/fetch/http-cache/304-update.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/README.md create mode 100644 test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test-ref.html create mode 100644 test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test.html create mode 100644 test/fixtures/wpt/fetch/http-cache/cache-mode.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/cc-request.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/credentials.tentative.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/freshness.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/heuristic.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/http-cache.js create mode 100644 test/fixtures/wpt/fetch/http-cache/invalidate.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/partial.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/post-patch.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/resources/http-cache.py create mode 100644 test/fixtures/wpt/fetch/http-cache/resources/securedimage.py create mode 100644 test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup-with-iframe.html create mode 100644 test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup.html create mode 100644 test/fixtures/wpt/fetch/http-cache/split-cache.html create mode 100644 test/fixtures/wpt/fetch/http-cache/status.any.js create mode 100644 test/fixtures/wpt/fetch/http-cache/vary.any.js create mode 100644 test/fixtures/wpt/fetch/images/canvas-remote-read-remote-image-redirect.html create mode 100644 test/fixtures/wpt/fetch/metadata/META.yml create mode 100644 test/fixtures/wpt/fetch/metadata/README.md create mode 100644 test/fixtures/wpt/fetch/metadata/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/fetch/metadata/audio-worklet.https.html create mode 100644 test/fixtures/wpt/fetch/metadata/embed.https.sub.tentative.html create mode 100644 test/fixtures/wpt/fetch/metadata/fetch-preflight.https.sub.any.js create mode 100644 test/fixtures/wpt/fetch/metadata/fetch.https.sub.any.js create mode 100644 test/fixtures/wpt/fetch/metadata/generated/audioworklet.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/css-font-face.https.sub.tentative.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/css-font-face.sub.tentative.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/css-images.https.sub.tentative.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/css-images.sub.tentative.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-a.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-a.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-area.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-area.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-audio.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-audio.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-embed.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-embed.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-frame.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-frame.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-iframe.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-iframe.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-img.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-img.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-input-image.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-input-image.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-link-icon.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-link-icon.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-picture.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-picture.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-script.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-script.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-video-poster.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-video-poster.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-video.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/element-video.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/fetch.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/fetch.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/form-submission.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/form-submission.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.tentative.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/header-link.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/header-refresh.https.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/header-refresh.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/serviceworker.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/svg-image.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/svg-image.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/window-history.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/window-history.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/window-location.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/window-location.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/navigation.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/object.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/paint-worklet.https.html create mode 100644 test/fixtures/wpt/fetch/metadata/preload.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/redirect/redirect-http-upgrade.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/redirect/redirect-https-downgrade.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/report.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/report.https.sub.html.sub.headers create mode 100644 test/fixtures/wpt/fetch/metadata/resources/appcache-iframe.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/resources/dedicatedWorker.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/echo-as-json.py create mode 100644 test/fixtures/wpt/fetch/metadata/resources/echo-as-script.py create mode 100644 test/fixtures/wpt/fetch/metadata/resources/es-json-module.sub.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/es-module.sub.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker-frame.html create mode 100644 test/fixtures/wpt/fetch/metadata/resources/header-link.py create mode 100644 test/fixtures/wpt/fetch/metadata/resources/helper.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/helper.sub.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/message-opener.html create mode 100644 test/fixtures/wpt/fetch/metadata/resources/post-to-owner.py create mode 100644 test/fixtures/wpt/fetch/metadata/resources/record-header.py create mode 100644 test/fixtures/wpt/fetch/metadata/resources/record-headers.py create mode 100644 test/fixtures/wpt/fetch/metadata/resources/redirectTestHelper.sub.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors-frame.html create mode 100644 test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors.sw.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/sharedWorker.js create mode 100644 test/fixtures/wpt/fetch/metadata/resources/unload-with-beacon.html create mode 100644 test/fixtures/wpt/fetch/metadata/resources/xslt-test.sub.xml create mode 100644 test/fixtures/wpt/fetch/metadata/serviceworker-accessors.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/sharedworker.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/style.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/README.md create mode 100644 test/fixtures/wpt/fetch/metadata/tools/fetch-metadata.conf.yml create mode 100755 test/fixtures/wpt/fetch/metadata/tools/generate.py create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/audioworklet.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/css-font-face.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/css-images.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-a.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-area.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-audio.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-embed.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-frame.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-iframe.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-img-environment-change.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-img.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-input-image.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-link-icon.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-picture.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-script.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-video-poster.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/element-video.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/fetch.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/form-submission.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/header-link.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/header-refresh.optional.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/script-json-module-import-static.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-static.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/serviceworker.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/svg-image.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/window-history.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/window-location.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/track.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/trailing-dot.https.sub.any.js create mode 100644 test/fixtures/wpt/fetch/metadata/unload.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/window-open.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/worker.https.sub.html create mode 100644 test/fixtures/wpt/fetch/metadata/xslt.https.sub.html create mode 100644 test/fixtures/wpt/fetch/nosniff/image.html create mode 100644 test/fixtures/wpt/fetch/nosniff/importscripts.html create mode 100644 test/fixtures/wpt/fetch/nosniff/importscripts.js create mode 100644 test/fixtures/wpt/fetch/nosniff/parsing-nosniff.window.js create mode 100644 test/fixtures/wpt/fetch/nosniff/resources/css.py create mode 100644 test/fixtures/wpt/fetch/nosniff/resources/image.py create mode 100644 test/fixtures/wpt/fetch/nosniff/resources/js.py create mode 100644 test/fixtures/wpt/fetch/nosniff/resources/nosniff.py create mode 100644 test/fixtures/wpt/fetch/nosniff/resources/worker.py create mode 100644 test/fixtures/wpt/fetch/nosniff/resources/x-content-type-options.json create mode 100644 test/fixtures/wpt/fetch/nosniff/script.html create mode 100644 test/fixtures/wpt/fetch/nosniff/stylesheet.html create mode 100644 test/fixtures/wpt/fetch/nosniff/worker.html create mode 100644 test/fixtures/wpt/fetch/orb/resources/data.json create mode 100644 test/fixtures/wpt/fetch/orb/resources/data_non_ascii.json create mode 100644 test/fixtures/wpt/fetch/orb/resources/empty.json create mode 100644 test/fixtures/wpt/fetch/orb/resources/font.ttf create mode 100644 test/fixtures/wpt/fetch/orb/resources/image.png create mode 100644 test/fixtures/wpt/fetch/orb/resources/js-unlabeled-utf16-without-bom.json create mode 100644 test/fixtures/wpt/fetch/orb/resources/js-unlabeled.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png create mode 100644 test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png.headers create mode 100644 test/fixtures/wpt/fetch/orb/resources/png-unlabeled.png create mode 100644 test/fixtures/wpt/fetch/orb/resources/script-asm-js-invalid.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/script-asm-js-valid.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/script-iso-8559-1.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/script-utf16-bom.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/script-utf16-without-bom.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/script.js create mode 100644 test/fixtures/wpt/fetch/orb/resources/sound.mp3 create mode 100644 test/fixtures/wpt/fetch/orb/resources/text.txt create mode 100644 test/fixtures/wpt/fetch/orb/resources/utils.js create mode 100644 test/fixtures/wpt/fetch/orb/tentative/compressed-image-sniffing.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/content-range.sub.any.js create mode 100644 test/fixtures/wpt/fetch/orb/tentative/img-mime-types-coverage.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/img-png-mislabeled-as-html.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/img-png-mislabeled-as-html.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/img-png-unlabeled.sub-ref.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/img-png-unlabeled.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/known-mime-type.sub.any.js create mode 100644 test/fixtures/wpt/fetch/orb/tentative/nosniff.sub.any.js create mode 100644 test/fixtures/wpt/fetch/orb/tentative/script-js-unlabeled-gziped.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/script-unlabeled.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/script-utf16-without-bom-hint-charset.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/status.sub.any.js create mode 100644 test/fixtures/wpt/fetch/orb/tentative/status.sub.html create mode 100644 test/fixtures/wpt/fetch/orb/tentative/unknown-mime-type.sub.any.js create mode 100644 test/fixtures/wpt/fetch/origin/assorted.window.js create mode 100644 test/fixtures/wpt/fetch/origin/resources/redirect-and-stash.py create mode 100644 test/fixtures/wpt/fetch/origin/resources/referrer-policy.py create mode 100644 test/fixtures/wpt/fetch/private-network-access/META.yml create mode 100644 test/fixtures/wpt/fetch/private-network-access/README.md create mode 100644 test/fixtures/wpt/fetch/private-network-access/anchor.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/anchor.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/fenced-frame-no-preflight-required.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/fenced-frame-subresource-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/fenced-frame.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/fetch-from-treat-as-public.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/fetch.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/iframe.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/iframe.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/mixed-content-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/nested-worker.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/nested-worker.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/preflight-cache.https.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/redirect.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/anchor.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/executor.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fenced-frame-fetcher.https.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fenced-frame-fetcher.https.html.headers create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fenced-frame-private-network-access-target.https.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fenced-frame-private-network-access.https.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fenced-frame-private-network-access.https.html.headers create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fetcher.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/fetcher.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/iframed-no-preflight-received.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/iframed.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/iframer.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/no-preflight-received.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/open-to-existing-window.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/openee.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/opener.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/preflight.py create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/service-worker-bridge.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/service-worker-fetch-all.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/service-worker.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/shared-fetcher.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/shared-worker-blob-fetcher.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/shared-worker-fetcher.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/socket-opener.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/support.sub.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/worker-blob-fetcher.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/worker-fetcher.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/worker-fetcher.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/resources/xhr-sender.html create mode 100644 test/fixtures/wpt/fetch/private-network-access/service-worker-background-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/service-worker-fetch-document-treat-as-public.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/service-worker-fetch-document.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/service-worker-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/service-worker-update.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/service-worker.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/shared-worker-blob-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/shared-worker-blob-fetch.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/shared-worker-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/shared-worker-fetch.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/shared-worker.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/shared-worker.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/websocket.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/websocket.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/window-open-existing.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/window-open-existing.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/window-open.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/window-open.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/worker-blob-fetch.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/worker-fetch.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/worker-fetch.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/worker.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/worker.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/xhr-from-treat-as-public.tentative.https.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/xhr.https.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/private-network-access/xhr.tentative.window.js create mode 100644 test/fixtures/wpt/fetch/range/blob.any.js create mode 100644 test/fixtures/wpt/fetch/range/data.any.js create mode 100644 test/fixtures/wpt/fetch/range/general.any.js create mode 100644 test/fixtures/wpt/fetch/range/general.window.js create mode 100644 test/fixtures/wpt/fetch/range/non-matching-range-response.html create mode 100644 test/fixtures/wpt/fetch/range/resources/basic.html create mode 100644 test/fixtures/wpt/fetch/range/resources/long-wav.py create mode 100644 test/fixtures/wpt/fetch/range/resources/partial-script.py create mode 100644 test/fixtures/wpt/fetch/range/resources/partial-text.py create mode 100644 test/fixtures/wpt/fetch/range/resources/range-sw.js create mode 100644 test/fixtures/wpt/fetch/range/resources/stash-take.py create mode 100644 test/fixtures/wpt/fetch/range/resources/utils.js create mode 100644 test/fixtures/wpt/fetch/range/resources/video-with-range.py create mode 100644 test/fixtures/wpt/fetch/range/sw.https.window.js create mode 100644 test/fixtures/wpt/fetch/redirect-navigate/302-found-post-handler.py create mode 100644 test/fixtures/wpt/fetch/redirect-navigate/302-found-post.html create mode 100644 test/fixtures/wpt/fetch/redirect-navigate/preserve-fragment.html create mode 100644 test/fixtures/wpt/fetch/redirect-navigate/resources/destination.html create mode 100644 test/fixtures/wpt/fetch/redirects/data.window.js create mode 100644 test/fixtures/wpt/fetch/redirects/subresource-fragments.html create mode 100644 test/fixtures/wpt/fetch/security/1xx-response.any.js create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/dangling-markup-mitigation-allowed-apis.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/dangling-markup-mitigation-data-url.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/dangling-markup-mitigation.tentative.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/dangling-markup-mitigation.tentative.https.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/media.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/option.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/resources/empty.html create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/resources/helper.js create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/service-worker.js create mode 100644 test/fixtures/wpt/fetch/security/dangling-markup/textarea.html create mode 100644 test/fixtures/wpt/fetch/security/embedded-credentials.tentative.sub.html create mode 100644 test/fixtures/wpt/fetch/security/redirect-to-url-with-credentials.https.html create mode 100644 test/fixtures/wpt/fetch/security/support/embedded-credential-window.sub.html create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/fetch-sw.https.html create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/fetch.any.js create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/resources/stale-css.py create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/resources/stale-image.py create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/resources/stale-script.py create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/revalidate-not-blocked-by-csp.html create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/stale-css.html create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/stale-image.html create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/stale-script.html create mode 100644 test/fixtures/wpt/fetch/stale-while-revalidate/sw-intercept.js create mode 100644 test/fixtures/wpt/interfaces/ANGLE_instanced_arrays.idl create mode 100644 test/fixtures/wpt/interfaces/CSP.idl create mode 100644 test/fixtures/wpt/interfaces/DOM-Parsing.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_blend_minmax.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_color_buffer_float.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_color_buffer_half_float.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_disjoint_timer_query.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_disjoint_timer_query_webgl2.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_float_blend.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_frag_depth.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_sRGB.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_shader_texture_lod.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_texture_compression_bptc.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_texture_compression_rgtc.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_texture_filter_anisotropic.idl create mode 100644 test/fixtures/wpt/interfaces/EXT_texture_norm16.idl create mode 100644 test/fixtures/wpt/interfaces/FileAPI.idl create mode 100644 test/fixtures/wpt/interfaces/IndexedDB.idl create mode 100644 test/fixtures/wpt/interfaces/KHR_parallel_shader_compile.idl create mode 100644 test/fixtures/wpt/interfaces/META.yml create mode 100644 test/fixtures/wpt/interfaces/OES_draw_buffers_indexed.idl create mode 100644 test/fixtures/wpt/interfaces/OES_element_index_uint.idl create mode 100644 test/fixtures/wpt/interfaces/OES_fbo_render_mipmap.idl create mode 100644 test/fixtures/wpt/interfaces/OES_standard_derivatives.idl create mode 100644 test/fixtures/wpt/interfaces/OES_texture_float.idl create mode 100644 test/fixtures/wpt/interfaces/OES_texture_float_linear.idl create mode 100644 test/fixtures/wpt/interfaces/OES_texture_half_float.idl create mode 100644 test/fixtures/wpt/interfaces/OES_texture_half_float_linear.idl create mode 100644 test/fixtures/wpt/interfaces/OES_vertex_array_object.idl create mode 100644 test/fixtures/wpt/interfaces/OVR_multiview2.idl create mode 100644 test/fixtures/wpt/interfaces/README.md create mode 100644 test/fixtures/wpt/interfaces/SVG.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_blend_equation_advanced_coherent.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_clip_cull_distance.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_color_buffer_float.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_compressed_texture_astc.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_compressed_texture_etc.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_compressed_texture_etc1.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_compressed_texture_pvrtc.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_compressed_texture_s3tc.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_compressed_texture_s3tc_srgb.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_debug_renderer_info.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_debug_shaders.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_depth_texture.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_draw_buffers.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_draw_instanced_base_vertex_base_instance.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_lose_context.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_multi_draw.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_multi_draw_instanced_base_vertex_base_instance.idl create mode 100644 test/fixtures/wpt/interfaces/WEBGL_provoking_vertex.idl create mode 100644 test/fixtures/wpt/interfaces/WebCryptoAPI.idl create mode 100644 test/fixtures/wpt/interfaces/accelerometer.idl create mode 100644 test/fixtures/wpt/interfaces/ambient-light.idl create mode 100644 test/fixtures/wpt/interfaces/anchors.idl create mode 100644 test/fixtures/wpt/interfaces/anonymous-iframe.idl create mode 100644 test/fixtures/wpt/interfaces/attribution-reporting-api.idl create mode 100644 test/fixtures/wpt/interfaces/audio-output.idl create mode 100644 test/fixtures/wpt/interfaces/audio-session.idl create mode 100644 test/fixtures/wpt/interfaces/autoplay-detection.idl create mode 100644 test/fixtures/wpt/interfaces/background-fetch.idl create mode 100644 test/fixtures/wpt/interfaces/background-sync.idl create mode 100644 test/fixtures/wpt/interfaces/badging.idl create mode 100644 test/fixtures/wpt/interfaces/battery-status.idl create mode 100644 test/fixtures/wpt/interfaces/beacon.idl create mode 100644 test/fixtures/wpt/interfaces/capture-handle-identity.idl create mode 100644 test/fixtures/wpt/interfaces/captured-mouse-events.idl create mode 100644 test/fixtures/wpt/interfaces/captured-mouse-events.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/clipboard-apis.idl create mode 100644 test/fixtures/wpt/interfaces/command-and-commandfor.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/compat.idl create mode 100644 test/fixtures/wpt/interfaces/compression.idl create mode 100644 test/fixtures/wpt/interfaces/compute-pressure.idl create mode 100644 test/fixtures/wpt/interfaces/console.idl create mode 100644 test/fixtures/wpt/interfaces/contact-picker.idl create mode 100644 test/fixtures/wpt/interfaces/content-index.idl create mode 100644 test/fixtures/wpt/interfaces/cookie-store.idl create mode 100644 test/fixtures/wpt/interfaces/credential-management.idl create mode 100644 test/fixtures/wpt/interfaces/csp-embedded-enforcement.idl create mode 100644 test/fixtures/wpt/interfaces/csp-next.idl create mode 100644 test/fixtures/wpt/interfaces/css-anchor-position.idl create mode 100644 test/fixtures/wpt/interfaces/css-animation-worklet.idl create mode 100644 test/fixtures/wpt/interfaces/css-animations-2.idl create mode 100644 test/fixtures/wpt/interfaces/css-animations.idl create mode 100644 test/fixtures/wpt/interfaces/css-cascade-6.idl create mode 100644 test/fixtures/wpt/interfaces/css-cascade.idl create mode 100644 test/fixtures/wpt/interfaces/css-color-5.idl create mode 100644 test/fixtures/wpt/interfaces/css-conditional-5.idl create mode 100644 test/fixtures/wpt/interfaces/css-conditional.idl create mode 100644 test/fixtures/wpt/interfaces/css-contain.idl create mode 100644 test/fixtures/wpt/interfaces/css-counter-styles.idl create mode 100644 test/fixtures/wpt/interfaces/css-font-loading.idl create mode 100644 test/fixtures/wpt/interfaces/css-fonts.idl create mode 100644 test/fixtures/wpt/interfaces/css-highlight-api.idl create mode 100644 test/fixtures/wpt/interfaces/css-images-4.idl create mode 100644 test/fixtures/wpt/interfaces/css-layout-api.idl create mode 100644 test/fixtures/wpt/interfaces/css-masking.idl create mode 100644 test/fixtures/wpt/interfaces/css-mixins.idl create mode 100644 test/fixtures/wpt/interfaces/css-nav.idl create mode 100644 test/fixtures/wpt/interfaces/css-nesting.idl create mode 100644 test/fixtures/wpt/interfaces/css-paint-api.idl create mode 100644 test/fixtures/wpt/interfaces/css-parser-api.idl create mode 100644 test/fixtures/wpt/interfaces/css-properties-values-api.idl create mode 100644 test/fixtures/wpt/interfaces/css-pseudo.idl create mode 100644 test/fixtures/wpt/interfaces/css-regions.idl create mode 100644 test/fixtures/wpt/interfaces/css-scroll-snap-2.idl create mode 100644 test/fixtures/wpt/interfaces/css-shadow-parts.idl create mode 100644 test/fixtures/wpt/interfaces/css-transitions-2.idl create mode 100644 test/fixtures/wpt/interfaces/css-transitions.idl create mode 100644 test/fixtures/wpt/interfaces/css-typed-om.idl create mode 100644 test/fixtures/wpt/interfaces/css-view-transitions-2.idl create mode 100644 test/fixtures/wpt/interfaces/css-view-transitions.idl create mode 100644 test/fixtures/wpt/interfaces/css-viewport.idl create mode 100644 test/fixtures/wpt/interfaces/cssom-view.idl create mode 100644 test/fixtures/wpt/interfaces/cssom.idl create mode 100644 test/fixtures/wpt/interfaces/datacue.idl create mode 100644 test/fixtures/wpt/interfaces/deprecation-reporting.idl create mode 100644 test/fixtures/wpt/interfaces/device-attributes.idl create mode 100644 test/fixtures/wpt/interfaces/device-memory.idl create mode 100644 test/fixtures/wpt/interfaces/device-posture.idl create mode 100644 test/fixtures/wpt/interfaces/digital-credentials.idl create mode 100644 test/fixtures/wpt/interfaces/digital-goods.idl create mode 100644 test/fixtures/wpt/interfaces/document-picture-in-picture.idl create mode 100644 test/fixtures/wpt/interfaces/dom.idl create mode 100644 test/fixtures/wpt/interfaces/edit-context.idl create mode 100644 test/fixtures/wpt/interfaces/element-capture.idl create mode 100644 test/fixtures/wpt/interfaces/element-timing.idl create mode 100644 test/fixtures/wpt/interfaces/encoding.idl create mode 100644 test/fixtures/wpt/interfaces/encrypted-media.idl create mode 100644 test/fixtures/wpt/interfaces/entries-api.idl create mode 100644 test/fixtures/wpt/interfaces/event-timing.idl create mode 100644 test/fixtures/wpt/interfaces/eyedropper-api.idl create mode 100644 test/fixtures/wpt/interfaces/fedcm.idl create mode 100644 test/fixtures/wpt/interfaces/fenced-frame.idl create mode 100644 test/fixtures/wpt/interfaces/fetch.idl create mode 100644 test/fixtures/wpt/interfaces/fido.idl create mode 100644 test/fixtures/wpt/interfaces/file-system-access.idl create mode 100644 test/fixtures/wpt/interfaces/filter-effects.idl create mode 100644 test/fixtures/wpt/interfaces/font-metrics-api.idl create mode 100644 test/fixtures/wpt/interfaces/fs.idl create mode 100644 test/fixtures/wpt/interfaces/fullscreen.idl create mode 100644 test/fixtures/wpt/interfaces/gamepad-extensions.idl create mode 100644 test/fixtures/wpt/interfaces/gamepad.idl create mode 100644 test/fixtures/wpt/interfaces/generic-sensor.idl create mode 100644 test/fixtures/wpt/interfaces/geolocation-sensor.idl create mode 100644 test/fixtures/wpt/interfaces/geolocation.idl create mode 100644 test/fixtures/wpt/interfaces/geometry.idl create mode 100644 test/fixtures/wpt/interfaces/get-installed-related-apps.idl create mode 100644 test/fixtures/wpt/interfaces/gpc.idl create mode 100644 test/fixtures/wpt/interfaces/gyroscope.idl create mode 100644 test/fixtures/wpt/interfaces/handwriting-recognition.idl create mode 100644 test/fixtures/wpt/interfaces/hr-time.idl create mode 100644 test/fixtures/wpt/interfaces/html-media-capture.idl create mode 100644 test/fixtures/wpt/interfaces/html.idl create mode 100644 test/fixtures/wpt/interfaces/idle-detection.idl create mode 100644 test/fixtures/wpt/interfaces/image-capture.idl create mode 100644 test/fixtures/wpt/interfaces/image-resource.idl create mode 100644 test/fixtures/wpt/interfaces/ink-enhancement.idl create mode 100644 test/fixtures/wpt/interfaces/input-device-capabilities.idl create mode 100644 test/fixtures/wpt/interfaces/input-events.idl create mode 100644 test/fixtures/wpt/interfaces/interest-invokers.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/intersection-observer.idl create mode 100644 test/fixtures/wpt/interfaces/intervention-reporting.idl create mode 100644 test/fixtures/wpt/interfaces/is-input-pending.idl create mode 100644 test/fixtures/wpt/interfaces/js-self-profiling.idl create mode 100644 test/fixtures/wpt/interfaces/keyboard-lock.idl create mode 100644 test/fixtures/wpt/interfaces/keyboard-map.idl create mode 100644 test/fixtures/wpt/interfaces/largest-contentful-paint.idl create mode 100644 test/fixtures/wpt/interfaces/layout-instability.idl create mode 100644 test/fixtures/wpt/interfaces/local-font-access.idl create mode 100644 test/fixtures/wpt/interfaces/login-status.idl create mode 100644 test/fixtures/wpt/interfaces/long-animation-frames.idl create mode 100644 test/fixtures/wpt/interfaces/longtasks.idl create mode 100644 test/fixtures/wpt/interfaces/magnetometer.idl create mode 100644 test/fixtures/wpt/interfaces/managed-configuration.idl create mode 100644 test/fixtures/wpt/interfaces/manifest-incubations.idl create mode 100644 test/fixtures/wpt/interfaces/mathml-core.idl create mode 100644 test/fixtures/wpt/interfaces/media-capabilities.idl create mode 100644 test/fixtures/wpt/interfaces/media-playback-quality.idl create mode 100644 test/fixtures/wpt/interfaces/media-source.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-automation.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-fromelement.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-handle-actions.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-region.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-streams.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-surface-control.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-transform.idl create mode 100644 test/fixtures/wpt/interfaces/mediacapture-viewport.idl create mode 100644 test/fixtures/wpt/interfaces/mediaqueries-5.idl create mode 100644 test/fixtures/wpt/interfaces/mediasession.idl create mode 100644 test/fixtures/wpt/interfaces/mediastream-recording.idl create mode 100644 test/fixtures/wpt/interfaces/model-element.idl create mode 100644 test/fixtures/wpt/interfaces/mst-content-hint.idl create mode 100644 test/fixtures/wpt/interfaces/navigation-timing.idl create mode 100644 test/fixtures/wpt/interfaces/netinfo.idl create mode 100644 test/fixtures/wpt/interfaces/notifications.idl create mode 100644 test/fixtures/wpt/interfaces/observable.idl create mode 100644 test/fixtures/wpt/interfaces/observable.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/orientation-event.idl create mode 100644 test/fixtures/wpt/interfaces/orientation-sensor.idl create mode 100644 test/fixtures/wpt/interfaces/page-lifecycle.idl create mode 100644 test/fixtures/wpt/interfaces/paint-timing.idl create mode 100644 test/fixtures/wpt/interfaces/parakeet.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/payment-handler.idl create mode 100644 test/fixtures/wpt/interfaces/payment-request.idl create mode 100644 test/fixtures/wpt/interfaces/performance-measure-memory.idl create mode 100644 test/fixtures/wpt/interfaces/performance-timeline.idl create mode 100644 test/fixtures/wpt/interfaces/periodic-background-sync.idl create mode 100644 test/fixtures/wpt/interfaces/permissions-policy.idl create mode 100644 test/fixtures/wpt/interfaces/permissions-request.idl create mode 100644 test/fixtures/wpt/interfaces/permissions-revoke.idl create mode 100644 test/fixtures/wpt/interfaces/permissions.idl create mode 100644 test/fixtures/wpt/interfaces/picture-in-picture.idl create mode 100644 test/fixtures/wpt/interfaces/pointerevents.idl create mode 100644 test/fixtures/wpt/interfaces/pointerlock.idl create mode 100644 test/fixtures/wpt/interfaces/portals.idl create mode 100644 test/fixtures/wpt/interfaces/prefer-current-tab.idl create mode 100644 test/fixtures/wpt/interfaces/prerendering-revamped.idl create mode 100644 test/fixtures/wpt/interfaces/presentation-api.idl create mode 100644 test/fixtures/wpt/interfaces/private-aggregation-api.idl create mode 100644 test/fixtures/wpt/interfaces/private-click-measurement.idl create mode 100644 test/fixtures/wpt/interfaces/private-network-access.idl create mode 100644 test/fixtures/wpt/interfaces/proximity.idl create mode 100644 test/fixtures/wpt/interfaces/push-api.idl create mode 100644 test/fixtures/wpt/interfaces/raw-camera-access.idl create mode 100644 test/fixtures/wpt/interfaces/real-world-meshing.idl create mode 100644 test/fixtures/wpt/interfaces/referrer-policy.idl create mode 100644 test/fixtures/wpt/interfaces/remote-playback.idl create mode 100644 test/fixtures/wpt/interfaces/reporting.idl create mode 100644 test/fixtures/wpt/interfaces/requestStorageAccessFor.idl create mode 100644 test/fixtures/wpt/interfaces/requestidlecallback.idl create mode 100644 test/fixtures/wpt/interfaces/resize-observer.idl create mode 100644 test/fixtures/wpt/interfaces/resource-timing.idl create mode 100644 test/fixtures/wpt/interfaces/saa-non-cookie-storage.idl create mode 100644 test/fixtures/wpt/interfaces/sanitizer-api.idl create mode 100644 test/fixtures/wpt/interfaces/sanitizer-api.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/savedata.idl create mode 100644 test/fixtures/wpt/interfaces/scheduling-apis.idl create mode 100644 test/fixtures/wpt/interfaces/screen-capture.idl create mode 100644 test/fixtures/wpt/interfaces/screen-orientation.idl create mode 100644 test/fixtures/wpt/interfaces/screen-wake-lock.idl create mode 100644 test/fixtures/wpt/interfaces/scroll-animations.idl create mode 100644 test/fixtures/wpt/interfaces/scroll-to-text-fragment.idl create mode 100644 test/fixtures/wpt/interfaces/secure-payment-confirmation.idl create mode 100644 test/fixtures/wpt/interfaces/selection-api.idl create mode 100644 test/fixtures/wpt/interfaces/serial.idl create mode 100644 test/fixtures/wpt/interfaces/server-timing.idl create mode 100644 test/fixtures/wpt/interfaces/service-workers.idl create mode 100644 test/fixtures/wpt/interfaces/shape-detection-api.idl create mode 100644 test/fixtures/wpt/interfaces/shared-storage.idl create mode 100644 test/fixtures/wpt/interfaces/speech-api.idl create mode 100644 test/fixtures/wpt/interfaces/storage-access.idl create mode 100644 test/fixtures/wpt/interfaces/storage-buckets.idl create mode 100644 test/fixtures/wpt/interfaces/storage.idl create mode 100644 test/fixtures/wpt/interfaces/streams.idl create mode 100644 test/fixtures/wpt/interfaces/svg-animations.idl create mode 100644 test/fixtures/wpt/interfaces/testutils.idl create mode 100644 test/fixtures/wpt/interfaces/text-detection-api.idl create mode 100644 test/fixtures/wpt/interfaces/touch-events.idl create mode 100644 test/fixtures/wpt/interfaces/trust-token-api.idl create mode 100644 test/fixtures/wpt/interfaces/trusted-types.idl create mode 100644 test/fixtures/wpt/interfaces/turtledove.idl create mode 100644 test/fixtures/wpt/interfaces/ua-client-hints.idl create mode 100644 test/fixtures/wpt/interfaces/uievents.idl create mode 100644 test/fixtures/wpt/interfaces/url.idl create mode 100644 test/fixtures/wpt/interfaces/urlpattern.idl create mode 100644 test/fixtures/wpt/interfaces/user-timing.idl create mode 100644 test/fixtures/wpt/interfaces/vibration.idl create mode 100644 test/fixtures/wpt/interfaces/video-rvfc.idl create mode 100644 test/fixtures/wpt/interfaces/virtual-keyboard.idl create mode 100644 test/fixtures/wpt/interfaces/virtual-keyboard.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/wai-aria.idl create mode 100644 test/fixtures/wpt/interfaces/wasm-js-api.idl create mode 100644 test/fixtures/wpt/interfaces/wasm-web-api.idl create mode 100644 test/fixtures/wpt/interfaces/web-animations-2.idl create mode 100644 test/fixtures/wpt/interfaces/web-animations.idl create mode 100644 test/fixtures/wpt/interfaces/web-app-launch.idl create mode 100644 test/fixtures/wpt/interfaces/web-bluetooth-scanning.idl create mode 100644 test/fixtures/wpt/interfaces/web-bluetooth.idl create mode 100644 test/fixtures/wpt/interfaces/web-locks.idl create mode 100644 test/fixtures/wpt/interfaces/web-nfc.idl create mode 100644 test/fixtures/wpt/interfaces/web-otp.idl create mode 100644 test/fixtures/wpt/interfaces/web-share.idl create mode 100644 test/fixtures/wpt/interfaces/webaudio.idl create mode 100644 test/fixtures/wpt/interfaces/webauthn.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-aac-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-av1-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-avc-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-flac-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-hevc-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-opus-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs-vp9-codec-registration.idl create mode 100644 test/fixtures/wpt/interfaces/webcodecs.idl create mode 100644 test/fixtures/wpt/interfaces/webcrypto-secure-curves.idl create mode 100644 test/fixtures/wpt/interfaces/webdriver.idl create mode 100644 test/fixtures/wpt/interfaces/webgl1.idl create mode 100644 test/fixtures/wpt/interfaces/webgl2.idl create mode 100644 test/fixtures/wpt/interfaces/webgpu.idl create mode 100644 test/fixtures/wpt/interfaces/webhid.idl create mode 100644 test/fixtures/wpt/interfaces/webidl.idl create mode 100644 test/fixtures/wpt/interfaces/webmidi.idl create mode 100644 test/fixtures/wpt/interfaces/webnn.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc-encoded-transform.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc-ice.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc-identity.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc-priority.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc-stats.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc-svc.idl create mode 100644 test/fixtures/wpt/interfaces/webrtc.idl create mode 100644 test/fixtures/wpt/interfaces/websockets.idl create mode 100644 test/fixtures/wpt/interfaces/webtransport.idl create mode 100644 test/fixtures/wpt/interfaces/webusb.idl create mode 100644 test/fixtures/wpt/interfaces/webvr.tentative.idl create mode 100644 test/fixtures/wpt/interfaces/webvtt.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-ar-module.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-depth-sensing.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-dom-overlays.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-gamepads-module.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-hand-input.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-hit-test.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-lighting-estimation.idl create mode 100644 test/fixtures/wpt/interfaces/webxr-plane-detection.idl create mode 100644 test/fixtures/wpt/interfaces/webxr.idl create mode 100644 test/fixtures/wpt/interfaces/webxrlayers.idl create mode 100644 test/fixtures/wpt/interfaces/window-controls-overlay.idl create mode 100644 test/fixtures/wpt/interfaces/window-management.idl create mode 100644 test/fixtures/wpt/interfaces/xhr.idl create mode 100644 test/fixtures/wpt/mimesniff/META.yml create mode 100644 test/fixtures/wpt/mimesniff/README.md create mode 100644 test/fixtures/wpt/mimesniff/media/media-sniff.window.js create mode 100644 test/fixtures/wpt/mimesniff/media/resources/flac.flac create mode 100755 test/fixtures/wpt/mimesniff/media/resources/make-vectors.sh create mode 100644 test/fixtures/wpt/mimesniff/media/resources/mp3-raw.mp3 create mode 100644 test/fixtures/wpt/mimesniff/media/resources/mp3-with-id3.mp3 create mode 100644 test/fixtures/wpt/mimesniff/media/resources/mp4.mp4 create mode 100644 test/fixtures/wpt/mimesniff/media/resources/ogg.ogg create mode 100644 test/fixtures/wpt/mimesniff/media/resources/wav.wav create mode 100644 test/fixtures/wpt/mimesniff/media/resources/webm.webm create mode 100644 test/fixtures/wpt/mimesniff/mime-types/README.md create mode 100644 test/fixtures/wpt/mimesniff/mime-types/charset-parameter.window.js create mode 100644 test/fixtures/wpt/mimesniff/mime-types/parsing.any.js create mode 100644 test/fixtures/wpt/mimesniff/mime-types/resources/generated-mime-types.json create mode 100644 test/fixtures/wpt/mimesniff/mime-types/resources/generated-mime-types.py create mode 100644 test/fixtures/wpt/mimesniff/mime-types/resources/mime-charset.py create mode 100644 test/fixtures/wpt/mimesniff/mime-types/resources/mime-groups.json create mode 100644 test/fixtures/wpt/mimesniff/mime-types/resources/mime-types-minimized.json create mode 100644 test/fixtures/wpt/mimesniff/mime-types/resources/mime-types.json create mode 100644 test/fixtures/wpt/mimesniff/sniffing/html.window.js create mode 100644 test/fixtures/wpt/mimesniff/sniffing/support/atom.html create mode 100644 test/fixtures/wpt/mimesniff/sniffing/support/rss.html create mode 100644 test/fixtures/wpt/resources/.htaccess create mode 100644 test/fixtures/wpt/resources/META.yml create mode 100644 test/fixtures/wpt/resources/SVGAnimationTestCase-testharness.js create mode 100644 test/fixtures/wpt/resources/accesskey.js create mode 100644 test/fixtures/wpt/resources/blank.html create mode 100644 test/fixtures/wpt/resources/channel.sub.js create mode 100644 test/fixtures/wpt/resources/check-layout-th.js create mode 100644 test/fixtures/wpt/resources/chromium/README.md create mode 100644 test/fixtures/wpt/resources/chromium/contacts_manager_mock.js create mode 100644 test/fixtures/wpt/resources/chromium/content-index-helpers.js create mode 100644 test/fixtures/wpt/resources/chromium/enable-hyperlink-auditing.js create mode 100644 test/fixtures/wpt/resources/chromium/fake-hid.js create mode 100644 test/fixtures/wpt/resources/chromium/fake-serial.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-barcodedetection.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-barcodedetection.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/mock-battery-monitor.headers create mode 100644 test/fixtures/wpt/resources/chromium/mock-battery-monitor.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-facedetection.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-facedetection.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/mock-idle-detection.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-imagecapture.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-managed-config.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-subapps.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-textdetection.js create mode 100644 test/fixtures/wpt/resources/chromium/mock-textdetection.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/nfc-mock.js create mode 100644 test/fixtures/wpt/resources/chromium/web-bluetooth-test.js create mode 100644 test/fixtures/wpt/resources/chromium/web-bluetooth-test.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/webusb-child-test.js create mode 100644 test/fixtures/wpt/resources/chromium/webusb-child-test.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/webusb-test.js create mode 100644 test/fixtures/wpt/resources/chromium/webusb-test.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/webxr-test-math-helper.js create mode 100644 test/fixtures/wpt/resources/chromium/webxr-test-math-helper.js.headers create mode 100644 test/fixtures/wpt/resources/chromium/webxr-test.js create mode 100644 test/fixtures/wpt/resources/chromium/webxr-test.js.headers create mode 100644 test/fixtures/wpt/resources/idlharness.js create mode 100644 test/fixtures/wpt/resources/idlharness.js.headers create mode 100644 test/fixtures/wpt/resources/out-of-scope-test.js create mode 100644 test/fixtures/wpt/resources/readme.md create mode 100644 test/fixtures/wpt/resources/sriharness.js create mode 100644 test/fixtures/wpt/resources/test-only-api.js create mode 100644 test/fixtures/wpt/resources/test-only-api.js.headers create mode 100644 test/fixtures/wpt/resources/test-only-api.m.js create mode 100644 test/fixtures/wpt/resources/test-only-api.m.js.headers create mode 100644 test/fixtures/wpt/resources/test/README.md create mode 100644 test/fixtures/wpt/resources/test/conftest.py create mode 100644 test/fixtures/wpt/resources/test/harness.html create mode 100644 test/fixtures/wpt/resources/test/idl-helper.js create mode 100644 test/fixtures/wpt/resources/test/nested-testharness.js create mode 100644 test/fixtures/wpt/resources/test/requirements.txt create mode 100644 test/fixtures/wpt/resources/test/tests/functional/abortsignal.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_async.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_async_bad_return.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_async_rejection.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_async_rejection_after_load.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_async_timeout.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_bad_return.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_count.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_err.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_err_multi.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/add_cleanup_sync_queue.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/api-tests-1.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/api-tests-2.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/api-tests-3.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/assert-array-equals.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/assert-throws-dom.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/force_timeout.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/generate-callback.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlDictionary/test_partial_interface_of.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlInterface/test_exposed_wildcard.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlInterface/test_immutable_prototype.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlInterface/test_interface_mixin.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlInterface/test_partial_interface_of.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlInterface/test_primary_interface_of.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlInterface/test_to_json_operation.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlNamespace/test_attribute.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlNamespace/test_operation.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/idlharness/IdlNamespace/test_partial_namespace.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/iframe-callback.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/iframe-consolidate-errors.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/iframe-consolidate-tests.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/iframe-msg.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/log-insertion.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/no-title.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/order.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/promise-async.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/promise-with-sync.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/promise.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/queue.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/setup-function-worker.js create mode 100644 test/fixtures/wpt/resources/test/tests/functional/setup-worker-service.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/single-page-test-fail.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/single-page-test-no-assertions.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/single-page-test-no-body.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/single-page-test-pass.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/step_wait.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/step_wait_func.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/task-scheduling-promise-test.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/task-scheduling-test.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/uncaught-exception-handle.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/uncaught-exception-ignore.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-dedicated-uncaught-allow.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-dedicated-uncaught-single.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-dedicated.sub.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-error.js create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-service.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-shared.html create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-uncaught-allow.js create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker-uncaught-single.js create mode 100644 test/fixtures/wpt/resources/test/tests/functional/worker.js create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlArray/is_json_type.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlDictionary/get_reverse_inheritance_stack.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlDictionary/test_partial_dictionary.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/constructors.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/default_to_json_operation.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/do_member_unscopable_asserts.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/get_interface_object.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/get_interface_object_owner.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/get_legacy_namespace.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/get_qualified_name.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/get_reverse_inheritance_stack.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/has_default_to_json_regular_operation.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/has_to_json_regular_operation.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/should_have_interface_object.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterface/test_primary_interface_of_undefined.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterfaceMember/is_to_json_regular_operation.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/IdlInterfaceMember/toString.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/assert_implements.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/assert_implements_optional.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/assert_object_equals.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/async-test-return-restrictions.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/basic.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/exceptional-cases-timeouts.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/exceptional-cases.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/format-value.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/helpers.js create mode 100644 test/fixtures/wpt/resources/test/tests/unit/late-test.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/promise_setup-timeout.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/promise_setup.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/single_test.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/test-return-restrictions.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/throwing-assertions.html create mode 100644 test/fixtures/wpt/resources/test/tests/unit/unpaired-surrogates.html create mode 100644 test/fixtures/wpt/resources/test/tox.ini create mode 100644 test/fixtures/wpt/resources/test/wptserver.py create mode 100644 test/fixtures/wpt/resources/testdriver-actions.js create mode 100644 test/fixtures/wpt/resources/testdriver-vendor.js create mode 100644 test/fixtures/wpt/resources/testdriver-vendor.js.headers create mode 100644 test/fixtures/wpt/resources/testdriver.js create mode 100644 test/fixtures/wpt/resources/testdriver.js.headers create mode 100644 test/fixtures/wpt/resources/testharness-shadowrealm-audioworkletprocessor.js create mode 100644 test/fixtures/wpt/resources/testharness-shadowrealm-inner.js create mode 100644 test/fixtures/wpt/resources/testharness-shadowrealm-outer.js create mode 100644 test/fixtures/wpt/resources/testharness.js create mode 100644 test/fixtures/wpt/resources/testharness.js.headers create mode 100644 test/fixtures/wpt/resources/testharnessreport.js create mode 100644 test/fixtures/wpt/resources/testharnessreport.js.headers create mode 100755 test/fixtures/wpt/resources/webidl2/build.sh create mode 100644 test/fixtures/wpt/resources/webidl2/lib/README.md create mode 100644 test/fixtures/wpt/resources/webidl2/lib/VERSION.md create mode 100644 test/fixtures/wpt/resources/webidl2/lib/webidl2.js create mode 100644 test/fixtures/wpt/resources/webidl2/lib/webidl2.js.headers create mode 100644 test/fixtures/wpt/service-workers/META.yml create mode 100644 test/fixtures/wpt/service-workers/cache-storage/META.yml create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-abort.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-add.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-delete.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-keys-attributes-for-service-worker.https.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-keys.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-match.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-matchAll.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-put.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-storage-buckets.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-storage-keys.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-storage-match.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cache-storage.https.any.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/common.https.window.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/crashtests/cache-response-clone.https.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/credentials.https.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/cross-partition.https.tentative.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/blank.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/cache-keys-attributes-for-service-worker.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/common-worker.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/credentials-iframe.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/credentials-worker.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/fetch-status.py create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/iframe.html create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/simple.txt create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/test-helpers.js create mode 100644 test/fixtures/wpt/service-workers/cache-storage/resources/vary.py create mode 100644 test/fixtures/wpt/service-workers/cache-storage/sandboxed-iframes.https.html create mode 100644 test/fixtures/wpt/service-workers/idlharness.https.any.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/Service-Worker-Allowed-header.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/close.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/error-message-event-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/error-message-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/extendable-message-event-constructor.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/extendable-message-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/fetch-on-the-right-interface.https.any.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/isSecureContext.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/isSecureContext.serviceworker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/message-event-ports-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/message-event-ports.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/postmessage.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/registration-attribute.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/close-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-constructor-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-loopback-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-ping-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-pong-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-utils.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/extendable-message-event-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/postmessage-loopback-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/postmessage-ping-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/postmessage-pong-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/registration-attribute-newer-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/registration-attribute-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/unregister-controlling-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/unregister-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/update-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/resources/update-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/service-worker-error-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/unregister.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ServiceWorkerGlobalScope/update.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/service-workers/service-worker/about-blank-replacement.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/activate-event-after-install-state-change.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/activation-after-registration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/activation.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/active.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-affect-other-registration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-fetch.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-not-using-registration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-shared-worker-fetch.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-using-registration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-with-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/claim-worker-fetch.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/client-id.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/client-navigate.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/client-url-of-blob-url-worker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-get-client-types.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-get-cross-origin.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-get-resultingClientId.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-get.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-blob-url-worker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-client-types.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-exact-controller.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-frozen.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-include-uncontrolled.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-on-evaluation.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall-order.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/clients-matchall.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/controlled-dedicatedworker-postMessage.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/controlled-iframe-postMessage.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/controller-on-disconnect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/controller-on-load.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/controller-on-reload.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/controller-with-no-fetch-event-handler.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/credentials.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/data-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/data-transfer-files.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/dedicated-worker-service-worker-interception.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/detached-context.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/embed-and-object-are-not-intercepted.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/extendable-event-async-waituntil.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/extendable-event-waituntil.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-audio-tainting.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-canvas-tainting-double-write.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-canvas-tainting-image-cache.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-canvas-tainting-image.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-canvas-tainting-video-cache.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-canvas-tainting-video-with-range-request.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-canvas-tainting-video.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-cors-exposed-header-names.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-cors-xhr.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-csp.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-error.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-add-async.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-after-navigation-within-page.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-async-respond-with.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-handled.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-is-history-backward-navigation-manual.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-is-history-forward-navigation-manual.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-is-reload-iframe-navigation-manual.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-is-reload-navigation-manual.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-network-error.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-referrer-policy.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-argument.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-body-loaded-in-chunk.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-custom-response.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-partial-stream.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-readable-stream-chunk.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-readable-stream.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-response-body-with-invalid-chunk.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-respond-with-stops-propagation.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-throws-after-respond-with.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-within-sw-manual.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event-within-sw.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event.https.h2.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-frame-resource.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-header-visibility.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-mixed-content-to-inscope.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-mixed-content-to-outscope.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-css-base-url.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-css-cross-origin.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-css-images.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-fallback.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-no-freshness-headers.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-resources.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-xhr-sync-error.https.window.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-xhr-sync-on-worker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-xhr-sync.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-request-xhr.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-response-taint.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-response-xhr.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-waits-for-activate.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/fetch-with-body.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/getregistration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/getregistrations.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/global-serviceworker.https.any.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/historical.https.any.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/http-to-https-redirect-and-register.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/immutable-prototype-serviceworker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/import-scripts-cross-origin.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/import-scripts-data-url.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/import-scripts-mime-types.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/import-scripts-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/import-scripts-resource-map.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/import-scripts-updated-flag.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/indexeddb.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/install-event-type.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/installing.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/interface-requirements-sw.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/invalid-blobtype.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/invalid-header.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/iso-latin1-header.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/local-url-inherit-controller.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/mime-sniffing.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/current/current.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/current/test-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/incumbent/incumbent.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/incumbent/test-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/relevant/relevant.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/relevant/test-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/test-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/multi-globals/url-parsing.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multipart-image.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multiple-register.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/multiple-update.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigate-window.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-headers.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/broken-chunked-encoding.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/chunked-encoding.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/empty-preload-response-body.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/get-state.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/navigationPreload.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/request-headers.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resource-timing.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/broken-chunked-encoding-scope.asis create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/broken-chunked-encoding-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/chunked-encoding-scope.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/chunked-encoding-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/cookie.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/empty-preload-response-body-scope.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/empty-preload-response-body-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/get-state-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/helpers.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/navigation-preload-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/redirect-redirected.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/redirect-scope.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/redirect-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/request-headers-scope.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/request-headers-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/resource-timing-scope.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/resource-timing-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/samesite-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/samesite-sw-helper.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/samesite-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/resources/wait-for-activate-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/samesite-cookies.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-preload/samesite-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-redirect-body.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-redirect-resolution.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-redirect-to-http.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-sets-cookie.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-timing-extended.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-timing-sizes.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/navigation-timing.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/nested-blob-url-workers.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/next-hop-protocol.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/no-dynamic-import-in-module.any.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/no-dynamic-import.any.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/onactivate-script-error.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/oninstall-script-error.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/opaque-response-preloaded.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/opaque-script.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/partitioned-claim.tentative.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/partitioned-cookies.tentative.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/partitioned-getRegistrations.tentative.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/partitioned-matchAll.tentative.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/partitioned.tentative.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/performance-timeline.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/postMessage-client-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/postmessage-blob-url.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/postmessage-from-waiting-serviceworker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/postmessage-msgport-to-client.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/postmessage-to-client-message-queue.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/postmessage-to-client.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/postmessage.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/ready.https.window.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/redirected-response.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/referer.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/referrer-policy-header.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/referrer-toplevel-script-fetch.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/register-closed-window.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/register-default-scope.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/register-same-scope-different-script-url.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/register-wait-forever-in-install-worker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-basic.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-end-to-end.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-events.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-mime-types.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-schedule-job.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-scope-module-static-import.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-scope.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-script-module.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-script-url.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-script.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-security-error.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-service-worker-attributes.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/registration-updateviacache.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/rejections.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/request-end-to-end.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resource-timing-bodySize.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resource-timing-cross-origin.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resource-timing-fetch-variants.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resource-timing.sub.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/404.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-blank-dynamic-nested-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-blank-nested-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-frame.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-ping-frame.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-popup-frame.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-srcdoc-nested-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-uncontrolled-nested-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/about-blank-replacement-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/basic-module-2.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/basic-module.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/blank.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/bytecheck-worker-imported-script.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/bytecheck-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-blob-url-worker-fetch-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-nested-worker-fetch-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-nested-worker-fetch-parent-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-shared-worker-fetch-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-shared-worker-fetch-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-with-redirect-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-worker-fetch-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-worker-fetch-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/claim-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/classic-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/client-id-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/client-navigate-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/client-navigate-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/client-navigated-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/client-url-of-blob-url-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/client-url-of-blob-url-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-frame-freeze.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-client-types-frame-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-client-types-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-client-types-shared-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-client-types-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-cross-origin-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-other-origin.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-resultingClientId-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-get-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-matchall-blob-url-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-matchall-client-types-dedicated-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-matchall-client-types-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-matchall-client-types-shared-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-matchall-on-evaluation-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/clients-matchall-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/controlled-frame-postMessage.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/controlled-worker-late-postMessage.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/controlled-worker-postMessage.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/cors-approved.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/cors-approved.txt.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/cors-denied.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/create-blob-url-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/create-out-of-scope-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/direct.css create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/direct.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/direct.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/direct.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/direct.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/echo-content.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/echo-cookie-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/echo-message-to-source-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/embed-and-object-are-not-intercepted-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/embed-image-is-not-intercepted-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/embed-is-not-intercepted-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/embed-navigation-is-not-intercepted-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/embedded-content-from-server.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/embedded-content-from-service-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/empty-but-slow-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/empty-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/empty.h2.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/empty.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/empty.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/enable-client-message-queue.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/end-to-end-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/events-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/extendable-event-async-waituntil.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/extendable-event-waituntil.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fail-on-fetch-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-access-control-login.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-access-control.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-canvas-tainting-double-write-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-canvas-tainting-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-canvas-tainting-tests.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-cors-exposed-header-names-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-cors-xhr-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-csp-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-csp-iframe.html.sub.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-add-async-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-after-navigation-within-page-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-async-respond-with-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-handled-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-network-error-controllee-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-network-error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-network-fallback-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-argument-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-argument-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-body-loaded-in-chunk-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-custom-response-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-partial-stream-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-readable-stream-chunk-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-readable-stream-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-response-body-with-invalid-chunk-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-response-body-with-invalid-chunk-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-respond-with-stops-propagation-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-test-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-event-within-sw-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-header-visibility-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-mixed-content-iframe-inscope-to-inscope.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-mixed-content-iframe-inscope-to-outscope.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-mixed-content-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-base-url-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-base-url-style.css create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-base-url-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-cross.css create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-cross.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-same.css create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-mime-check-same.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-read-contents.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-css-cross-origin-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-fallback-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-fallback-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-html-imports-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-html-imports-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-no-freshness-headers-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-no-freshness-headers-script.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-no-freshness-headers-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-redirect-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-resources-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-resources-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-xhr-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-xhr-sync-error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-xhr-sync-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-xhr-sync-on-worker-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-xhr-sync-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-request-xhr-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-response-taint-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-response-xhr-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-response-xhr-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-response.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-response.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-rewrite-worker-referrer-policy.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-rewrite-worker-referrer-policy.js.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-rewrite-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-rewrite-worker.js.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-variants-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-waits-for-activate-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-with-body-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/fetch-with-body-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/form-poster.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/frame-for-getregistrations.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/get-resultingClientId-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/http-to-https-redirect-and-register-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/iframe-with-fetch-variants.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/iframe-with-image.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/immutable-prototype-serviceworker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-echo-cookie-worker-module.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-echo-cookie-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-mime-type-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-relative.xsl create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-404-after-update-plus-update-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-404-after-update.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-404.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-cross-origin-worker.sub.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-data-url-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-diff-resource-map-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-echo.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-get.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-mime-types-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-redirect-import.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-redirect-on-second-time-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-redirect-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-resource-map-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-updated-flag-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/import-scripts-version.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/imported-classic-script.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/imported-module-script.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/imported-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/indexeddb-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/install-event-type-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/install-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/interface-requirements-worker.sub.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/invalid-blobtype-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/invalid-blobtype-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/invalid-chunked-encoding-with-flush.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/invalid-chunked-encoding.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/invalid-header-iframe.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/invalid-header-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/iso-latin1-header-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/iso-latin1-header-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/load_worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/loaded.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/local-url-inherit-controller-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/local-url-inherit-controller-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/location-setter.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/malformed-http-response.asis create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/malformed-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/message-vs-microtask.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/mime-sniffing-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/mime-type-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/mint-new-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/missing.asis create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/module-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/multipart-image-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/multipart-image-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/multipart-image.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigate-window-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-headers-server.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-body-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-body.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-other-origin.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-out-scope.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-scope1.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-scope2.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-to-http-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-redirect-to-http-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-timing-worker-extended.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/navigation-timing-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/nested-blob-url-worker-created-from-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/nested-blob-url-workers.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/nested-iframe-parent.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/nested-parent.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/nested-worker-created-from-blob-url-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/nested_load_worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/no-dynamic-import.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/notification_icon.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/object-image-is-not-intercepted-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/object-is-not-intercepted-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/object-navigation-is-not-intercepted-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onactivate-throw-error-from-nested-event-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onactivate-throw-error-then-cancel-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onactivate-throw-error-then-prevent-default-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onactivate-throw-error-with-empty-onerror-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onactivate-throw-error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onactivate-waituntil-forever.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onfetch-waituntil-forever.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-throw-error-from-nested-event-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-throw-error-then-cancel-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-throw-error-then-prevent-default-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-throw-error-with-empty-onerror-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-throw-error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-waituntil-forever.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/oninstall-waituntil-throw-error-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/onparse-infiniteloop-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-response-being-preloaded-xhr.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-response-preloaded-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-response-preloaded-xhr.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-script-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-script-large.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-script-small.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/opaque-script-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/or-test/direct1.text create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/or-test/direct1.text.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/or-test/direct2.text create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/or-test/direct2.text.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/other.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/override_assert_object_equals.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-cookies-3p-credentialless-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-cookies-3p-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-cookies-3p-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-cookies-3p-window.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-cookies-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-cookies-test-helpers.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-iframe-claim.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-nested-iframe-child.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-nested-iframe-parent.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-third-party-iframe-getRegistrations.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-third-party-iframe-matchAll.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-third-party-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-service-worker-third-party-window.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-storage-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/partitioned-utils.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/pass-through-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/pass.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/performance-timeline-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-blob-url.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-dictionary-transferables-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-echo-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-fetched-text.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-msgport-to-client-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-on-load-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-to-client-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-transferables-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/postmessage-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/range-request-to-different-origins-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/range-request-with-different-cors-modes-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/range-request-with-synth-head-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/redirect-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/redirect.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/referer-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/referrer-policy-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/register-closed-window-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/register-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/register-rewrite-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/registration-tests-mime-types.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/registration-tests-scope.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/registration-tests-script-url.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/registration-tests-script.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/registration-tests-security-error.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/registration-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/reject-install-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/reply-to-message.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/request-end-to-end-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/request-headers.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/resource-timing-iframe.sub.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/resource-timing-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/respond-then-throw-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/respond-with-body-accessed-response-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/respond-with-body-accessed-response-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/respond-with-body-accessed-response.jsonp create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/router-rules.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sample-worker-interceptor.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sample.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sample.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sample.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sandboxed-iframe-fetch-event-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sandboxed-iframe-fetch-event-iframe.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sandboxed-iframe-fetch-event-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/sandboxed-iframe-navigator-serviceworker-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope1/module-worker-importing-redirect-to-scope2.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope1/module-worker-importing-scope2.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope1/redirect.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope2/import-scripts-echo.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope2/imported-module-script.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope2/simple.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/scope2/worker_interception_redirect_webworker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/secure-context-service-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/secure-context/sender.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/secure-context/window.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/service-worker-csp-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/service-worker-header.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/service-worker-interception-dynamic-import-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/service-worker-interception-network-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/service-worker-interception-service-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/service-worker-interception-static-import-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/shadowrealm-promise-rejection-test-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/silence.oga create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/simple-intercept-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/simple-intercept-worker.js.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/simple-test-for-condition-main-resource.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/simple.csv create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/simple.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/simple.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/skip-waiting-installed-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/skip-waiting-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/square.png create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/square.png.sub.headers create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/stalling-service-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/static-router-helpers.sub.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/static-router-no-fetch-handler-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/static-router-race-network-and-fetch-handler-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/static-router-sw.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/static-router-sw.sub.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/subdir/blank.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/subdir/import-scripts-echo.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/subdir/simple.txt create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/subdir/worker_interception_redirect_webworker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/success.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/svg-target-reftest-001-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/svg-target-reftest-001.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/svg-target-reftest-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/test-helpers.sub.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/test-request-headers-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/test-request-headers-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/test-request-mode-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/test-request-mode-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/testharness-helpers.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/trickle.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/type-check-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/unregister-controller-page.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/unregister-immediately-helpers.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/unregister-rewrite-worker.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-claim-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-during-installation-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-during-installation-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-fetch-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-max-aged-worker-imported-script.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-max-aged-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-missing-import-scripts-imported-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-missing-import-scripts-main-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-nocookie-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-recovery-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-registration-with-type.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-smaller-body-after-update-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-smaller-body-before-update-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-worker-from-file.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update-worker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update/update-after-oneday.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/update_shell.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/vtt-frame.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/wait-forever-in-install-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/websocket-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/websocket.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/window-opener.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/windowclient-navigate-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker-client-id-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker-fetching-cross-origin.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker-interception-redirect-serviceworker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker-interception-redirect-webworker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker-load-interceptor.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker-testharness.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/worker_interception_redirect_webworker.py create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/xhr-content-length-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/xhr-iframe.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/xhr-response-url-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/xsl-base-url-iframe.xml create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/xsl-base-url-worker.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/resources/xslt-pass.xsl create mode 100644 test/fixtures/wpt/service-workers/service-worker/respond-with-body-accessed-response.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/same-site-cookies.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/sandboxed-iframe-fetch-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/sandboxed-iframe-navigator-serviceworker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/secure-context.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/service-worker-csp-connect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/service-worker-csp-default.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/service-worker-csp-script.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/service-worker-header.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/serviceworker-message-event-historical.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/serviceworkerobject-scripturl.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/shadowrealm-promise-rejection.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/skip-waiting-installed.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/skip-waiting-using-registration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/skip-waiting-without-client.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/skip-waiting-without-using-registration.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/skip-waiting.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/state.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-fetch-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-invalid-rules.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-main-resource.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-multiple-router-registrations.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-mutiple-conditions.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-no-fetch-handler.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-race-network-and-fetch-handler.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-request-destination.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-request-method.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/static-router-subresource.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/svg-target-reftest.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/synced-state.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/tentative/static-router/README.md create mode 100644 test/fixtures/wpt/service-workers/service-worker/tentative/static-router/static-router-resource-timing.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/uncontrolled-page.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister-controller.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister-immediately-before-installed.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister-immediately-during-extendable-events.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister-immediately.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister-then-register-new-script.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister-then-register.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/unregister.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-after-navigation-fetch-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-after-navigation-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-after-oneday.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-bytecheck-cors-import.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-bytecheck.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-import-scripts.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-missing-import-scripts.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-module-request-mode.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-no-cache-request-headers.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-not-allowed.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-on-navigation.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-recovery.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-registration-with-type.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update-result.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/update.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/waiting.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/websocket-in-service-worker.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/websocket.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/webvtt-cross-origin.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/windowclient-navigate.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/worker-client-id.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/worker-in-sandboxed-iframe-by-csp-fetch-event.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/worker-interception-redirect.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/worker-interception.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/xhr-content-length.https.window.js create mode 100644 test/fixtures/wpt/service-workers/service-worker/xhr-response-url.https.html create mode 100644 test/fixtures/wpt/service-workers/service-worker/xsl-base-url.https.html create mode 100644 test/fixtures/wpt/storage/META.yml create mode 100644 test/fixtures/wpt/storage/README.md create mode 100644 test/fixtures/wpt/storage/buckets/META.yml create mode 100644 test/fixtures/wpt/storage/buckets/WEB_FEATURES.yml create mode 100644 test/fixtures/wpt/storage/buckets/bucket-quota-indexeddb.tentative.https.any.js create mode 100644 test/fixtures/wpt/storage/buckets/bucket-storage-policy.tentative.https.any.js create mode 100644 test/fixtures/wpt/storage/buckets/bucket_names.tentative.https.any.js create mode 100644 test/fixtures/wpt/storage/buckets/buckets_basic.tentative.https.any.js create mode 100644 test/fixtures/wpt/storage/buckets/buckets_storage_policy.tentative.https.any.js create mode 100644 test/fixtures/wpt/storage/buckets/detached-iframe.https.html create mode 100644 test/fixtures/wpt/storage/buckets/idlharness-worker.https.any.js create mode 100644 test/fixtures/wpt/storage/buckets/opaque-origin.https.window.js create mode 100644 test/fixtures/wpt/storage/buckets/resources/cached-resource.txt create mode 100644 test/fixtures/wpt/storage/buckets/resources/opaque-origin-sandbox.html create mode 100644 test/fixtures/wpt/storage/buckets/resources/util.js create mode 100644 test/fixtures/wpt/storage/buckets/storage_bucket_object.tentative.https.any.js create mode 100644 test/fixtures/wpt/storage/estimate-indexeddb.https.any.js create mode 100644 test/fixtures/wpt/storage/estimate-parallel.https.any.js create mode 100644 test/fixtures/wpt/storage/estimate-usage-details-caches.https.tentative.any.js create mode 100644 test/fixtures/wpt/storage/estimate-usage-details-indexeddb.https.tentative.any.js create mode 100644 test/fixtures/wpt/storage/estimate-usage-details-service-workers.https.tentative.window.js create mode 100644 test/fixtures/wpt/storage/estimate-usage-details.https.tentative.any.js create mode 100644 test/fixtures/wpt/storage/helpers.js create mode 100644 test/fixtures/wpt/storage/idlharness.https.any.js create mode 100644 test/fixtures/wpt/storage/opaque-origin.https.window.js create mode 100644 test/fixtures/wpt/storage/partitioned-estimate-usage-details-caches.tentative.https.sub.html create mode 100644 test/fixtures/wpt/storage/partitioned-estimate-usage-details-indexeddb.tentative.https.sub.html create mode 100644 test/fixtures/wpt/storage/partitioned-estimate-usage-details-service-workers.tentative.https.sub.html create mode 100644 test/fixtures/wpt/storage/permission-query.https.any.js create mode 100644 test/fixtures/wpt/storage/persist-permission-manual.https.html create mode 100644 test/fixtures/wpt/storage/persisted.https.any.js create mode 100644 test/fixtures/wpt/storage/resources/helpers.js create mode 100644 test/fixtures/wpt/storage/resources/partitioned-estimate-usage-details-caches-helper-frame.html create mode 100644 test/fixtures/wpt/storage/resources/partitioned-estimate-usage-details-indexeddb-helper-frame.html create mode 100644 test/fixtures/wpt/storage/resources/partitioned-estimate-usage-details-service-workers-helper-frame.html create mode 100644 test/fixtures/wpt/storage/resources/worker.js create mode 100644 test/fixtures/wpt/storage/storagemanager-estimate.https.any.js create mode 100644 test/fixtures/wpt/storage/storagemanager-persist-persisted-match.https.window.js create mode 100644 test/fixtures/wpt/storage/storagemanager-persist.https.window.js create mode 100644 test/fixtures/wpt/storage/storagemanager-persist.https.worker.js create mode 100644 test/fixtures/wpt/storage/storagemanager-persisted.https.any.js create mode 100644 test/fixtures/wpt/websockets/Close-1000-reason.any.js create mode 100644 test/fixtures/wpt/websockets/Close-1000-verify-code.any.js create mode 100644 test/fixtures/wpt/websockets/Close-1000.any.js create mode 100644 test/fixtures/wpt/websockets/Close-1005-verify-code.any.js create mode 100644 test/fixtures/wpt/websockets/Close-1005.any.js create mode 100644 test/fixtures/wpt/websockets/Close-2999-reason.any.js create mode 100644 test/fixtures/wpt/websockets/Close-3000-reason.any.js create mode 100644 test/fixtures/wpt/websockets/Close-3000-verify-code.any.js create mode 100644 test/fixtures/wpt/websockets/Close-4999-reason.any.js create mode 100644 test/fixtures/wpt/websockets/Close-Reason-124Bytes.any.js create mode 100644 test/fixtures/wpt/websockets/Close-delayed.any.js create mode 100644 test/fixtures/wpt/websockets/Close-onlyReason.any.js create mode 100644 test/fixtures/wpt/websockets/Close-readyState-Closed.any.js create mode 100644 test/fixtures/wpt/websockets/Close-readyState-Closing.any.js create mode 100644 test/fixtures/wpt/websockets/Close-reason-unpaired-surrogates.any.js create mode 100644 test/fixtures/wpt/websockets/Close-server-initiated-close.any.js create mode 100644 test/fixtures/wpt/websockets/Close-undefined.any.js create mode 100644 test/fixtures/wpt/websockets/Create-asciiSep-protocol-string.any.js create mode 100644 test/fixtures/wpt/websockets/Create-blocked-port.any.js create mode 100644 test/fixtures/wpt/websockets/Create-extensions-empty.any.js create mode 100644 test/fixtures/wpt/websockets/Create-http-urls.any.js create mode 100644 test/fixtures/wpt/websockets/Create-invalid-urls.any.js create mode 100644 test/fixtures/wpt/websockets/Create-non-absolute-url.any.js create mode 100644 test/fixtures/wpt/websockets/Create-nonAscii-protocol-string.any.js create mode 100644 test/fixtures/wpt/websockets/Create-on-worker-shutdown.any.js create mode 100644 test/fixtures/wpt/websockets/Create-protocol-with-space.any.js create mode 100644 test/fixtures/wpt/websockets/Create-protocols-repeated-case-insensitive.any.js create mode 100644 test/fixtures/wpt/websockets/Create-protocols-repeated.any.js create mode 100644 test/fixtures/wpt/websockets/Create-url-with-space.any.js create mode 100644 test/fixtures/wpt/websockets/Create-url-with-windows-1252-encoding.html create mode 100644 test/fixtures/wpt/websockets/Create-valid-url-array-protocols.any.js create mode 100644 test/fixtures/wpt/websockets/Create-valid-url-binaryType-blob.any.js create mode 100644 test/fixtures/wpt/websockets/Create-valid-url-protocol-empty.any.js create mode 100644 test/fixtures/wpt/websockets/Create-valid-url-protocol-setCorrectly.any.js create mode 100644 test/fixtures/wpt/websockets/Create-valid-url-protocol-string.any.js create mode 100644 test/fixtures/wpt/websockets/Create-valid-url-protocol.any.js create mode 100644 test/fixtures/wpt/websockets/Create-valid-url.any.js create mode 100644 test/fixtures/wpt/websockets/META.yml create mode 100644 test/fixtures/wpt/websockets/README.md create mode 100644 test/fixtures/wpt/websockets/Send-0byte-data.any.js create mode 100644 test/fixtures/wpt/websockets/Send-65K-data.any.js create mode 100644 test/fixtures/wpt/websockets/Send-before-open.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-65K-arraybuffer.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybuffer.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-float16.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-float32.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-float64.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-int16-offset.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-int32.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-int8.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-uint16-offset-length.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-uint32-offset.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-uint8-offset-length.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-arraybufferview-uint8-offset.any.js create mode 100644 test/fixtures/wpt/websockets/Send-binary-blob.any.js create mode 100644 test/fixtures/wpt/websockets/Send-data.any.js create mode 100644 test/fixtures/wpt/websockets/Send-data.worker.js create mode 100644 test/fixtures/wpt/websockets/Send-null.any.js create mode 100644 test/fixtures/wpt/websockets/Send-paired-surrogates.any.js create mode 100644 test/fixtures/wpt/websockets/Send-unicode-data.any.js create mode 100644 test/fixtures/wpt/websockets/Send-unpaired-surrogates.any.js create mode 100644 test/fixtures/wpt/websockets/back-forward-cache-with-closed-websocket-connection-ccns.tentative.window.js create mode 100644 test/fixtures/wpt/websockets/back-forward-cache-with-closed-websocket-connection.window.js create mode 100644 test/fixtures/wpt/websockets/back-forward-cache-with-open-websocket-connection-ccns.tentative.window.js create mode 100644 test/fixtures/wpt/websockets/back-forward-cache-with-open-websocket-connection.window.js create mode 100644 test/fixtures/wpt/websockets/basic-auth.any.js create mode 100644 test/fixtures/wpt/websockets/binary/001.html create mode 100644 test/fixtures/wpt/websockets/binary/002.html create mode 100644 test/fixtures/wpt/websockets/binary/004.html create mode 100644 test/fixtures/wpt/websockets/binary/005.html create mode 100644 test/fixtures/wpt/websockets/binaryType-wrong-value.any.js create mode 100644 test/fixtures/wpt/websockets/bufferedAmount-unchanged-by-sync-xhr.any.js create mode 100644 test/fixtures/wpt/websockets/close-invalid.any.js create mode 100644 test/fixtures/wpt/websockets/closing-handshake/002.html create mode 100644 test/fixtures/wpt/websockets/closing-handshake/003.html create mode 100644 test/fixtures/wpt/websockets/closing-handshake/004.html create mode 100644 test/fixtures/wpt/websockets/constants.sub.js create mode 100644 test/fixtures/wpt/websockets/constructor.any.js create mode 100644 test/fixtures/wpt/websockets/constructor/001.html create mode 100644 test/fixtures/wpt/websockets/constructor/004.html create mode 100644 test/fixtures/wpt/websockets/constructor/005.html create mode 100644 test/fixtures/wpt/websockets/constructor/006.html create mode 100644 test/fixtures/wpt/websockets/constructor/007.html create mode 100644 test/fixtures/wpt/websockets/constructor/008.html create mode 100644 test/fixtures/wpt/websockets/constructor/009.html create mode 100644 test/fixtures/wpt/websockets/constructor/010.html create mode 100644 test/fixtures/wpt/websockets/constructor/011.html create mode 100644 test/fixtures/wpt/websockets/constructor/012.html create mode 100644 test/fixtures/wpt/websockets/constructor/013.html create mode 100644 test/fixtures/wpt/websockets/constructor/014.html create mode 100644 test/fixtures/wpt/websockets/constructor/016.html create mode 100644 test/fixtures/wpt/websockets/constructor/017.html create mode 100644 test/fixtures/wpt/websockets/constructor/018.html create mode 100644 test/fixtures/wpt/websockets/constructor/019.html create mode 100644 test/fixtures/wpt/websockets/constructor/020.html create mode 100644 test/fixtures/wpt/websockets/constructor/021.html create mode 100644 test/fixtures/wpt/websockets/constructor/022.html create mode 100644 test/fixtures/wpt/websockets/cookies/001.html create mode 100644 test/fixtures/wpt/websockets/cookies/002.html create mode 100644 test/fixtures/wpt/websockets/cookies/003.html create mode 100644 test/fixtures/wpt/websockets/cookies/004.html create mode 100644 test/fixtures/wpt/websockets/cookies/005.html create mode 100644 test/fixtures/wpt/websockets/cookies/006.html create mode 100644 test/fixtures/wpt/websockets/cookies/007.html create mode 100644 test/fixtures/wpt/websockets/cookies/support/set-cookie.py create mode 100644 test/fixtures/wpt/websockets/cookies/support/websocket-cookies-helper.sub.js create mode 100644 test/fixtures/wpt/websockets/cookies/third-party-cookie-accepted.https.html create mode 100644 test/fixtures/wpt/websockets/eventhandlers.any.js create mode 100644 test/fixtures/wpt/websockets/extended-payload-length.html create mode 100755 test/fixtures/wpt/websockets/handlers/basic_auth_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/delayed-passive-close_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo-cookie_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo-query_v13_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo-query_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo_close_data_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo_exit_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo_raw_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/echo_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/empty-message_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/handshake_no_extensions_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/handshake_no_protocol_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/handshake_protocol_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/handshake_sleep_2_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/invalid_wsh.py create mode 100644 test/fixtures/wpt/websockets/handlers/msg_channel_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/origin_wsh.py create mode 100644 test/fixtures/wpt/websockets/handlers/passive-close-abort_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/protocol_array_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/protocol_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/receive-backpressure_wsh.py create mode 100644 test/fixtures/wpt/websockets/handlers/receive-many-with-backpressure_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/referrer_wsh.py create mode 100644 test/fixtures/wpt/websockets/handlers/remote-close_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/send-backpressure_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/set-cookie-secure_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/set-cookie_http_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/set-cookie_wsh.py create mode 100644 test/fixtures/wpt/websockets/handlers/set-cookies-samesite_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/simple_handshake_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/sleep_10_v13_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/stash_responder_blocking_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/stash_responder_wsh.py create mode 100755 test/fixtures/wpt/websockets/handlers/wrong_accept_key_wsh.py create mode 100644 test/fixtures/wpt/websockets/idlharness.any.js create mode 100644 test/fixtures/wpt/websockets/interfaces/CloseEvent/clean-close.html create mode 100644 test/fixtures/wpt/websockets/interfaces/CloseEvent/constructor.html create mode 100644 test/fixtures/wpt/websockets/interfaces/CloseEvent/historical.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-arraybuffer.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-blob.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-defineProperty-getter.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-defineProperty-setter.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-deleting.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-getting.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-initial.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-large.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-readonly.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/bufferedAmount/bufferedAmount-unicode.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-basic.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-connecting-async.any.js create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-connecting.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-multiple.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-nested.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-replace.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/close/close-return.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/constants/001.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/constants/002.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/constants/003.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/constants/004.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/constants/005.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/constants/006.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/001.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/002.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/003.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/004.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/006.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/007.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/008.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/009.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/010.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/011.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/012.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/013.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/014.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/015.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/016.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/017.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/018.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/019.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/events/020.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/extensions/001.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/protocol/protocol-initial.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/001.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/002.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/003.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/004.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/005.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/006.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/007.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/readyState/008.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/001.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/002.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/003.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/004.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/005.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/006.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/007.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/008.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/009.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/010.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/011.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/send/012.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/001.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/002.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/003.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/004.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/005.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/006.html create mode 100644 test/fixtures/wpt/websockets/interfaces/WebSocket/url/resolve.html create mode 100644 test/fixtures/wpt/websockets/keeping-connection-open/001.html create mode 100644 test/fixtures/wpt/websockets/mixed-content.https.any.js create mode 100644 test/fixtures/wpt/websockets/multi-globals/message-received.html create mode 100644 test/fixtures/wpt/websockets/multi-globals/support/incumbent.sub.html create mode 100644 test/fixtures/wpt/websockets/multi-globals/support/relevant.html create mode 100644 test/fixtures/wpt/websockets/multi-globals/url-parsing/current/current.html create mode 100644 test/fixtures/wpt/websockets/multi-globals/url-parsing/incumbent/incumbent.html create mode 100644 test/fixtures/wpt/websockets/multi-globals/url-parsing/url-parsing.html create mode 100644 test/fixtures/wpt/websockets/opening-handshake/001.html create mode 100644 test/fixtures/wpt/websockets/opening-handshake/002.html create mode 100644 test/fixtures/wpt/websockets/opening-handshake/003-sets-origin.worker.js create mode 100644 test/fixtures/wpt/websockets/opening-handshake/003.html create mode 100644 test/fixtures/wpt/websockets/opening-handshake/005.html create mode 100644 test/fixtures/wpt/websockets/opening-handshake/006.html create mode 100644 test/fixtures/wpt/websockets/referrer.any.js create mode 100644 test/fixtures/wpt/websockets/remove-own-iframe-during-onerror.window.js create mode 100644 test/fixtures/wpt/websockets/resources/websockets-test-helpers.sub.js create mode 100644 test/fixtures/wpt/websockets/security/001.html create mode 100644 test/fixtures/wpt/websockets/security/002.html create mode 100644 test/fixtures/wpt/websockets/security/check.py create mode 100644 test/fixtures/wpt/websockets/send-many-64K-messages-with-backpressure.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/README.md create mode 100644 test/fixtures/wpt/websockets/stream/tentative/abort.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/backpressure-receive.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/backpressure-send.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/close.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/constructor.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/remote-close.any.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/resources/url-constants.js create mode 100644 test/fixtures/wpt/websockets/stream/tentative/websocket-error.any.js create mode 100644 test/fixtures/wpt/websockets/unload-a-document/001-1.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/001-2.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/001.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/002-1.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/002-2.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/002.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/003.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/004.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/005-1.html create mode 100644 test/fixtures/wpt/websockets/unload-a-document/005.html create mode 100644 test/fixtures/wpt/xhr/META.yml create mode 100644 test/fixtures/wpt/xhr/README.md create mode 100644 test/fixtures/wpt/xhr/XMLHttpRequest-withCredentials.any.js create mode 100644 test/fixtures/wpt/xhr/abort-after-receive.any.js create mode 100644 test/fixtures/wpt/xhr/abort-after-send.any.js create mode 100644 test/fixtures/wpt/xhr/abort-after-stop.window.js create mode 100644 test/fixtures/wpt/xhr/abort-after-timeout.any.js create mode 100644 test/fixtures/wpt/xhr/abort-during-done.window.js create mode 100644 test/fixtures/wpt/xhr/abort-during-headers-received.window.js create mode 100644 test/fixtures/wpt/xhr/abort-during-loading.window.js create mode 100644 test/fixtures/wpt/xhr/abort-during-open.any.js create mode 100644 test/fixtures/wpt/xhr/abort-during-readystatechange.any.js create mode 100644 test/fixtures/wpt/xhr/abort-during-unsent.any.js create mode 100644 test/fixtures/wpt/xhr/abort-during-upload.any.js create mode 100644 test/fixtures/wpt/xhr/abort-event-abort.any.js create mode 100644 test/fixtures/wpt/xhr/abort-event-listeners.any.js create mode 100644 test/fixtures/wpt/xhr/abort-event-loadend.any.js create mode 100644 test/fixtures/wpt/xhr/abort-event-order.htm create mode 100644 test/fixtures/wpt/xhr/abort-upload-event-abort.any.js create mode 100644 test/fixtures/wpt/xhr/abort-upload-event-loadend.any.js create mode 100644 test/fixtures/wpt/xhr/abort-with-error.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-and-redirects-async-same-origin.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-and-redirects-async.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-and-redirects.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-access-control-origin-header-data-url.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-access-control-origin-header.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-async.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-non-cors-safelisted-method-async.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-non-cors-safelisted-method.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-preflight-cache-invalidation-by-header.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-preflight-cache-invalidation-by-method.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-preflight-cache-timeout.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-preflight-cache.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow-star.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-allow.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-basic-cors-safelisted-request-headers.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-cors-safelisted-response-headers.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-denied.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-get-fail-non-simple.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-non-cors-safelisted-content-type.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-post-success-no-content-type.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-post-with-non-cors-safelisted-content-type.htm create mode 100644 test/fixtures/wpt/xhr/access-control-basic-preflight-denied.htm create mode 100644 test/fixtures/wpt/xhr/access-control-expose-headers-on-redirect.html create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-async-header-denied.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-async-method-denied.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-async-not-supported.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-credential-async.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-credential-sync.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-headers-async.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-headers-sync.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-allow-headers-returns-star.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-header-lowercase.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-header-returns-origin.any.js create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-header-sorted.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-headers-origin.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-invalid-status-301.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-invalid-status-400.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-invalid-status-501.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-request-must-not-contain-cookie.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-sync-header-denied.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-sync-method-denied.htm create mode 100644 test/fixtures/wpt/xhr/access-control-preflight-sync-not-supported.htm create mode 100644 test/fixtures/wpt/xhr/access-control-recursive-failed-request.htm create mode 100644 test/fixtures/wpt/xhr/access-control-response-with-body-sync.htm create mode 100644 test/fixtures/wpt/xhr/access-control-response-with-body.htm create mode 100644 test/fixtures/wpt/xhr/access-control-response-with-exposed-headers.htm create mode 100644 test/fixtures/wpt/xhr/access-control-sandboxed-iframe-allow-origin-null.htm create mode 100644 test/fixtures/wpt/xhr/access-control-sandboxed-iframe-allow.htm create mode 100644 test/fixtures/wpt/xhr/access-control-sandboxed-iframe-denied-without-wildcard.htm create mode 100644 test/fixtures/wpt/xhr/access-control-sandboxed-iframe-denied.htm create mode 100644 test/fixtures/wpt/xhr/allow-lists-starting-with-comma.htm create mode 100644 test/fixtures/wpt/xhr/anonymous-mode-unsupported.htm create mode 100644 test/fixtures/wpt/xhr/blob-range.any.js create mode 100644 test/fixtures/wpt/xhr/close-worker-with-xhr-in-progress.html create mode 100644 test/fixtures/wpt/xhr/content-type-unmodified.any.js create mode 100644 test/fixtures/wpt/xhr/cookies.http.html create mode 100644 test/fixtures/wpt/xhr/cors-expose-star.sub.any.js create mode 100644 test/fixtures/wpt/xhr/cors-upload.any.js create mode 100644 test/fixtures/wpt/xhr/data-uri.htm create mode 100644 test/fixtures/wpt/xhr/event-abort.any.js create mode 100644 test/fixtures/wpt/xhr/event-error-order.sub.html create mode 100644 test/fixtures/wpt/xhr/event-error.sub.any.js create mode 100644 test/fixtures/wpt/xhr/event-load.any.js create mode 100644 test/fixtures/wpt/xhr/event-loadend.any.js create mode 100644 test/fixtures/wpt/xhr/event-loadstart-upload.any.js create mode 100644 test/fixtures/wpt/xhr/event-loadstart.any.js create mode 100644 test/fixtures/wpt/xhr/event-progress.any.js create mode 100644 test/fixtures/wpt/xhr/event-readystate-sync-open.any.js create mode 100644 test/fixtures/wpt/xhr/event-readystatechange-loaded.any.js create mode 100644 test/fixtures/wpt/xhr/event-timeout-order.any.js create mode 100644 test/fixtures/wpt/xhr/event-timeout.any.js create mode 100644 test/fixtures/wpt/xhr/event-upload-progress-crossorigin.any.js create mode 100644 test/fixtures/wpt/xhr/event-upload-progress.any.js create mode 100644 test/fixtures/wpt/xhr/firing-events-http-content-length.html create mode 100644 test/fixtures/wpt/xhr/firing-events-http-no-content-length.html create mode 100644 test/fixtures/wpt/xhr/folder.txt create mode 100644 test/fixtures/wpt/xhr/formdata.html create mode 100644 test/fixtures/wpt/xhr/formdata/append-formelement.html create mode 100644 test/fixtures/wpt/xhr/formdata/append.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/constructor-formelement.html create mode 100644 test/fixtures/wpt/xhr/formdata/constructor-submitter-coordinate.html create mode 100644 test/fixtures/wpt/xhr/formdata/constructor-submitter.html create mode 100644 test/fixtures/wpt/xhr/formdata/constructor.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/delete-formelement.html create mode 100644 test/fixtures/wpt/xhr/formdata/delete.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/foreach.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/get-formelement.html create mode 100644 test/fixtures/wpt/xhr/formdata/get.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/has-formelement.html create mode 100644 test/fixtures/wpt/xhr/formdata/has.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/iteration.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/set-blob.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/set-formelement.html create mode 100644 test/fixtures/wpt/xhr/formdata/set.any.js create mode 100644 test/fixtures/wpt/xhr/formdata/submitter-coordinate-value.html create mode 100644 test/fixtures/wpt/xhr/getallresponseheaders-cookies.htm create mode 100644 test/fixtures/wpt/xhr/getallresponseheaders-status.htm create mode 100644 test/fixtures/wpt/xhr/getallresponseheaders.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-case-insensitive.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-chunked-trailer.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-cookies-and-more.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-error-state.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-server-date.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-special-characters.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader-unsent-opened-state.htm create mode 100644 test/fixtures/wpt/xhr/getresponseheader.any.js create mode 100644 test/fixtures/wpt/xhr/header-user-agent-async.htm create mode 100644 test/fixtures/wpt/xhr/header-user-agent-sync.htm create mode 100644 test/fixtures/wpt/xhr/headers-normalize-response.htm create mode 100644 test/fixtures/wpt/xhr/historical.html create mode 100644 test/fixtures/wpt/xhr/idlharness.any.js create mode 100644 test/fixtures/wpt/xhr/json.any.js create mode 100644 test/fixtures/wpt/xhr/loadstart-and-state.html create mode 100644 test/fixtures/wpt/xhr/open-after-abort.htm create mode 100644 test/fixtures/wpt/xhr/open-after-setrequestheader.htm create mode 100644 test/fixtures/wpt/xhr/open-after-stop.window.js create mode 100644 test/fixtures/wpt/xhr/open-during-abort-event.htm create mode 100644 test/fixtures/wpt/xhr/open-during-abort-processing.htm create mode 100644 test/fixtures/wpt/xhr/open-during-abort.htm create mode 100644 test/fixtures/wpt/xhr/open-method-bogus.htm create mode 100644 test/fixtures/wpt/xhr/open-method-case-insensitive.htm create mode 100644 test/fixtures/wpt/xhr/open-method-case-sensitive.htm create mode 100644 test/fixtures/wpt/xhr/open-method-insecure.htm create mode 100644 test/fixtures/wpt/xhr/open-method-responsetype-set-sync.htm create mode 100644 test/fixtures/wpt/xhr/open-open-send.htm create mode 100644 test/fixtures/wpt/xhr/open-open-sync-send.htm create mode 100644 test/fixtures/wpt/xhr/open-parameters-toString.htm create mode 100644 test/fixtures/wpt/xhr/open-referer.htm create mode 100644 test/fixtures/wpt/xhr/open-send-during-abort.htm create mode 100644 test/fixtures/wpt/xhr/open-send-open.htm create mode 100644 test/fixtures/wpt/xhr/open-sync-open-send.htm create mode 100644 test/fixtures/wpt/xhr/open-url-about-blank-window.htm create mode 100644 test/fixtures/wpt/xhr/open-url-base-inserted-after-open.htm create mode 100644 test/fixtures/wpt/xhr/open-url-base-inserted.htm create mode 100644 test/fixtures/wpt/xhr/open-url-base.htm create mode 100644 test/fixtures/wpt/xhr/open-url-encoding.htm create mode 100644 test/fixtures/wpt/xhr/open-url-fragment.htm create mode 100644 test/fixtures/wpt/xhr/open-url-javascript-window-2.htm create mode 100644 test/fixtures/wpt/xhr/open-url-javascript-window.htm create mode 100644 test/fixtures/wpt/xhr/open-url-multi-window-2.htm create mode 100644 test/fixtures/wpt/xhr/open-url-multi-window-3.htm create mode 100644 test/fixtures/wpt/xhr/open-url-multi-window-4.htm create mode 100644 test/fixtures/wpt/xhr/open-url-multi-window-5.htm create mode 100644 test/fixtures/wpt/xhr/open-url-multi-window-6.htm create mode 100644 test/fixtures/wpt/xhr/open-url-multi-window.htm create mode 100644 test/fixtures/wpt/xhr/open-url-redirected-sharedworker-origin.htm create mode 100644 test/fixtures/wpt/xhr/open-url-redirected-worker-origin.htm create mode 100644 test/fixtures/wpt/xhr/open-url-worker-origin.htm create mode 100644 test/fixtures/wpt/xhr/open-url-worker-simple.htm create mode 100644 test/fixtures/wpt/xhr/open-user-password-non-same-origin.htm create mode 100644 test/fixtures/wpt/xhr/over-1-meg.any.js create mode 100644 test/fixtures/wpt/xhr/overridemimetype-blob.html create mode 100644 test/fixtures/wpt/xhr/overridemimetype-done-state.any.js create mode 100644 test/fixtures/wpt/xhr/overridemimetype-edge-cases.window.js create mode 100644 test/fixtures/wpt/xhr/overridemimetype-headers-received-state-force-shiftjis.htm create mode 100644 test/fixtures/wpt/xhr/overridemimetype-invalid-mime-type.htm create mode 100644 test/fixtures/wpt/xhr/overridemimetype-loading-state.htm create mode 100644 test/fixtures/wpt/xhr/overridemimetype-open-state-force-utf-8.htm create mode 100644 test/fixtures/wpt/xhr/overridemimetype-open-state-force-xml.htm create mode 100644 test/fixtures/wpt/xhr/overridemimetype-unsent-state-force-shiftjis.any.js create mode 100644 test/fixtures/wpt/xhr/preserve-ua-header-on-redirect.htm create mode 100644 test/fixtures/wpt/xhr/progress-events-response-data-gzip.htm create mode 100644 test/fixtures/wpt/xhr/progressevent-constructor.html create mode 100644 test/fixtures/wpt/xhr/progressevent-interface.html create mode 100644 test/fixtures/wpt/xhr/request-content-length.any.js create mode 100644 test/fixtures/wpt/xhr/resources/accept-language.py create mode 100644 test/fixtures/wpt/xhr/resources/accept.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-allow-lists.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-allow-with-body.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-auth-basic.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-allow-no-credentials.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-allow-star.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-allow.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-cors-safelisted-request-headers.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-cors-safelisted-response-headers.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-denied.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-options-not-supported.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-preflight-cache-invalidation.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-preflight-cache-timeout.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-preflight-cache.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-basic-put-allow.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-cookie.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-origin-header.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-denied.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-allow-headers-returns-star.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-header-lowercase.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-header-returns-origin.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-header-sorted.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-headers-origin.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-invalid-status.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-preflight-request-must-not-contain-cookie.py create mode 100644 test/fixtures/wpt/xhr/resources/access-control-sandboxed-iframe.html create mode 100644 test/fixtures/wpt/xhr/resources/auth1/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth10/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth11/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth2/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth2/corsenabled.py create mode 100644 test/fixtures/wpt/xhr/resources/auth3/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth4/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth5/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth6/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/auth7/corsenabled.py create mode 100644 test/fixtures/wpt/xhr/resources/auth8/corsenabled-no-authorize.py create mode 100644 test/fixtures/wpt/xhr/resources/auth9/auth.py create mode 100644 test/fixtures/wpt/xhr/resources/authentication.py create mode 100644 test/fixtures/wpt/xhr/resources/bad-chunk-encoding.py create mode 100644 test/fixtures/wpt/xhr/resources/base.xml create mode 100644 test/fixtures/wpt/xhr/resources/chunked.py create mode 100644 test/fixtures/wpt/xhr/resources/conditional.py create mode 100644 test/fixtures/wpt/xhr/resources/content.py create mode 100644 test/fixtures/wpt/xhr/resources/corsenabled.py create mode 100644 test/fixtures/wpt/xhr/resources/delay.py create mode 100644 test/fixtures/wpt/xhr/resources/echo-content-cors.py create mode 100644 test/fixtures/wpt/xhr/resources/echo-content-type.py create mode 100644 test/fixtures/wpt/xhr/resources/echo-headers.py create mode 100644 test/fixtures/wpt/xhr/resources/echo-method.py create mode 100644 test/fixtures/wpt/xhr/resources/empty-div-utf8-html.py create mode 100644 test/fixtures/wpt/xhr/resources/folder.txt create mode 100644 test/fixtures/wpt/xhr/resources/form.py create mode 100644 test/fixtures/wpt/xhr/resources/get-set-cookie.py create mode 100644 test/fixtures/wpt/xhr/resources/gzip.py create mode 100644 test/fixtures/wpt/xhr/resources/header-content-length-twice.asis create mode 100644 test/fixtures/wpt/xhr/resources/header-content-length.asis create mode 100644 test/fixtures/wpt/xhr/resources/header-user-agent.py create mode 100644 test/fixtures/wpt/xhr/resources/headers-basic.asis create mode 100644 test/fixtures/wpt/xhr/resources/headers-double-empty.asis create mode 100644 test/fixtures/wpt/xhr/resources/headers-some-are-empty.asis create mode 100644 test/fixtures/wpt/xhr/resources/headers-www-authenticate.asis create mode 100644 test/fixtures/wpt/xhr/resources/headers.asis create mode 100644 test/fixtures/wpt/xhr/resources/headers.py create mode 100644 test/fixtures/wpt/xhr/resources/image.gif create mode 100644 test/fixtures/wpt/xhr/resources/img-utf8-html.py create mode 100644 test/fixtures/wpt/xhr/resources/img.jpg create mode 100644 test/fixtures/wpt/xhr/resources/infinite-redirects.py create mode 100644 test/fixtures/wpt/xhr/resources/init.htm create mode 100644 test/fixtures/wpt/xhr/resources/inspect-headers.py create mode 100644 test/fixtures/wpt/xhr/resources/invalid-utf8-html.py create mode 100644 test/fixtures/wpt/xhr/resources/last-modified.py create mode 100644 test/fixtures/wpt/xhr/resources/no-custom-header-on-preflight.py create mode 100644 test/fixtures/wpt/xhr/resources/nocors/folder.txt create mode 100644 test/fixtures/wpt/xhr/resources/over-1-meg.txt create mode 100644 test/fixtures/wpt/xhr/resources/parse-headers.py create mode 100644 test/fixtures/wpt/xhr/resources/pass.txt create mode 100644 test/fixtures/wpt/xhr/resources/redirect-cors.py create mode 100644 test/fixtures/wpt/xhr/resources/redirect.py create mode 100644 test/fixtures/wpt/xhr/resources/requri.py create mode 100644 test/fixtures/wpt/xhr/resources/reset-token.py create mode 100644 test/fixtures/wpt/xhr/resources/responseType-document-in-worker.js create mode 100644 test/fixtures/wpt/xhr/resources/responseXML-unavailable-in-worker.js create mode 100644 test/fixtures/wpt/xhr/resources/send-after-setting-document-domain-window-1.htm create mode 100644 test/fixtures/wpt/xhr/resources/send-after-setting-document-domain-window-2.htm create mode 100644 test/fixtures/wpt/xhr/resources/send-after-setting-document-domain-window-helper.js create mode 100644 test/fixtures/wpt/xhr/resources/shift-jis-html.py create mode 100644 test/fixtures/wpt/xhr/resources/status.py create mode 100644 test/fixtures/wpt/xhr/resources/top.txt create mode 100644 test/fixtures/wpt/xhr/resources/trickle.py create mode 100644 test/fixtures/wpt/xhr/resources/upload.py create mode 100644 test/fixtures/wpt/xhr/resources/utf16-bom.json create mode 100644 test/fixtures/wpt/xhr/resources/utf16.txt create mode 100644 test/fixtures/wpt/xhr/resources/well-formed.xml create mode 100644 test/fixtures/wpt/xhr/resources/win-1252-html.py create mode 100644 test/fixtures/wpt/xhr/resources/win-1252-xml.py create mode 100644 test/fixtures/wpt/xhr/resources/workerxhr-origin-referrer.js create mode 100644 test/fixtures/wpt/xhr/resources/workerxhr-simple.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-event-order.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-aborted.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-abortedonmain.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-overrides.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-overridesexpires.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-runner.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-simple.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-synconmain.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-synconworker.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout-twice.js create mode 100644 test/fixtures/wpt/xhr/resources/xmlhttprequest-timeout.js create mode 100644 test/fixtures/wpt/xhr/resources/zlib.py create mode 100644 test/fixtures/wpt/xhr/response-body-errors.any.js create mode 100644 test/fixtures/wpt/xhr/response-data-arraybuffer.htm create mode 100644 test/fixtures/wpt/xhr/response-data-blob.htm create mode 100644 test/fixtures/wpt/xhr/response-data-deflate.htm create mode 100644 test/fixtures/wpt/xhr/response-data-gzip.htm create mode 100644 test/fixtures/wpt/xhr/response-data-progress.htm create mode 100644 test/fixtures/wpt/xhr/response-invalid-responsetype.htm create mode 100644 test/fixtures/wpt/xhr/response-json.htm create mode 100644 test/fixtures/wpt/xhr/response-method.htm create mode 100644 test/fixtures/wpt/xhr/responseText-status.html create mode 100644 test/fixtures/wpt/xhr/responseType-document-in-worker.html create mode 100644 test/fixtures/wpt/xhr/responseXML-unavailable-in-worker.html create mode 100644 test/fixtures/wpt/xhr/responsedocument-decoding.htm create mode 100644 test/fixtures/wpt/xhr/responsetext-decoding.htm create mode 100644 test/fixtures/wpt/xhr/responsetype.any.js create mode 100644 test/fixtures/wpt/xhr/responseurl.html create mode 100644 test/fixtures/wpt/xhr/responsexml-basic.htm create mode 100644 test/fixtures/wpt/xhr/responsexml-document-properties.htm create mode 100644 test/fixtures/wpt/xhr/responsexml-get-twice.htm create mode 100644 test/fixtures/wpt/xhr/responsexml-invalid-type.html create mode 100644 test/fixtures/wpt/xhr/responsexml-media-type.htm create mode 100644 test/fixtures/wpt/xhr/responsexml-non-document-types.htm create mode 100644 test/fixtures/wpt/xhr/responsexml-non-well-formed.htm create mode 100644 test/fixtures/wpt/xhr/security-consideration.sub.html create mode 100644 test/fixtures/wpt/xhr/send-accept-language.htm create mode 100644 test/fixtures/wpt/xhr/send-accept.htm create mode 100644 test/fixtures/wpt/xhr/send-after-setting-document-domain.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic-cors-not-enabled.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic-cors.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic-repeat-no-args.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic-setrequestheader-and-arguments.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic-setrequestheader-existing-session.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic-setrequestheader.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-basic.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-competing-names-passwords.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-cors-basic-setrequestheader.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-cors-setrequestheader-no-cred.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-existing-session-manual.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-prompt-2-manual.htm create mode 100644 test/fixtures/wpt/xhr/send-authentication-prompt-manual.htm create mode 100644 test/fixtures/wpt/xhr/send-blob-with-no-mime-type.html create mode 100644 test/fixtures/wpt/xhr/send-conditional-cors.htm create mode 100644 test/fixtures/wpt/xhr/send-conditional.htm create mode 100644 test/fixtures/wpt/xhr/send-content-type-charset.htm create mode 100644 test/fixtures/wpt/xhr/send-content-type-string.htm create mode 100644 test/fixtures/wpt/xhr/send-data-arraybuffer.any.js create mode 100644 test/fixtures/wpt/xhr/send-data-arraybufferview.any.js create mode 100644 test/fixtures/wpt/xhr/send-data-blob.htm create mode 100644 test/fixtures/wpt/xhr/send-data-es-object.any.js create mode 100644 test/fixtures/wpt/xhr/send-data-formdata.any.js create mode 100644 test/fixtures/wpt/xhr/send-data-sharedarraybuffer.any.js create mode 100644 test/fixtures/wpt/xhr/send-data-string-invalid-unicode.any.js create mode 100644 test/fixtures/wpt/xhr/send-data-unexpected-tostring.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-basic.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-document-bogus.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-document.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-empty.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-get-head-async.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-get-head.htm create mode 100644 test/fixtures/wpt/xhr/send-entity-body-none.htm create mode 100644 test/fixtures/wpt/xhr/send-network-error-async-events.sub.htm create mode 100644 test/fixtures/wpt/xhr/send-network-error-sync-events.sub.htm create mode 100644 test/fixtures/wpt/xhr/send-no-response-event-loadend.htm create mode 100644 test/fixtures/wpt/xhr/send-no-response-event-loadstart.htm create mode 100644 test/fixtures/wpt/xhr/send-no-response-event-order.htm create mode 100644 test/fixtures/wpt/xhr/send-non-same-origin.htm create mode 100644 test/fixtures/wpt/xhr/send-receive-utf16.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-bogus-sync.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-bogus.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-infinite-sync.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-infinite.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-no-location.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-post-upload.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-to-cors.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect-to-non-cors.htm create mode 100644 test/fixtures/wpt/xhr/send-redirect.htm create mode 100644 test/fixtures/wpt/xhr/send-response-event-order.htm create mode 100644 test/fixtures/wpt/xhr/send-response-upload-event-loadend.htm create mode 100644 test/fixtures/wpt/xhr/send-response-upload-event-loadstart.htm create mode 100644 test/fixtures/wpt/xhr/send-response-upload-event-progress.htm create mode 100644 test/fixtures/wpt/xhr/send-send.any.js create mode 100644 test/fixtures/wpt/xhr/send-sync-blocks-async.htm create mode 100644 test/fixtures/wpt/xhr/send-sync-no-response-event-load.htm create mode 100644 test/fixtures/wpt/xhr/send-sync-no-response-event-loadend.htm create mode 100644 test/fixtures/wpt/xhr/send-sync-no-response-event-order.htm create mode 100644 test/fixtures/wpt/xhr/send-sync-response-event-order.htm create mode 100644 test/fixtures/wpt/xhr/send-sync-timeout.htm create mode 100644 test/fixtures/wpt/xhr/send-timeout-events.htm create mode 100644 test/fixtures/wpt/xhr/send-usp.any.js create mode 100644 test/fixtures/wpt/xhr/setrequestheader-after-send.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-allow-empty-value.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-allow-whitespace-in-value.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-before-open.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-bogus-name.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-bogus-value.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-case-insensitive.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-combining.window.js create mode 100644 test/fixtures/wpt/xhr/setrequestheader-content-type.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-header-allowed.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-header-forbidden.htm create mode 100644 test/fixtures/wpt/xhr/setrequestheader-open-setrequestheader.htm create mode 100644 test/fixtures/wpt/xhr/status-async.htm create mode 100644 test/fixtures/wpt/xhr/status-basic.htm create mode 100644 test/fixtures/wpt/xhr/status-error.htm create mode 100644 test/fixtures/wpt/xhr/status.h2.window.js create mode 100644 test/fixtures/wpt/xhr/sync-no-progress.any.js create mode 100644 test/fixtures/wpt/xhr/sync-no-timeout.any.js create mode 100644 test/fixtures/wpt/xhr/sync-xhr-and-window-onload.html create mode 100644 test/fixtures/wpt/xhr/sync-xhr-supported-by-feature-policy.html create mode 100644 test/fixtures/wpt/xhr/template-element.html create mode 100644 test/fixtures/wpt/xhr/thrown-error-in-events.html create mode 100644 test/fixtures/wpt/xhr/timeout-cors-async.htm create mode 100644 test/fixtures/wpt/xhr/timeout-multiple-fetches.html create mode 100644 test/fixtures/wpt/xhr/timeout-sync.htm create mode 100644 test/fixtures/wpt/xhr/xhr-authorization-redirect.any.js create mode 100644 test/fixtures/wpt/xhr/xhr-timeout-longtask.any.js create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-basic.htm create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-eventtarget.htm create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-network-error-sync.htm create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-network-error.htm create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-sync-block-defer-scripts-subframe.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-sync-block-defer-scripts.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-sync-block-scripts.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-sync-default-feature-policy.sub.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-sync-not-hang-scriptloader-subframe.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-sync-not-hang-scriptloader.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-aborted.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-abortedonmain.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-overrides.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-overridesexpires.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-reused.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-simple.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-synconmain.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-twice.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-worker-aborted.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-worker-overrides.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-worker-overridesexpires.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-worker-simple.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-worker-synconworker.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-timeout-worker-twice.html create mode 100644 test/fixtures/wpt/xhr/xmlhttprequest-unsent.htm create mode 100644 test/fuzzing/client/client-fuzz-body.js create mode 100644 test/fuzzing/client/client-fuzz-headers.js create mode 100644 test/fuzzing/client/client-fuzz-options.js create mode 100644 test/fuzzing/client/index.js create mode 100644 test/fuzzing/fuzzing.test.js create mode 100644 test/fuzzing/server/index.js create mode 100644 test/fuzzing/server/server-fuzz-append-data.js create mode 100644 test/fuzzing/server/server-fuzz-split-data.js create mode 100644 test/gc.js create mode 100644 test/get-head-body.js create mode 100644 test/headers-as-array.js create mode 100644 test/headers-crlf.js create mode 100644 test/http-100.js create mode 100644 test/http-req-destroy.js create mode 100644 test/http2-alpn.js create mode 100644 test/http2.js create mode 100644 test/https.js create mode 100644 test/imports/undici-import.ts create mode 100644 test/inflight-and-close.js create mode 100644 test/interceptors/cache-fastimers-fix.js create mode 100644 test/interceptors/cache.js create mode 100644 test/interceptors/dns.js create mode 100644 test/interceptors/dump-interceptor.js create mode 100644 test/interceptors/redirect-issue-3803.js create mode 100644 test/interceptors/redirect.js create mode 100644 test/interceptors/response-error.js create mode 100644 test/interceptors/retry.js create mode 100644 test/invalid-headers.js create mode 100644 test/issue-1757.js create mode 100644 test/issue-2065.js create mode 100644 test/issue-2078.js create mode 100644 test/issue-2283.js create mode 100644 test/issue-2349.js create mode 100644 test/issue-2590.js create mode 100644 test/issue-3356.js create mode 100644 test/issue-3410.js create mode 100644 test/issue-3934.js create mode 100644 test/issue-803.js create mode 100644 test/issue-810.js create mode 100644 test/jest/instanceof-error.test.js create mode 100644 test/jest/issue-1757.test.js create mode 100644 test/jest/mock-agent.test.js create mode 100644 test/jest/mock-scope.test.js create mode 100644 test/jest/test.js create mode 100644 test/max-headers.js create mode 100644 test/max-response-size.js create mode 100644 test/mock-agent.js create mode 100644 test/mock-client.js create mode 100644 test/mock-errors.js create mode 100644 test/mock-interceptor-unused-assertions.js create mode 100644 test/mock-interceptor.js create mode 100644 test/mock-pool.js create mode 100644 test/mock-scope.js create mode 100644 test/mock-utils.js create mode 100644 test/no-strict-content-length.js create mode 100644 test/node-fetch/LICENSE create mode 100644 test/node-fetch/headers.js create mode 100644 test/node-fetch/main.js create mode 100644 test/node-fetch/mock.js create mode 100644 test/node-fetch/request.js create mode 100644 test/node-fetch/response.js create mode 100644 test/node-fetch/utils/dummy.txt create mode 100644 test/node-fetch/utils/read-stream.js create mode 100644 test/node-fetch/utils/server.js create mode 100644 test/node-platform-objects.js create mode 100644 test/node-test/abort-controller.js create mode 100644 test/node-test/abort-event-emitter.js create mode 100644 test/node-test/agent.js create mode 100644 test/node-test/async_hooks.js create mode 100644 test/node-test/autoselectfamily.js create mode 100644 test/node-test/balanced-pool.js create mode 100644 test/node-test/ca-fingerprint.js create mode 100644 test/node-test/client-abort.js create mode 100644 test/node-test/client-connect.js create mode 100644 test/node-test/client-dispatch.js create mode 100644 test/node-test/client-errors.js create mode 100644 test/node-test/debug.js create mode 100644 test/node-test/diagnostics-channel/connect-error.js create mode 100644 test/node-test/diagnostics-channel/error.js create mode 100644 test/node-test/diagnostics-channel/get-h2.js create mode 100644 test/node-test/diagnostics-channel/get.js create mode 100644 test/node-test/diagnostics-channel/post-stream.js create mode 100644 test/node-test/diagnostics-channel/post.js create mode 100644 test/node-test/large-body.js create mode 100644 test/node-test/tree.js create mode 100644 test/node-test/unix.js create mode 100644 test/node-test/util.js create mode 100644 test/node-test/validations.js create mode 100644 test/parser-issues.js create mode 100644 test/pipeline-pipelining.js create mode 100644 test/pool.js create mode 100644 test/promises.js create mode 100644 test/proxy-agent.js create mode 100644 test/proxy.js create mode 100644 test/readable.js create mode 100644 test/redirect-pipeline.js create mode 100644 test/redirect-request.js create mode 100644 test/redirect-stream.js create mode 100644 test/request-crlf.js create mode 100644 test/request-signal.js create mode 100644 test/request-timeout.js create mode 100644 test/request-timeout2.js create mode 100644 test/request.js create mode 100644 test/retry-agent.js create mode 100644 test/retry-handler.js create mode 100644 test/socket-back-pressure.js create mode 100644 test/socket-timeout.js create mode 100644 test/stream-compat.js create mode 100644 test/timers.js create mode 100644 test/tls-session-reuse.js create mode 100644 test/tls.js create mode 100644 test/trailers.js create mode 100644 test/types/agent.test-d.ts create mode 100644 test/types/api.test-d.ts create mode 100644 test/types/balanced-pool.test-d.ts create mode 100644 test/types/cache-interceptor.test-d.ts create mode 100644 test/types/cache-storage.test-d.ts create mode 100644 test/types/client.test-d.ts create mode 100644 test/types/connector.test-d.ts create mode 100644 test/types/diagnostics-channel.test-d.ts create mode 100644 test/types/dispatcher.events.test-d.ts create mode 100644 test/types/dispatcher.test-d.ts create mode 100644 test/types/env-http-proxy-agent.test-d.ts create mode 100644 test/types/errors.test-d.ts create mode 100644 test/types/event-source-d.ts create mode 100644 test/types/fetch.test-d.ts create mode 100644 test/types/formdata.test-d.ts create mode 100644 test/types/global-dispatcher.test-d.ts create mode 100644 test/types/header.test-d.ts create mode 100644 test/types/index.test-d.ts create mode 100644 test/types/mock-agent.test-d.ts create mode 100644 test/types/mock-client.test-d.ts create mode 100644 test/types/mock-errors.test-d.ts create mode 100644 test/types/mock-interceptor.test-d.ts create mode 100644 test/types/mock-pool.test-d.ts create mode 100644 test/types/pool.test-d.ts create mode 100644 test/types/proxy-agent.test-d.ts create mode 100644 test/types/readable.test-d.ts create mode 100644 test/types/retry-agent.test-d.ts create mode 100644 test/types/retry-handler.test-d.ts create mode 100644 test/types/util.test-d.ts create mode 100644 test/util.js create mode 100644 test/utils/async-iterators.js create mode 100644 test/utils/date.js create mode 100644 test/utils/esm-wrapper.mjs create mode 100644 test/utils/event-loop-blocker.js create mode 100644 test/utils/formdata.js create mode 100644 test/utils/hello-world-server.js create mode 100644 test/utils/node-http.js create mode 100644 test/utils/redirecting-servers.js create mode 100644 test/utils/stream.js create mode 100644 test/webidl/converters.js create mode 100644 test/webidl/errors.js create mode 100644 test/webidl/helpers.js create mode 100644 test/webidl/util.js create mode 100644 test/websocket/client-received-masked-frame.js create mode 100644 test/websocket/close-invalid-status-code.js create mode 100644 test/websocket/close-invalid-utf-8.js create mode 100644 test/websocket/close.js create mode 100644 test/websocket/constructor.js create mode 100644 test/websocket/continuation-frames.js create mode 100644 test/websocket/custom-headers.js create mode 100644 test/websocket/diagnostics-channel.js create mode 100644 test/websocket/events.js create mode 100644 test/websocket/fragments.js create mode 100644 test/websocket/frame.js create mode 100644 test/websocket/issue-2679.js create mode 100644 test/websocket/issue-2844.js create mode 100644 test/websocket/issue-2859.js create mode 100644 test/websocket/issue-3202.js create mode 100644 test/websocket/issue-3506.js create mode 100644 test/websocket/issue-3546.js create mode 100644 test/websocket/issue-3697-2399493917.js create mode 100644 test/websocket/messageevent.js create mode 100644 test/websocket/opening-handshake.js create mode 100644 test/websocket/ping-pong.js create mode 100644 test/websocket/receive.js create mode 100644 test/websocket/send-mutable.js create mode 100644 test/websocket/send.js create mode 100644 test/websocket/util.js create mode 100644 test/websocket/websocketinit.js create mode 100644 test/wpt/runner/runner.mjs create mode 100644 test/wpt/runner/util.mjs create mode 100644 test/wpt/runner/worker.mjs create mode 100644 test/wpt/server/constants.mjs create mode 100644 test/wpt/server/lockedresource.mjs create mode 100644 test/wpt/server/routes/network-partition-key.mjs create mode 100644 test/wpt/server/routes/redirect.mjs create mode 100644 test/wpt/server/server.mjs create mode 100644 test/wpt/server/util.mjs create mode 100644 test/wpt/server/websocket.mjs create mode 100644 test/wpt/start-cacheStorage.mjs create mode 100644 test/wpt/start-eventsource.mjs create mode 100644 test/wpt/start-fetch.mjs create mode 100644 test/wpt/start-mimesniff.mjs create mode 100644 test/wpt/start-websockets.mjs create mode 100644 test/wpt/start-xhr.mjs create mode 100644 test/wpt/status/eventsource.status.json create mode 100644 test/wpt/status/fetch.status.json create mode 100644 test/wpt/status/mimesniff.status.json create mode 100644 test/wpt/status/service-workers/cache-storage.status.json create mode 100644 test/wpt/status/websockets.status.json create mode 100644 test/wpt/status/xhr/formdata.status.json create mode 100644 types/README.md create mode 100644 types/agent.d.ts create mode 100644 types/api.d.ts create mode 100644 types/balanced-pool.d.ts create mode 100644 types/cache-interceptor.d.ts create mode 100644 types/cache.d.ts create mode 100644 types/client.d.ts create mode 100644 types/connector.d.ts create mode 100644 types/content-type.d.ts create mode 100644 types/cookies.d.ts create mode 100644 types/diagnostics-channel.d.ts create mode 100644 types/dispatcher.d.ts create mode 100644 types/env-http-proxy-agent.d.ts create mode 100644 types/errors.d.ts create mode 100644 types/eventsource.d.ts create mode 100644 types/fetch.d.ts create mode 100644 types/formdata.d.ts create mode 100644 types/global-dispatcher.d.ts create mode 100644 types/global-origin.d.ts create mode 100644 types/handlers.d.ts create mode 100644 types/header.d.ts create mode 100644 types/index.d.ts create mode 100644 types/interceptors.d.ts create mode 100644 types/mock-agent.d.ts create mode 100644 types/mock-client.d.ts create mode 100644 types/mock-errors.d.ts create mode 100644 types/mock-interceptor.d.ts create mode 100644 types/mock-pool.d.ts create mode 100644 types/patch.d.ts create mode 100644 types/pool-stats.d.ts create mode 100644 types/pool.d.ts create mode 100644 types/proxy-agent.d.ts create mode 100644 types/readable.d.ts create mode 100644 types/retry-agent.d.ts create mode 100644 types/retry-handler.d.ts create mode 100644 types/util.d.ts create mode 100644 types/utility.d.ts create mode 100644 types/webidl.d.ts create mode 100644 types/websocket.d.ts diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..11549ad --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,13 @@ +{ + "all": true, + "reporter": [ + "lcov", + "text", + "html", + "text-summary" + ], + "include": [ + "lib/**/*.js", + "index.js" + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4ad0cf5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Ignore everything but the stuff following the `*` with the `!` +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file + +* +!package.json +!lib +!deps +!build diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c7a0d1f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +# https://editorconfig.org/ + +root = true + +[*] +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..8ff7029 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,34 @@ +--- +name: Bug Report +about: Report an issue +title: '' +labels: bug +assignees: '' + +--- + +## Bug Description + + + +## Reproducible By + + + +## Expected Behavior + + + +## Logs & Screenshots + + + +## Environment + + + +### Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..0c3a4ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,28 @@ +--- +name: Feature Request +about: Make a suggestion on a feature or improvement for the project +title: '' +labels: enhancement +assignees: '' + +--- + +## This would solve... + + + +## The implementation should look like... + + + +## I have also considered... + + + +## Additional context + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2620ffb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,53 @@ + + +## This relates to... + + + +## Rationale + + + +## Changes + + + +### Features + + + +### Bug Fixes + + + +### Breaking Changes and Deprecations + + + +## Status + + + + +- [ ] I have read and agreed to the [Developer's Certificate of Origin][cert] +- [ ] Tested +- [ ] Benchmarked (**optional**) +- [ ] Documented +- [ ] Review ready +- [ ] In review +- [ ] Merge ready + +[cert]: https://github.com/nodejs/undici/blob/main/CONTRIBUTING.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..befcde3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: /docs + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "npm" + directory: /benchmarks + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: docker + directory: /build + schedule: + interval: daily + + - package-ecosystem: pip + directory: /test/wpt/tests/resources/test + schedule: + interval: daily diff --git a/.github/workflows/autobahn.yml b/.github/workflows/autobahn.yml new file mode 100644 index 0000000..2542fed --- /dev/null +++ b/.github/workflows/autobahn.yml @@ -0,0 +1,60 @@ +name: Autobahn + +on: + workflow_dispatch: + workflow_call: + inputs: + node-version: + default: '22' + type: string + pull_request: + paths: + - '.github/workflows/autobahn.yml' + - 'lib/web/websocket/**' + - 'test/autobahn/**' +permissions: + contents: read + +jobs: + autobahn: + name: Autobahn Test Suite + runs-on: ubuntu-latest + container: node:22 + services: + fuzzingserver: + image: crossbario/autobahn-testsuite:latest + ports: + - '9001:9001' + options: --name fuzzingserver + volumes: + - ${{ github.workspace }}/test/autobahn/config:/config + - ${{ github.workspace }}/test/autobahn/reports:/reports + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + clean: false + + - name: Restart Autobahn Server + # Restart service after volumes have been checked out + uses: docker://docker + with: + args: docker restart --time 0 --signal=SIGKILL fuzzingserver + + - name: Setup Node.js@${{ inputs.node-version || '22' }} + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: ${{ inputs.node-version || '22' }} + + - name: Run Autobahn Test Suite + run: npm run test:websocket:autobahn + env: + FUZZING_SERVER_URL: ws://fuzzingserver:9001 + LOG_ON_ERROR: false + + - name: Report CI + id: report-ci + run: npm run test:websocket:autobahn:report + env: + FAIL_ON_ERROR: true diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000..4621cf7 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,29 @@ +name: Backport +on: + pull_request_target: + types: + - closed + - labeled + +permissions: + pull-requests: write + contents: write + +jobs: + backport: + runs-on: ubuntu-latest + if: > + github.event.pull_request.merged + && ( + github.event.action == 'closed' + || ( + github.event.action == 'labeled' + && contains(github.event.label.name, 'backport') + ) + ) + name: Backport + steps: + - name: Backport + uses: tibdex/backport@v2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..f9eac1c --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,137 @@ +name: Benchmarks +on: + - push + - pull_request + +permissions: + contents: read + +jobs: + benchmark_current: + name: benchmark current + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + ref: ${{ github.base_ref }} + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + - name: Install Modules for undici + run: npm i --ignore-scripts --omit=dev + - name: Install Modules for Benchmarks + run: npm i + working-directory: ./benchmarks + - name: Run Benchmark + run: npm run bench + working-directory: ./benchmarks + + benchmark_branch: + name: benchmark branch + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + - name: Install Modules for undici + run: npm i --ignore-scripts --omit=dev + - name: Install Modules for Benchmarks + run: npm i + working-directory: ./benchmarks + - name: Run Benchmark + run: npm run bench + working-directory: ./benchmarks + + benchmark_post_current: + name: benchmark (sending data) current + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + ref: ${{ github.base_ref }} + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + - name: Install Modules for undici + run: npm i --ignore-scripts --omit=dev + - name: Install Modules for Benchmarks + run: npm i + working-directory: ./benchmarks + - name: Run Benchmark + run: npm run bench-post + working-directory: ./benchmarks + + benchmark_post_branch: + name: benchmark (sending data) branch + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + - name: Install Modules for undici + run: npm i --ignore-scripts --omit=dev + - name: Install Modules for Benchmarks + run: npm i + working-directory: ./benchmarks + - name: Run Benchmark + run: npm run bench-post + working-directory: ./benchmarks + + benchmark_current_h2: + name: benchmark current h2 + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + ref: ${{ github.base_ref }} + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + - name: Install Modules for undici + run: npm i --ignore-scripts --omit=dev + - name: Install Modules for Benchmarks + run: npm i + working-directory: ./benchmarks + - name: Run Benchmark + run: npm run bench:h2 + working-directory: ./benchmarks + + benchmark_branch_h2: + name: benchmark branch h2 + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + - name: Install Modules for undici + run: npm i --ignore-scripts --omit=dev + - name: Install Modules for Benchmarks + run: npm i + working-directory: ./benchmarks + - name: Run Benchmark + run: npm run bench:h2 + working-directory: ./benchmarks diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5d2fe29 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + - cron: "0 0 * * 1" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["javascript", "python", "typescript"] + # CodeQL supports [ $supported-codeql-languages ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Harden Runner + uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v2.3.3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v2.3.3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v2.3.3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..7da843a --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,79 @@ +name: Nightly tests + +on: + workflow_dispatch: + schedule: + - cron: "0 10 * * *" + +permissions: + contents: read + +jobs: + test-linux: + if: github.repository == 'nodejs/undici' + uses: ./.github/workflows/test.yml + with: + node-version: 23-nightly + runs-on: ubuntu-latest + secrets: inherit + + test-autobahn: + if: github.repository == 'nodejs/undici' + uses: ./.github/workflows/autobahn.yml + with: + node-version: 23-nightly + secrets: inherit + + test-windows: + if: github.repository == 'nodejs/undici' + uses: ./.github/workflows/test.yml + with: + node-version: 23-nightly + runs-on: windows-latest + secrets: inherit + + test-macos: + if: github.repository == 'nodejs/undici' + uses: ./.github/workflows/test.yml + with: + node-version: 23-nightly + runs-on: macos-latest + secrets: inherit + + report-failure: + if: ${{ always() && (needs.test-linux.result == 'failure' && needs.test-windows.result == 'failure' && needs.test-macos.result == 'failure') }} + needs: + - test-linux + - test-windows + - test-macos + - test-autobahn + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Create or update issue + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const ISSUE_TITLE = "Nightly tests are failing" + + const actionRunUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + const issueContext = { + owner: context.repo.owner, + repo: context.repo.repo + } + + let issue = (await github.rest.issues.listForRepo({ + state: "open", + creator: "github-actions[bot]", + ...issueContext + })).data.find((issue) => issue.title === ISSUE_TITLE) + + if(!issue) { + issue = (await github.rest.issues.create({ + title: ISSUE_TITLE, + body: `Tests against nightly failed, see: ${actionRunUrl}`, + ...issueContext + })).data + } diff --git a/.github/workflows/nodejs-shared.yml b/.github/workflows/nodejs-shared.yml new file mode 100644 index 0000000..172cc72 --- /dev/null +++ b/.github/workflows/nodejs-shared.yml @@ -0,0 +1,102 @@ +name: Node.js compiled --shared-builtin-undici/undici-path CI + +on: + push: + branches: + - main + - current + - next + - 'v*' + pull_request: + +permissions: + contents: read + +jobs: + test-shared-builtin: + name: Test with Node.js ${{ matrix.version }} compiled --shared-builtin-undici/undici-path + strategy: + fail-fast: false + max-parallel: 0 + matrix: + version: [20, 22, 23] + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + # Checkout into a subdirectory otherwise Node.js tests will break due to finding Undici's package.json in a parent directory. + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + path: ./undici + persist-credentials: false + + # Setup node, install deps, and build undici prior to building node with `--shared-builtin-undici/undici-path` and testing + - name: Setup Node.js@${{ inputs.version }} + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: ${{ inputs.version }} + + - name: Install dependencies + working-directory: ./undici + run: npm install + + - name: Install wasi-libc + run: sudo apt-get install -y wasi-libc + + - name: Build WASM + working-directory: ./undici + run: | + export EXTERNAL_PATH=${{ github.workspace }}/undici + export WASM_CC=clang + export WASM_CFLAGS='--target=wasm32-wasi --sysroot=/usr' + export WASM_LDFLAGS='-nodefaultlibs' + export WASM_LDLIBS='-lc' + node build/wasm.js + + - name: Determine latest release + id: release + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + result-encoding: string + script: | + const req = await fetch('https://nodejs.org/download/release/index.json') + const releases = await req.json() + + const latest = releases.find((r) => r.version.startsWith('v${{ matrix.version }}')) + return latest.version + + - name: Download and extract source + run: curl https://nodejs.org/download/release/${{ steps.release.outputs.result }}/node-${{ steps.release.outputs.result }}.tar.xz | tar xfJ - + + - name: Install ninja + run: sudo apt-get install ninja-build + + - name: ccache + uses: hendrikmuhs/ccache-action@ed74d11c0b343532753ecead8a951bb09bb34bc9 #v1.2.14 + with: + key: node(external_undici)${{ matrix.version }} + + - name: Build node + working-directory: ./node-${{ steps.release.outputs.result }} + run: | + export CC="ccache gcc" + export CXX="ccache g++" + rm -rf deps/undici + ./configure --shared-builtin-undici/undici-path ${{ github.workspace }}/undici/loader.js --ninja --prefix=./final + make + make install + echo "$(pwd)/final/bin" >> $GITHUB_PATH + + - name: Print version information + run: | + echo OS: $(node -p "os.version()") + echo Node.js: $(node --version) + echo npm: $(npm --version) + echo git: $(git --version) + echo external config: $(node -e "console.log(process.config)" | grep NODE_SHARED_BUILTIN_UNDICI_UNDICI_PATH) + echo Node.js built-in undici version: $(node -p "process.versions.undici") # undefined for external Undici + + - name: Run tests + working-directory: ./node-${{ steps.release.outputs.result }} + run: tools/test.py -p dots --flaky-tests=dontcare + diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..8076294 --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,223 @@ +name: Node CI + +on: + push: + branches: + - main + - current + - next + - 'v*' + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: Dependency Review + uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + + test: + strategy: + fail-fast: false + max-parallel: 0 + matrix: + node-version: + - 20 + - 22 + - 23 + runs-on: + - ubuntu-latest + - windows-latest + - macos-latest + uses: ./.github/workflows/test.yml + with: + node-version: ${{ matrix.node-version }} + runs-on: ${{ matrix.runs-on }} + secrets: inherit + + test-without-intl: + name: Test with Node.js ${{ matrix.version }} compiled --without-intl + strategy: + fail-fast: false + max-parallel: 0 + matrix: + version: [20, 22, 23] + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + # Setup node, install deps, and build undici prior to building icu-less node and testing + - name: Setup Node.js@${{ inputs.version }} + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: ${{ inputs.version }} + + - name: Install dependencies + run: npm install + + - name: Build undici + run: npm run build:node + + - name: Determine latest release + id: release + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + result-encoding: string + script: | + const req = await fetch('https://nodejs.org/download/release/index.json') + const releases = await req.json() + + const latest = releases.find((r) => r.version.startsWith('v${{ matrix.version }}')) + return latest.version + + - name: Download and extract source + run: curl https://nodejs.org/download/release/${{ steps.release.outputs.result }}/node-${{ steps.release.outputs.result }}.tar.xz | tar xfJ - + + - name: Install ninja + run: sudo apt-get install ninja-build + + - name: ccache + uses: hendrikmuhs/ccache-action@ed74d11c0b343532753ecead8a951bb09bb34bc9 #v1.2.14 + with: + key: node${{ matrix.version }} + + - name: Build node + working-directory: ./node-${{ steps.release.outputs.result }} + run: | + export CC="ccache gcc" + export CXX="ccache g++" + ./configure --without-intl --ninja --prefix=./final + make + make install + echo "$(pwd)/final/bin" >> $GITHUB_PATH + + - name: Print version information + run: | + echo OS: $(node -p "os.version()") + echo Node.js: $(node --version) + echo npm: $(npm --version) + echo git: $(git --version) + echo icu config: $(node -e "console.log(process.config)" | grep icu) + + - name: Run tests + run: npm run test:javascript:without-intl + + test-fuzzing: + name: Fuzzing + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + + - name: Install dependencies + run: npm install + + - name: Run fuzzing tests + run: npm run test:fuzzing + + test-types: + name: Test TypeScript types + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: lts/* + + - name: Install dependencies + run: npm install + + - name: Run typings tests + run: npm run test:typescript + + test-sqlite: + name: Test with SQLite enabled + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: 22 + + - name: Install dependencies + run: npm install + + - name: Run typings tests + run: npm run test:sqlite + + automerge: + if: > + github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' + needs: + - dependency-review + - test + - test-types + - test-without-intl + - test-fuzzing + - lint + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Merge Dependabot PR + uses: fastify/github-action-merge-dependabot@c3bde0759d4f24db16f7b250b2122bc2df57e817 # v3.11.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-create-pr.yml b/.github/workflows/release-create-pr.yml new file mode 100644 index 0000000..bc61291 --- /dev/null +++ b/.github/workflows/release-create-pr.yml @@ -0,0 +1,57 @@ +name: Create release PR + +permissions: + contents: read + +on: + workflow_dispatch: + inputs: + version: + description: 'The version number to release (has priority over release_type)' + type: string + release_type: + description: Type of release + type: choice + default: patch + options: + - patch + - minor + - major + +jobs: + create-pr: + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + outputs: + version: ${{ steps.bump.outputs.version }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Git Config + run: | + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + - name: Change version number and push + id: bump + run: | + npm version ${{ inputs.version || inputs.release_type }} --git-tag-version=false + VERSION=`jq -r ".version" package.json` + RELEASE_BRANCH="release/v$VERSION" + git add -u + git commit -m "Bumped v$VERSION" + git push origin "HEAD:$RELEASE_BRANCH" + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Create PR + uses: actions/github-script@v7 + with: + script: | + const defaultBranch = "${{ github.event.repository.default_branch }}" + const versionTag = "v${{ steps.bump.outputs.version }}" + await require('./scripts/release').generatePr({ github, context, defaultBranch, versionTag }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f71d45 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Create release + +on: + push: + branches: + - main + paths: + - package.json + +permissions: + contents: read + +jobs: + check-release-version: + runs-on: ubuntu-latest + outputs: + release-version: ${{ steps.set-release-version.outputs.result }} + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + id: set-release-version + with: + result-encoding: string + script: | + const { owner, repo } = context.repo + const version = require("./package.json").version + const versionTag = `v${version}` + + const { data: releases } = await github.rest.repos.listReleases({ + owner, + repo + }) + + const previousRelease = releases.find((r) => r.tag_name.startsWith('v7')) + + if (versionTag !== previousRelease?.tag_name) { + return versionTag + } + + release: + runs-on: ubuntu-latest + needs: check-release-version + if: ${{ startsWith(needs.check-release-version.outputs.release-version, 'v') }} + + permissions: + contents: write + id-token: write + + environment: release + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + - run: npm install -g npm@latest + - run: npm install + - name: Create NPM release + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: node scripts/generate-undici-types-package-json.js + - run: npm publish --provenance + working-directory: './types' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Create GitHub release + uses: actions/github-script@v7 + with: + script: | + const defaultBranch = "${{ github.event.repository.default_branch }}" + const versionTag = "${{ needs.check-release-version.outputs.release-version }}" + await require('./scripts/release').release({ github, context, defaultBranch, versionTag }) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..8fb713e --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,56 @@ +# This workflow uses actions that are not certified by GitHub. They are provided +# by a third-party and are governed by separate terms of service, privacy +# policy, and support documentation. + +name: Scorecard supply-chain security +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: '16 10 * * 2' + push: + branches: [ "main" ] + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + + steps: + - name: "Checkout code" + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v3.28.1 + with: + sarif_file: results.sarif diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..968dada --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,64 @@ +name: Run tests + +on: + workflow_call: + inputs: + node-version: + required: true + type: string + runs-on: + required: true + type: string + +permissions: + contents: read + +jobs: + test: + name: Test with Node.js ${{ inputs.node-version }} on ${{ inputs.runs-on }} + timeout-minutes: 15 + runs-on: ${{ inputs.runs-on }} + steps: + - name: Checkout + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + persist-credentials: false + + - name: Setup Node.js@${{ inputs.node-version }} + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 + with: + node-version: ${{ inputs.node-version }} + + - name: Print version information + run: | + echo OS: $(node -p "os.version()") + echo Node.js: $(node --version) + echo npm: $(npm --version) + echo git: $(git --version) + + - name: Install dependencies + run: npm install + + - name: Print installed dependencies + run: npm ls --all + continue-on-error: true + + - name: Run tests with coverage + id: coverage + if: inputs.runs-on == 'ubuntu-latest' && inputs.node-version == 22 + run: npm run coverage:ci + env: + CI: true + NODE_V8_COVERAGE: ./coverage/tmp + + - name: Run tests + if: steps.coverage.outcome == 'skipped' + run: npm run test:javascript + env: + CI: true + + - name: Coverage Report + if: inputs.runs-on == 'ubuntu-latest' && inputs.node-version == 20 + uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/triggered-autobahn.yml b/.github/workflows/triggered-autobahn.yml new file mode 100644 index 0000000..d8100bb --- /dev/null +++ b/.github/workflows/triggered-autobahn.yml @@ -0,0 +1,16 @@ +name: Autobahn + +on: + pull_request: + types: + - labeled + +permissions: + contents: read + pull-requests: write + +jobs: + autobahn: + if: ${{ github.event.label.name == 'autobahn' }} + name: Autobahn Test Suite + uses: ./.github/workflows/autobahn.yml diff --git a/.github/workflows/update-cache-tests.yml b/.github/workflows/update-cache-tests.yml new file mode 100644 index 0000000..317aa5f --- /dev/null +++ b/.github/workflows/update-cache-tests.yml @@ -0,0 +1,45 @@ +name: Update Cache Tests + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +permissions: + contents: write + pull-requests: write + +jobs: + update-cache-tests: + name: Update Cache Tests + runs-on: ubuntu-latest + steps: + - name: Git Config + run: | + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Checkout Repository + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + + - name: Update Cache Tests + run: | + rm -rf test/fixtures/cache-tests && mkdir test/fixtures/cache-tests && + + git clone https://github.com/http-tests/cache-tests --depth=1 test/fixtures/tmp-cache-tests/ && + + mv test/fixtures/tmp-cache-tests/LICENSE test/fixtures/cache-tests/LICENSE && + mv test/fixtures/tmp-cache-tests/tests test/fixtures/cache-tests/tests && + mv test/fixtures/tmp-cache-tests/test-engine test/fixtures/cache-tests/test-engine && + mv test/fixtures/tmp-cache-tests/results test/fixtures/cache-tests/results && + + rm -rf test/fixtures/tmp-cache-tests/ + + - name: Create Pull Request + uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7.0.6 + with: + base: main + branch: cache-tests-update + title: Update Cache Tests + body: Automated update of the Cache Test Suite + commit-message: "chore: update cache tests" diff --git a/.github/workflows/update-wpt.yml b/.github/workflows/update-wpt.yml new file mode 100644 index 0000000..c61f620 --- /dev/null +++ b/.github/workflows/update-wpt.yml @@ -0,0 +1,51 @@ +name: Update WPT + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +permissions: + contents: write + pull-requests: write + +jobs: + update-wpt: + name: Update WPT + runs-on: ubuntu-latest + steps: + - name: Git Config + run: | + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + - name: Checkout Repository + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + - name: Update WPT + run: | + rm -rf test/fixtures/wpt && mkdir test/fixtures/wpt && + + git clone https://github.com/web-platform-tests/wpt.git --depth=1 test/fixtures/tmp-wpt && + + mv test/fixtures/tmp-wpt/LICENSE.md test/fixtures/wpt/LICENSE.md && + + mv test/fixtures/tmp-wpt/common test/fixtures/wpt/common && + mv test/fixtures/tmp-wpt/eventsource test/fixtures/wpt/eventsource && + mv test/fixtures/tmp-wpt/fetch test/fixtures/wpt/fetch && + mv test/fixtures/tmp-wpt/interfaces test/fixtures/wpt/interfaces && + mv test/fixtures/tmp-wpt/mimesniff test/fixtures/wpt/mimesniff && + mv test/fixtures/tmp-wpt/resources test/fixtures/wpt/resources && + mv test/fixtures/tmp-wpt/service-workers test/fixtures/wpt/service-workers && + mv test/fixtures/tmp-wpt/storage test/fixtures/wpt/storage && + mv test/fixtures/tmp-wpt/websockets test/fixtures/wpt/websockets && + mv test/fixtures/tmp-wpt/xhr test/fixtures/wpt/xhr && + + rm -rf test/fixtures/tmp-wpt + - name: Create Pull Request + uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7.0.6 + with: + base: main + branch: wpt-update + title: Update WPT + body: Automated update of the WPT + commit-message: "chore: update WPT" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cba7df --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +# lock files +package-lock.json +yarn.lock + +# IDE files +.idea +.vscode + +*0x +*clinic* + +# Fuzzing +corpus/ +crash-* +fuzz-results-*.json + +# Bundle output +undici-fetch.js +/test/imports/undici-import.js + +# .npmrc has platform specific value for windows +.npmrc + +.tap + +# File generated by /test/request-timeout.js +test/request-timeout.10mb.bin diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..3867a0f --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npm run lint diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..461d334 --- /dev/null +++ b/.npmignore @@ -0,0 +1,16 @@ +* +!lib/**/* +!index.js +!index-fetch.js + +# The wasm files are stored as base64 strings in the corresponding .js files +lib/llhttp/llhttp_simd.wasm +lib/llhttp/llhttp.wasm + +!types/**/* +!index.d.ts +!docs/docs/**/* +!scripts/strip-comments.js + +# File generated by /test/request-timeout.js +test/request-timeout.10mb.bin diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..cb674bc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,6 @@ +# Code of Conduct + +Undici is committed to upholding the Node.js Code of Conduct. + +The Node.js Code of Conduct document can be found at +https://github.com/nodejs/admin/blob/main/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ce0b68d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,213 @@ +# Contributing to Undici + +* [Guides](#guides) + * [Update `llhttp`](#update-llhttp) + * [Lint](#lint) + * [Test](#test) + * [Coverage](#coverage) + * [Releases](#releases) + * [Update `WPTs`](#update-wpts) + * [Building for externally shared node builtins](#external-builds) + * [Benchmarks](#benchmarks) + * [Documentation](#documentation) +* [Developer's Certificate of Origin 1.1](#developers-certificate-of-origin) + * [Moderation Policy](#moderation-policy) + + +## Guides + +This is a collection of guides on how to run and update `undici`, and how to run different parts of the project. + + +### Update `llhttp` + +The HTTP parser used by `undici` is a WebAssembly build of [`llhttp`](https://github.com/nodejs/llhttp). + +While the project itself provides a way to compile targeting WebAssembly, at the moment we embed the sources +directly and compile the module in `undici`. + +The `deps/llhttp/include` folder contains the C header files, while the `deps/llhttp/src` folder contains +the C source files needed to compile the module. + +The `lib/llhttp` folder contains the `.js` transpiled assets required to implement a parser. + +The following are the steps required to perform an update. + +#### Clone the [llhttp](https://github.com/nodejs/llhttp) project + +```bash +git clone git@github.com:nodejs/llhttp.git + +cd llhttp +``` + +#### Checkout a `llhttp` release + +```bash +git checkout +``` + +#### Install the `llhttp` dependencies + +```bash +npm i +``` + +#### Run the wasm build script + +> This requires [docker](https://www.docker.com/) installed on your machine. + +```bash +npm run build-wasm +``` + +#### Copy the sources to `undici` + +```bash +cp build/wasm/*.js /lib/llhttp/ + +cp build/wasm/*.js.map /lib/llhttp/ + +cp build/wasm/*.d.ts /lib/llhttp/ + +cp src/native/api.c src/native/http.c build/c/llhttp.c /deps/llhttp/src/ + +cp src/native/api.h build/llhttp.h /deps/llhttp/include/ +``` + +#### Build the WebAssembly module in `undici` + +> This requires [docker](https://www.docker.com/) installed on your machine. + +```bash +cd + +npm run build:wasm +``` + +#### Commit the contents of lib/llhttp + +Create a commit which includes all of the updated files in lib/llhttp. + + +### Update `WPTs` + +`undici` runs a subset of the [`web-platform-tests`](https://github.com/web-platform-tests/wpt). + +### Requirements: +- [Node core utils](https://github.com/nodejs/node-core-utils) setup with credentials. + +To update every test, run the following commands. Typically you would only need to update the tests in a specific directory. + +```bash +git node wpt resources +git node wpt interfaces +git node wpt common +git node wpt fetch +git node wpt xhr +git node wpt websockets +git node wpt mimesniff +git node wpt storage +git node wpt service-workers +git node wpt eventsource +``` + +#### Run the tests + +Run the tests to ensure that any new failures are marked as such. + +You can mark tests as failing in their corresponding [status](./test/wpt/status) file. + +```bash +npm run test:wpt +``` + + +### Lint + +```bash +npm run lint +``` + + +### Test + +```bash +npm run test +``` + + +### Coverage + +```bash +npm run coverage +``` + + +### Issuing Releases + +Release is automatic on commit to main which bumps the package.json version field. +Use the "Create release PR" github action to generate a release PR. + + +### Building for externally shared node builtins + +If you are packaging `undici` for a distro, this might help if you would like to use +an unbundled version instead of bundling one in `libnode.so`. + +To enable this, pass `EXTERNAL_PATH=/path/to/global/node_modules/undici` to `build/wasm.js`. +Pass this path with `loader.js` appended to `--shared-builtin-undici/undici-path` in Node.js's `configure.py`. +If building on a non-Alpine Linux distribution, you may need to also set the `WASM_CC`, `WASM_CFLAGS`, `WASM_LDFLAGS` and `WASM_LDLIBS` environment variables before running `build/wasm.js`. +Similarly, you can set the `WASM_OPT` environment variable to utilize your own `wasm-opt` optimizer. + + +### Benchmarks + +```bash +cd benchmarks && npm i && npm run bench +``` + +The benchmarks will be available at `http://localhost:3042`. + + +### Documentation + +```bash +cd docs && npm i && npm run serve +``` + +The documentation will be available at `http://localhost:3000`. + + +## Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +* (a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +* (b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +* (c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +* (d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + + +### Moderation Policy + +The [Node.js Moderation Policy] applies to this project. + +[Node.js Moderation Policy]: https://github.com/nodejs/admin/blob/main/Moderation-Policy.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..dc1223c --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,136 @@ +### Undici Working Group + +The Node.js Undici project is governed by a Working Group (WG) +that is responsible for high-level guidance of the project. + +The WG has final authority over this project including: + +* Technical direction +* Project governance and process (including this policy) +* Contribution policy +* GitHub repository hosting +* Conduct guidelines +* Maintaining the list of additional Collaborators + +For the current list of WG members, see the project +[README.md](./README.md#collaborators). + +### Collaborators + +The undici GitHub repository is +maintained by the WG and additional Collaborators who are added by the +WG on an ongoing basis. + +Individuals making significant and valuable contributions are made +Collaborators and given commit-access to the project. These +individuals are identified by the WG and their addition as +Collaborators is discussed during the WG meeting. + +_Note:_ If you make a significant contribution and are not considered +for commit-access log an issue or contact a WG member directly and it +will be brought up in the next WG meeting. + +Modifications of the contents of the undici repository are +made on +a collaborative basis. Anybody with a GitHub account may propose a +modification via pull request and it will be considered by the project +Collaborators. All pull requests must be reviewed and accepted by a +Collaborator with sufficient expertise who is able to take full +responsibility for the change. In the case of pull requests proposed +by an existing Collaborator, an additional Collaborator is required +for sign-off. Consensus should be sought if additional Collaborators +participate and there is disagreement around a particular +modification. See _Consensus Seeking Process_ below for further detail +on the consensus model used for governance. + +Collaborators may opt to elevate significant or controversial +modifications, or modifications that have not found consensus to the +WG for discussion by assigning the ***WG-agenda*** tag to a pull +request or issue. The WG should serve as the final arbiter where +required. + +For the current list of Collaborators, see the project +[README.md](./README.md#collaborators). The list should be in +alphabetical order. + +### WG Membership + +WG seats are not time-limited. There is no fixed size of the WG. +However, the expected target is between 6 and 12, to ensure adequate +coverage of important areas of expertise, balanced with the ability to +make decisions efficiently. + +There is no specific set of requirements or qualifications for WG +membership beyond these rules. + +The WG may add additional members to the WG by unanimous consensus. + +A WG member may be removed from the WG by voluntary resignation, or by +unanimous consensus of all other WG members. + +Changes to WG membership should be posted in the agenda, and may be +suggested as any other agenda item (see "WG Meetings" below). + +If an addition or removal is proposed during a meeting, and the full +WG is not in attendance to participate, then the addition or removal +is added to the agenda for the subsequent meeting. This is to ensure +that all members are given the opportunity to participate in all +membership decisions. If a WG member is unable to attend a meeting +where a planned membership decision is being made, then their consent +is assumed. + +No more than 1/3 of the WG members may be affiliated with the same +employer. If removal or resignation of a WG member, or a change of +employment by a WG member, creates a situation where more than 1/3 of +the WG membership shares an employer, then the situation must be +immediately remedied by the resignation or removal of one or more WG +members affiliated with the over-represented employer(s). + +### WG Meetings + +The WG meets occasionally on Zoom. A designated moderator +approved by the WG runs the meeting. Each meeting should be +published to YouTube. + +Items are added to the WG agenda that are considered contentious or +are modifications of governance, contribution policy, WG membership, +or release process. + +The intention of the agenda is not to approve or review all patches; +that should happen continuously on GitHub and be handled by the larger +group of Collaborators. + +Any community member or contributor can ask that something be added to +the next meeting's agenda by logging a GitHub Issue. Any Collaborator, +WG member or the moderator can add the item to the agenda by adding +the ***WG-agenda*** tag to the issue. + +Prior to each WG meeting the moderator will share the Agenda with +members of the WG. WG members can add any items they like to the +agenda at the beginning of each meeting. The moderator and the WG +cannot veto or remove items. + +The WG may invite persons or representatives from certain projects to +participate in a non-voting capacity. + +The moderator is responsible for summarizing the discussion of each +agenda item and sends it as a pull request after the meeting. + +### Consensus Seeking Process + +The WG follows a +[Consensus +Seeking](http://en.wikipedia.org/wiki/Consensus-seeking_decision-making) +decision-making model. + +When an agenda item has appeared to reach a consensus the moderator +will ask "Does anyone object?" as a final call for dissent from the +consensus. + +If an agenda item cannot reach a consensus a WG member can call for +either a closing vote or a vote to table the issue to the next +meeting. The call for a vote must be seconded by a majority of the WG +or else the discussion will continue. Simple majority wins. + +Note that changes to WG membership require a majority consensus. See +"WG Membership" above. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7323bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Matteo Collina and Undici contributors + +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/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..b98d904 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,33 @@ +# Maintainers + +This document details any and all processes relevant to project maintainers. Maintainers should feel empowered to contribute back to this document with any process changes they feel improve the overall experience for themselves and other maintainers. + +## Labels + +Maintainers are encouraged to use the extensive and detailed list of labels for easier repo management. + +* Generally, all issues should be labelled. The most general labels are `bug`, `enhancement`, and `Status: help-wanted`. +* Issues specific to a certain aspect of the project should be labeled using one of the specificity labels listed below. For example, a bug in the `Client` class should have the `Client` and `bug` label assigned. + * Specificity labels: + * `Agent` + * `Client` + * `Docs` + * `Performance` + * `Pool` + * `Tests` + * `Types` +* Any `question` or `usage help` issues should be converted into Q&A Discussions +* `Status:` labels should be added to all open issues indicating their relative development status. + * Status labels: + * `Status: blocked` + * `Status: help-wanted` + * `Status: in-progress` + * `Status: wontfix` +* Issues and/or pull requests with an agreed upon semver status can be assigned the appropriate `semver-` label. + * Semver labels: + * `semver-major` + * `semver-minor` + * `semver-patch` +* Issues with a low-barrier of entry should be assigned the `good first issue` label. +* Do not use the `invalid` label, instead use `bug` or `Status: wontfix`. +* Duplicate issues should initially be assigned the `duplicate` label. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b47a5fe --- /dev/null +++ b/README.md @@ -0,0 +1,466 @@ +# undici + +[![Node CI](https://github.com/nodejs/undici/actions/workflows/nodejs.yml/badge.svg)](https://github.com/nodejs/undici/actions/workflows/nodejs.yml) [![neostandard javascript style](https://img.shields.io/badge/neo-standard-7fffff?style=flat\&labelColor=ff80ff)](https://github.com/neostandard/neostandard) [![npm version](https://badge.fury.io/js/undici.svg)](https://badge.fury.io/js/undici) [![codecov](https://codecov.io/gh/nodejs/undici/branch/main/graph/badge.svg?token=yZL6LtXkOA)](https://codecov.io/gh/nodejs/undici) + +An HTTP/1.1 client, written from scratch for Node.js. + +> Undici means eleven in Italian. 1.1 -> 11 -> Eleven -> Undici. +It is also a Stranger Things reference. + +## How to get involved + +Have a question about using Undici? Open a [Q&A Discussion](https://github.com/nodejs/undici/discussions/new) or join our official OpenJS [Slack](https://openjs-foundation.slack.com/archives/C01QF9Q31QD) channel. + +Looking to contribute? Start by reading the [contributing guide](./CONTRIBUTING.md) + +## Install + +``` +npm i undici +``` + +## Benchmarks + +The benchmark is a simple getting data [example](https://github.com/nodejs/undici/blob/main/benchmarks/benchmark.js) using a +50 TCP connections with a pipelining depth of 10 running on Node 22.11.0. + +``` +┌────────────────────────┬─────────┬────────────────────┬────────────┬─────────────────────────┐ +│ Tests │ Samples │ Result │ Tolerance │ Difference with slowest │ +├────────────────────────┼─────────┼────────────────────┼────────────┼─────────────────────────┤ +│ 'axios' │ 15 │ '5708.26 req/sec' │ '± 2.91 %' │ '-' │ +│ 'http - no keepalive' │ 10 │ '5809.80 req/sec' │ '± 2.30 %' │ '+ 1.78 %' │ +│ 'request' │ 30 │ '5828.80 req/sec' │ '± 2.91 %' │ '+ 2.11 %' │ +│ 'undici - fetch' │ 40 │ '5903.78 req/sec' │ '± 2.87 %' │ '+ 3.43 %' │ +│ 'node-fetch' │ 10 │ '5945.40 req/sec' │ '± 2.13 %' │ '+ 4.15 %' │ +│ 'got' │ 35 │ '6511.45 req/sec' │ '± 2.84 %' │ '+ 14.07 %' │ +│ 'http - keepalive' │ 65 │ '9193.24 req/sec' │ '± 2.92 %' │ '+ 61.05 %' │ +│ 'superagent' │ 35 │ '9339.43 req/sec' │ '± 2.95 %' │ '+ 63.61 %' │ +│ 'undici - pipeline' │ 50 │ '13364.62 req/sec' │ '± 2.93 %' │ '+ 134.13 %' │ +│ 'undici - stream' │ 95 │ '18245.36 req/sec' │ '± 2.99 %' │ '+ 219.63 %' │ +│ 'undici - request' │ 50 │ '18340.17 req/sec' │ '± 2.84 %' │ '+ 221.29 %' │ +│ 'undici - dispatch' │ 40 │ '22234.42 req/sec' │ '± 2.94 %' │ '+ 289.51 %' │ +└────────────────────────┴─────────┴────────────────────┴────────────┴─────────────────────────┘ +``` + +## Quick Start + +```js +import { request } from 'undici' + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) +console.log('headers', headers) + +for await (const data of body) { console.log('data', data) } + +console.log('trailers', trailers) +``` + +## Body Mixins + +The `body` mixins are the most common way to format the request/response body. Mixins include: + +- [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) +- [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) +- [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) +- [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) + +> [!NOTE] +> The body returned from `undici.request` does not implement `.formData()`. + +Example usage: + +```js +import { request } from 'undici' + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) +console.log('headers', headers) +console.log('data', await body.json()) +console.log('trailers', trailers) +``` + +_Note: Once a mixin has been called then the body cannot be reused, thus calling additional mixins on `.body`, e.g. `.body.json(); .body.text()` will result in an error `TypeError: unusable` being thrown and returned through the `Promise` rejection._ + +Should you need to access the `body` in plain-text after using a mixin, the best practice is to use the `.text()` mixin first and then manually parse the text to the desired format. + +For more information about their behavior, please reference the body mixin from the [Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin). + +## Common API Methods + +This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site. + +### `undici.request([url, options]): Promise` + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`RequestOptions`](./docs/docs/api/Dispatcher.md#parameter-requestoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **method** `String` - Default: `PUT` if `options.body`, otherwise `GET` + +Returns a promise with the result of the `Dispatcher.request` method. + +Calls `options.dispatcher.request(options)`. + +See [Dispatcher.request](./docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback) for more details, and [request examples](./docs/examples/README.md) for examples. + +### `undici.stream([url, options, ]factory): Promise` + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`StreamOptions`](./docs/docs/api/Dispatcher.md#parameter-streamoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **method** `String` - Default: `PUT` if `options.body`, otherwise `GET` +* **factory** `Dispatcher.stream.factory` + +Returns a promise with the result of the `Dispatcher.stream` method. + +Calls `options.dispatcher.stream(options, factory)`. + +See [Dispatcher.stream](./docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback) for more details. + +### `undici.pipeline([url, options, ]handler): Duplex` + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`PipelineOptions`](./docs/docs/api/Dispatcher.md#parameter-pipelineoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) + * **method** `String` - Default: `PUT` if `options.body`, otherwise `GET` +* **handler** `Dispatcher.pipeline.handler` + +Returns: `stream.Duplex` + +Calls `options.dispatch.pipeline(options, handler)`. + +See [Dispatcher.pipeline](./docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler) for more details. + +### `undici.connect([url, options]): Promise` + +Starts two-way communications with the requested resource using [HTTP CONNECT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT). + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`ConnectOptions`](./docs/docs/api/Dispatcher.md#parameter-connectoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) +* **callback** `(err: Error | null, data: ConnectData | null) => void` (optional) + +Returns a promise with the result of the `Dispatcher.connect` method. + +Calls `options.dispatch.connect(options)`. + +See [Dispatcher.connect](./docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback) for more details. + +### `undici.fetch(input[, init]): Promise` + +Implements [fetch](https://fetch.spec.whatwg.org/#fetch-method). + +* https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch +* https://fetch.spec.whatwg.org/#fetch-method + +Basic usage example: + +```js +import { fetch } from 'undici' + + +const res = await fetch('https://example.com') +const json = await res.json() +console.log(json) +``` + +You can pass an optional dispatcher to `fetch` as: + +```js +import { fetch, Agent } from 'undici' + +const res = await fetch('https://example.com', { + // Mocks are also supported + dispatcher: new Agent({ + keepAliveTimeout: 10, + keepAliveMaxTimeout: 10 + }) +}) +const json = await res.json() +console.log(json) +``` + +#### `request.body` + +A body can be of the following types: + +- ArrayBuffer +- ArrayBufferView +- AsyncIterables +- Blob +- Iterables +- String +- URLSearchParams +- FormData + +In this implementation of fetch, ```request.body``` now accepts ```Async Iterables```. It is not present in the [Fetch Standard](https://fetch.spec.whatwg.org). + +```js +import { fetch } from 'undici' + +const data = { + async *[Symbol.asyncIterator]() { + yield 'hello' + yield 'world' + }, +} + +await fetch('https://example.com', { body: data, method: 'POST', duplex: 'half' }) +``` + +[FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData) besides text data and buffers can also utilize streams via [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) objects: + +```js +import { openAsBlob } from 'node:fs' + +const file = await openAsBlob('./big.csv') +const body = new FormData() +body.set('file', file, 'big.csv') + +await fetch('http://example.com', { method: 'POST', body }) +``` + +#### `request.duplex` + +- `'half'` + +In this implementation of fetch, `request.duplex` must be set if `request.body` is `ReadableStream` or `Async Iterables`, however, even though the value must be set to `'half'`, it is actually a _full_ duplex. For more detail refer to the [Fetch Standard](https://fetch.spec.whatwg.org/#dom-requestinit-duplex). + +#### `response.body` + +Nodejs has two kinds of streams: [web streams](https://nodejs.org/api/webstreams.html), which follow the API of the WHATWG web standard found in browsers, and an older Node-specific [streams API](https://nodejs.org/api/stream.html). `response.body` returns a readable web stream. If you would prefer to work with a Node stream you can convert a web stream using `.fromWeb()`. + +```js +import { fetch } from 'undici' +import { Readable } from 'node:stream' + +const response = await fetch('https://example.com') +const readableWebStream = response.body +const readableNodeStream = Readable.fromWeb(readableWebStream) +``` + +#### Specification Compliance + +This section documents parts of the [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does +not support or does not fully implement. + +##### Garbage Collection + +* https://fetch.spec.whatwg.org/#garbage-collection + +The [Fetch Standard](https://fetch.spec.whatwg.org) allows users to skip consuming the response body by relying on +[garbage collection](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management#garbage_collection) to release connection resources. Undici does not do the same. Therefore, it is important to always either consume or cancel the response body. + +Garbage collection in Node is less aggressive and deterministic +(due to the lack of clear idle periods that browsers have through the rendering refresh rate) +which means that leaving the release of connection resources to the garbage collector can lead +to excessive connection usage, reduced performance (due to less connection re-use), and even +stalls or deadlocks when running out of connections. + +```js +// Do +const { body, headers } = await fetch(url); +for await (const chunk of body) { + // force consumption of body +} + +// Do not +const { headers } = await fetch(url); +``` + +The same applies for `request` too: +```js +// Do +const { body, headers } = await request(url); +await res.body.dump(); // force consumption of body + +// Do not +const { headers } = await request(url); +``` + +However, if you want to get only headers, it might be better to use `HEAD` request method. Usage of this method will obviate the need for consumption or cancelling of the response body. See [MDN - HTTP - HTTP request methods - HEAD](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD) for more details. + +```js +const headers = await fetch(url, { method: 'HEAD' }) + .then(res => res.headers) +``` + +##### Forbidden and Safelisted Header Names + +* https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name +* https://fetch.spec.whatwg.org/#forbidden-header-name +* https://fetch.spec.whatwg.org/#forbidden-response-header-name +* https://github.com/wintercg/fetch/issues/6 + +The [Fetch Standard](https://fetch.spec.whatwg.org) requires implementations to exclude certain headers from requests and responses. In browser environments, some headers are forbidden so the user agent remains in full control over them. In Undici, these constraints are removed to give more control to the user. + +### `undici.upgrade([url, options]): Promise` + +Upgrade to a different protocol. See [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details. + +Arguments: + +* **url** `string | URL | UrlObject` +* **options** [`UpgradeOptions`](./docs/docs/api/Dispatcher.md#parameter-upgradeoptions) + * **dispatcher** `Dispatcher` - Default: [getGlobalDispatcher](#undicigetglobaldispatcher) +* **callback** `(error: Error | null, data: UpgradeData) => void` (optional) + +Returns a promise with the result of the `Dispatcher.upgrade` method. + +Calls `options.dispatcher.upgrade(options)`. + +See [Dispatcher.upgrade](./docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback) for more details. + +### `undici.setGlobalDispatcher(dispatcher)` + +* dispatcher `Dispatcher` + +Sets the global dispatcher used by Common API Methods. + +### `undici.getGlobalDispatcher()` + +Gets the global dispatcher used by Common API Methods. + +Returns: `Dispatcher` + +### `undici.setGlobalOrigin(origin)` + +* origin `string | URL | undefined` + +Sets the global origin used in `fetch`. + +If `undefined` is passed, the global origin will be reset. This will cause `Response.redirect`, `new Request()`, and `fetch` to throw an error when a relative path is passed. + +```js +setGlobalOrigin('http://localhost:3000') + +const response = await fetch('/api/ping') + +console.log(response.url) // http://localhost:3000/api/ping +``` + +### `undici.getGlobalOrigin()` + +Gets the global origin used in `fetch`. + +Returns: `URL` + +### `UrlObject` + +* **port** `string | number` (optional) +* **path** `string` (optional) +* **pathname** `string` (optional) +* **hostname** `string` (optional) +* **origin** `string` (optional) +* **protocol** `string` (optional) +* **search** `string` (optional) + +## Specification Compliance + +This section documents parts of the HTTP/1.1 specification that Undici does +not support or does not fully implement. + +### Expect + +Undici does not support the `Expect` request header field. The request +body is always immediately sent and the `100 Continue` response will be +ignored. + +Refs: https://tools.ietf.org/html/rfc7231#section-5.1.1 + +### Pipelining + +Undici will only use pipelining if configured with a `pipelining` factor +greater than `1`. Also it is important to pass `blocking: false` to the +request options to properly pipeline requests. + +Undici always assumes that connections are persistent and will immediately +pipeline requests, without checking whether the connection is persistent. +Hence, automatic fallback to HTTP/1.0 or HTTP/1.1 without pipelining is +not supported. + +Undici will immediately pipeline when retrying requests after a failed +connection. However, Undici will not retry the first remaining requests in +the prior pipeline and instead error the corresponding callback/promise/stream. + +Undici will abort all running requests in the pipeline when any of them are +aborted. + +* Refs: https://tools.ietf.org/html/rfc2616#section-8.1.2.2 +* Refs: https://tools.ietf.org/html/rfc7230#section-6.3.2 + +### Manual Redirect + +Since it is not possible to manually follow an HTTP redirect on the server-side, +Undici returns the actual response instead of an `opaqueredirect` filtered one +when invoked with a `manual` redirect. This aligns `fetch()` with the other +implementations in Deno and Cloudflare Workers. + +Refs: https://fetch.spec.whatwg.org/#atomic-http-redirect-handling + +## Workarounds + +### Network address family autoselection. + +If you experience problem when connecting to a remote server that is resolved by your DNS servers to a IPv6 (AAAA record) +first, there are chances that your local router or ISP might have problem connecting to IPv6 networks. In that case +undici will throw an error with code `UND_ERR_CONNECT_TIMEOUT`. + +If the target server resolves to both a IPv6 and IPv4 (A records) address and you are using a compatible Node version +(18.3.0 and above), you can fix the problem by providing the `autoSelectFamily` option (support by both `undici.request` +and `undici.Agent`) which will enable the family autoselection algorithm when establishing the connection. + +## Collaborators + +* [__Daniele Belardi__](https://github.com/dnlup), +* [__Ethan Arrowood__](https://github.com/ethan-arrowood), +* [__Matteo Collina__](https://github.com/mcollina), +* [__Matthew Aitken__](https://github.com/KhafraDev), +* [__Robert Nagy__](https://github.com/ronag), +* [__Szymon Marczak__](https://github.com/szmarczak), + +## Past Collaborators +* [__Tomas Della Vedova__](https://github.com/delvedor), + +### Releasers + +* [__Ethan Arrowood__](https://github.com/ethan-arrowood), +* [__Matteo Collina__](https://github.com/mcollina), +* [__Robert Nagy__](https://github.com/ronag), +* [__Matthew Aitken__](https://github.com/KhafraDev), + +## Long Term Support + +Undici aligns with the Node.js LTS schedule. The following table shows the supported versions: + +| Version | Node.js | End of Life | +|---------|-------------|-------------| +| 5.x | v18.x | 2024-04-30 | +| 6.x | v20.x v22.x | 2026-04-30 | +| 7.x | v24.x | 2027-04-30 | + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dc5499a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,2 @@ +If you believe you have found a security issue in the software in this +repository, please consult https://github.com/nodejs/node/blob/HEAD/SECURITY.md. diff --git a/benchmarks/_util/index.js b/benchmarks/_util/index.js new file mode 100644 index 0000000..75f9035 --- /dev/null +++ b/benchmarks/_util/index.js @@ -0,0 +1,53 @@ +'use strict' + +const parallelRequests = parseInt(process.env.PARALLEL, 10) || 100 + +function makeParallelRequests (cb) { + const promises = new Array(parallelRequests) + for (let i = 0; i < parallelRequests; ++i) { + promises[i] = new Promise(cb) + } + return Promise.all(promises) +} + +function printResults (results) { + // Sort results by least performant first, then compare relative performances and also printing padding + let last + + const rows = Object.entries(results) + // If any failed, put on the top of the list, otherwise order by mean, ascending + .sort((a, b) => (!a[1].success ? -1 : b[1].mean - a[1].mean)) + .map(([name, result]) => { + if (!result.success) { + return { + Tests: name, + Samples: result.size, + Result: 'Errored', + Tolerance: 'N/A', + 'Difference with Slowest': 'N/A' + } + } + + // Calculate throughput and relative performance + const { size, mean, standardError } = result + const relative = last !== 0 ? (last / mean - 1) * 100 : 0 + + // Save the slowest for relative comparison + if (typeof last === 'undefined') { + last = mean + } + + return { + Tests: name, + Samples: size, + Result: `${((parallelRequests * 1e9) / mean).toFixed(2)} req/sec`, + Tolerance: `± ${((standardError / mean) * 100).toFixed(2)} %`, + 'Difference with slowest': + relative > 0 ? `+ ${relative.toFixed(2)} %` : '-' + } + }) + + return console.table(rows) +} + +module.exports = { makeParallelRequests, printResults } diff --git a/benchmarks/api/util.mjs b/benchmarks/api/util.mjs new file mode 100644 index 0000000..34c5401 --- /dev/null +++ b/benchmarks/api/util.mjs @@ -0,0 +1,37 @@ +import { bench, group, run } from 'mitata' +import { isContentTypeText, isContentTypeApplicationJson } from '../../lib/api/util.js' + +const html = 'text/html' +const json = 'application/json; charset=UTF-8' + +group('isContentTypeText', () => { + bench(`isContentTypeText('${html}')`, () => { + return isContentTypeText(html) + }) + bench(`isContentTypeText('${json}')`, () => { + return isContentTypeText(json) + }) + bench('html.startsWith(\'text/\')', () => { + return html.startsWith('text/') + }) + bench('json.startsWith(\'text/\')', () => { + return json.startsWith('text/') + }) +}) + +group('isContentTypeApplicationJson', () => { + bench(`isContentTypeApplicationJson('${html}')`, () => { + return isContentTypeApplicationJson(html) + }) + bench(`isContentTypeApplicationJson('${json}')`, () => { + return isContentTypeApplicationJson(json) + }) + bench('html.startsWith(\'application/json\')', () => { + return html.startsWith('application/json') + }) + bench('json.startsWith(\'application/json\')', () => { + return json.startsWith('application/json') + }) +}) + +await run() diff --git a/benchmarks/benchmark-http2.js b/benchmarks/benchmark-http2.js new file mode 100644 index 0000000..46ac89f --- /dev/null +++ b/benchmarks/benchmark-http2.js @@ -0,0 +1,283 @@ +'use strict' + +const os = require('node:os') +const path = require('node:path') +const http2 = require('node:http2') +const { readFileSync } = require('node:fs') +const { Writable } = require('node:stream') +const { isMainThread } = require('node:worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const ca = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'ca.pem'), 'utf8') +const servername = 'agent1' + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_THRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const parallelRequests = parseInt(process.env.PARALLEL, 10) || 100 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `https://localhost:${process.env.PORT}` +} else { + dest.url = 'https://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +const httpsBaseOptions = { + ca, + servername, + protocol: 'https:', + hostname: 'localhost', + method: 'GET', + path: '/', + query: { + frappucino: 'muffin', + goat: 'scone', + pond: 'moose', + foo: ['bar', 'baz', 'bal'], + bool: true, + numberKey: 256 + }, + ...dest +} + +const undiciOptions = { + path: '/', + method: 'GET', + headersTimeout, + bodyTimeout +} + +const http2NativeClient = http2.connect(httpsBaseOptions.url, { + rejectUnauthorized: false +}) + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpsBaseOptions.url, { + allowH2: true, + pipelining, + connections, + connect: { + rejectUnauthorized: false, + ca, + servername + }, + ...dest +}) + +setGlobalDispatcher(new Agent({ + allowH2: true, + pipelining, + connections, + connect: { + rejectUnauthorized: false, + ca, + servername + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +function makeParallelRequests (cb) { + const res = Promise.all(Array.from(Array(parallelRequests)).map(() => new Promise(cb))) + res.catch(console.error) + return res +} + +function printResults (results) { + // Sort results by least performant first, then compare relative performances and also printing padding + let last + + const rows = Object.entries(results) + // If any failed, put on the top of the list, otherwise order by mean, ascending + .sort((a, b) => (!a[1].success ? -1 : b[1].mean - a[1].mean)) + .map(([name, result]) => { + if (!result.success) { + return { + Tests: name, + Samples: result.size, + Result: 'Errored', + Tolerance: 'N/A', + 'Difference with Slowest': 'N/A' + } + } + + // Calculate throughput and relative performance + const { size, mean, standardError } = result + const relative = last !== 0 ? (last / mean - 1) * 100 : 0 + + // Save the slowest for relative comparison + if (typeof last === 'undefined') { + last = mean + } + + console.log(mean) + + return { + Tests: name, + Samples: size, + Result: `${((1e9 * parallelRequests) / mean).toFixed(2)} req/sec`, + Tolerance: `± ${((standardError / mean) * 100).toFixed(2)} %`, + 'Difference with slowest': relative > 0 ? `+ ${relative.toFixed(2)} %` : '-' + } + }) + + return console.table(rows) +} + +const experiments = { + 'native - http2' () { + return makeParallelRequests(resolve => { + const stream = http2NativeClient.request({ + [http2.constants.HTTP2_HEADER_PATH]: httpsBaseOptions.path, + [http2.constants.HTTP2_HEADER_METHOD]: httpsBaseOptions.method + }) + + stream.end().on('response', () => { + stream.pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('error', (err) => { + console.log('http2 - request - response - error', err) + }) + .on('finish', () => { + resolve() + }) + }) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + dispatcher + .pipeline(undiciOptions, data => { + return data.body + }) + .end() + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + try { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('error', (err) => { + console.log('undici - request - dispatcher.request - body - error', err) + }) + .on('finish', () => { + resolve() + }) + }) + } catch (err) { + console.error('undici - request - dispatcher.request - requestCount', err) + } + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url, {}).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + printResults(results) + dispatcher.destroy() + http2NativeClient.close() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/benchmark-https.js b/benchmarks/benchmark-https.js new file mode 100644 index 0000000..ca0d598 --- /dev/null +++ b/benchmarks/benchmark-https.js @@ -0,0 +1,291 @@ +'use strict' + +const https = require('node:https') +const os = require('node:os') +const path = require('node:path') +const { readFileSync } = require('node:fs') +const { Writable } = require('node:stream') +const { isMainThread } = require('node:worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const ca = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'ca.pem'), 'utf8') +const servername = 'agent1' + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_THRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const parallelRequests = parseInt(process.env.PARALLEL, 10) || 100 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `https://localhost:${process.env.PORT}` +} else { + dest.url = 'https://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +const httpsBaseOptions = { + ca, + servername, + protocol: 'https:', + hostname: 'localhost', + method: 'GET', + path: '/', + query: { + frappucino: 'muffin', + goat: 'scone', + pond: 'moose', + foo: ['bar', 'baz', 'bal'], + bool: true, + numberKey: 256 + }, + ...dest +} + +const httpsNoKeepAliveOptions = { + ...httpsBaseOptions, + agent: new https.Agent({ + keepAlive: false, + maxSockets: connections, + // rejectUnauthorized: false, + ca, + servername + }) +} + +const httpsKeepAliveOptions = { + ...httpsBaseOptions, + agent: new https.Agent({ + keepAlive: true, + maxSockets: connections, + // rejectUnauthorized: false, + ca, + servername + }) +} + +const undiciOptions = { + path: '/', + method: 'GET', + headersTimeout, + bodyTimeout +} + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpsBaseOptions.url, { + pipelining, + connections, + connect: { + // rejectUnauthorized: false, + ca, + servername + }, + ...dest +}) + +setGlobalDispatcher(new Agent({ + pipelining, + connections, + connect: { + // rejectUnauthorized: false, + ca, + servername + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +function makeParallelRequests (cb) { + return Promise.all(Array.from(Array(parallelRequests)).map(() => new Promise(cb))) +} + +function printResults (results) { + // Sort results by least performant first, then compare relative performances and also printing padding + let last + + const rows = Object.entries(results) + // If any failed, put on the top of the list, otherwise order by mean, ascending + .sort((a, b) => (!a[1].success ? -1 : b[1].mean - a[1].mean)) + .map(([name, result]) => { + if (!result.success) { + return { + Tests: name, + Samples: result.size, + Result: 'Errored', + Tolerance: 'N/A', + 'Difference with Slowest': 'N/A' + } + } + + // Calculate throughput and relative performance + const { size, mean, standardError } = result + const relative = last !== 0 ? (last / mean - 1) * 100 : 0 + + // Save the slowest for relative comparison + if (typeof last === 'undefined') { + last = mean + } + + return { + Tests: name, + Samples: size, + Result: `${((parallelRequests * 1e9) / mean).toFixed(2)} req/sec`, + Tolerance: `± ${((standardError / mean) * 100).toFixed(2)} %`, + 'Difference with slowest': relative > 0 ? `+ ${relative.toFixed(2)} %` : '-' + } + }) + + return console.table(rows) +} + +const experiments = { + 'https - no keepalive' () { + return makeParallelRequests(resolve => { + https.get(httpsNoKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'https - keepalive' () { + return makeParallelRequests(resolve => { + https.get(httpsKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + dispatcher + .pipeline(undiciOptions, data => { + return data.body + }) + .end() + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url, {}).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + printResults(results) + dispatcher.destroy() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/benchmark.js b/benchmarks/benchmark.js new file mode 100644 index 0000000..5c8f49e --- /dev/null +++ b/benchmarks/benchmark.js @@ -0,0 +1,342 @@ +'use strict' + +const http = require('node:http') +const os = require('node:os') +const path = require('node:path') +const { Writable } = require('node:stream') +const { isMainThread } = require('node:worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const { makeParallelRequests, printResults } = require('./_util') + +let nodeFetch +const axios = require('axios') +let superagent +let got + +const { promisify } = require('node:util') +const request = promisify(require('request')) + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_THRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `http://localhost:${process.env.PORT}` +} else { + dest.url = 'http://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +/** @type {http.RequestOptions} */ +const httpBaseOptions = { + protocol: 'http:', + hostname: 'localhost', + method: 'GET', + path: '/', + ...dest +} + +/** @type {http.RequestOptions} */ +const httpNoKeepAliveOptions = { + ...httpBaseOptions, + agent: new http.Agent({ + keepAlive: false, + maxSockets: connections + }) +} + +/** @type {http.RequestOptions} */ +const httpKeepAliveOptions = { + ...httpBaseOptions, + agent: new http.Agent({ + keepAlive: true, + maxSockets: connections + }) +} + +const axiosAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const fetchAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const gotAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const requestAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const superagentAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const undiciOptions = { + path: '/', + method: 'GET', + blocking: false, + reset: false, + headersTimeout, + bodyTimeout +} + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpBaseOptions.url, { + pipelining, + connections, + ...dest +}) + +setGlobalDispatcher(new Agent({ + pipelining, + connections, + connect: { + rejectUnauthorized: false + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +const experiments = { + 'http - no keepalive' () { + return makeParallelRequests(resolve => { + http.get(httpNoKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'http - keepalive' () { + return makeParallelRequests(resolve => { + http.get(httpKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + dispatcher + .pipeline(undiciOptions, ({ body }) => { + return body + }) + .end() + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } + + experiments['node-fetch'] = () => { + return makeParallelRequests(resolve => { + nodeFetch(dest.url, { agent: fetchAgent }).then(res => { + res.body.pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }).catch(console.log) + }) + } + + const axiosOptions = { + url: dest.url, + method: 'GET', + responseType: 'stream', + httpAgent: axiosAgent + } + experiments.axios = () => { + return makeParallelRequests(resolve => { + axios.request(axiosOptions).then(res => { + res.data.pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }).catch(console.log) + }) + } + + const gotOptions = { + url: dest.url, + method: 'GET', + agent: { + http: gotAgent + }, + // avoid body processing + isStream: true + } + experiments.got = () => { + return makeParallelRequests(resolve => { + got(gotOptions).pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }) + } + + const requestOptions = { + url: dest.url, + method: 'GET', + agent: requestAgent, + // avoid body toString + encoding: null + } + experiments.request = () => { + return makeParallelRequests(resolve => { + request(requestOptions).then(() => { + // already body consumed + resolve() + }).catch(console.log) + }) + } + + experiments.superagent = () => { + return makeParallelRequests(resolve => { + superagent.get(dest.url).pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + const _nodeFetch = await import('node-fetch') + nodeFetch = _nodeFetch.default + const _got = await import('got') + got = _got.default + const _superagent = await import('superagent') + // https://github.com/ladjs/superagent/issues/1540#issue-561464561 + superagent = _superagent.agent().use((req) => req.agent(superagentAgent)) + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + printResults(results) + dispatcher.destroy() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/cache/date.mjs b/benchmarks/cache/date.mjs new file mode 100644 index 0000000..6812fe7 --- /dev/null +++ b/benchmarks/cache/date.mjs @@ -0,0 +1,71 @@ +'use strict' + +import { group, bench, run } from 'mitata' +import { parseHttpDate } from '../../lib/util/date.js' + +const DATES = [ + // IMF + 'Sun, 06 Nov 1994 08:49:37 GMT', + 'Thu, 18 Aug 1950 02:01:18 GMT', + 'Wed, 11 Dec 2024 23:20:57 GMT', + 'Wed, aa Dec 2024 23:20:57 GMT', + 'aaa, 06 Dec 2024 23:20:57 GMT', + 'Wed, 01 aaa 2024 23:20:57 GMT', + 'Wed, 6 Dec 2024 23:20:07 GMT', + 'Wed, 06 Dec 2024 3:20:07 GMT', + 'Wed, 06 Dec 2024 23:1:07 GMT', + 'Wed, 06 Dec 2024 23:01:7 GMT', + 'Wed, 06 Dec aaaa 23:01:07 GMT', + 'Wed, 06 Dec 2024 aa:01:07 GMT', + 'Wed, 06 Dec 2024 23:aa:07 GMT', + 'Wed, 06 Dec 2024 23:01:aa GMT', + + // RFC850 + 'Sunday, 06-Nov-94 08:49:37 GMT', + 'Thursday, 18-Aug-50 02:01:18 GMT', + 'Wednesday, 11-Dec-24 23:20:57 GMT', + 'Wednesday, aa Dec 2024 23:20:57 GMT', + 'aaa, 06 Dec 2024 23:20:57 GMT', + 'Wednesday, 01-aaa-24 23:20:57 GMT', + 'Wednesday, 6-Dec-24 23:20:07 GMT', + 'Wednesday, 06-Dec-24 3:20:07 GMT', + 'Wednesday, 06-Dec-24 23:1:07 GMT', + 'Wednesday, 06-Dec-24 23:01:7 GMT', + 'Wednesday, 06 Dec-aa 23:01:07 GMT', + 'Wednesday, 06-Dec-24 aa:01:07 GMT', + 'Wednesday, 06-Dec-24 23:aa:07 GMT', + 'Wednesday, 06-Dec-24 23:01:aa GMT', + + // asctime() + 'Sun Nov 6 08:49:37 1994', + 'Thu Aug 18 02:01:18 1950', + 'Wed Dec 11 23:20:57 2024', + 'Wed Dec aa 23:20:57 2024', + 'aaa Dec 06 23:20:57 2024', + 'Wed aaa 01 23:20:57 2024', + 'Wed Dec 6 23:20:07 2024', + 'Wed Dec 06 3:20:07 2024', + 'Wed Dec 06 23:1:07 2024', + 'Wed Dec 06 23:01:7 2024', + 'Wed 06 Dec 23:01:07 aaaa', + 'Wed Dec 06 aa:01:07 2024', + 'Wed Dec 06 23:aa:07 2024', + 'Wed Dec 06 23:01:aa 2024' +] + +group(() => { + bench('parseHttpDate', () => { + for (const date of DATES) { + parseHttpDate(date) + } + }) + + bench('new Date()', () => { + for (const date of DATES) { + // eslint-disable-next-line no-new + new Date(date) + } + }) +}) + +await run() diff --git a/benchmarks/cache/get-field-values.mjs b/benchmarks/cache/get-field-values.mjs new file mode 100644 index 0000000..c62f773 --- /dev/null +++ b/benchmarks/cache/get-field-values.mjs @@ -0,0 +1,23 @@ +import { bench, group, run } from 'mitata' +import { getFieldValues } from '../../lib/web/cache/util.js' + +const values = [ + '', + 'foo', + 'invälid', + 'foo, ', + 'foo, bar', + 'foo, bar, baz', + 'foo, bar, baz, ', + 'foo, bar, baz, , ' +] + +group('getFieldValues', () => { + bench('getFieldValues', () => { + for (let i = 0; i < values.length; ++i) { + getFieldValues(values[i]) + } + }) +}) + +await run() diff --git a/benchmarks/cookies/Is-ctl-excluding-htab.mjs b/benchmarks/cookies/Is-ctl-excluding-htab.mjs new file mode 100644 index 0000000..12cdb90 --- /dev/null +++ b/benchmarks/cookies/Is-ctl-excluding-htab.mjs @@ -0,0 +1,17 @@ +import { bench, group, run } from 'mitata' +import { isCTLExcludingHtab } from '../../lib/web/cookies/util.js' + +const valid = 'Space=Cat; Secure; HttpOnly; Max-Age=2' +const invalid = 'Space=Cat; Secure; HttpOnly; Max-Age=2\x7F' + +group('isCTLExcludingHtab', () => { + bench(`valid: ${valid}`, () => { + return isCTLExcludingHtab(valid) + }) + + bench(`invalid: ${invalid}`, () => { + return isCTLExcludingHtab(invalid) + }) +}) + +await run() diff --git a/benchmarks/cookies/to-imf-date.mjs b/benchmarks/cookies/to-imf-date.mjs new file mode 100644 index 0000000..7934cc7 --- /dev/null +++ b/benchmarks/cookies/to-imf-date.mjs @@ -0,0 +1,12 @@ +import { bench, group, run } from 'mitata' +import { toIMFDate } from '../../lib/web/cookies/util.js' + +const date = new Date() + +group('toIMFDate', () => { + bench(`toIMFDate: ${date}`, () => { + return toIMFDate(date) + }) +}) + +await run() diff --git a/benchmarks/cookies/validate-cookie-name.mjs b/benchmarks/cookies/validate-cookie-name.mjs new file mode 100644 index 0000000..70e2b8a --- /dev/null +++ b/benchmarks/cookies/validate-cookie-name.mjs @@ -0,0 +1,12 @@ +import { bench, group, run } from 'mitata' +import { validateCookieName } from '../../lib/web/cookies/util.js' + +const valid = 'Cat' + +group('validateCookieName', () => { + bench(`valid: ${valid}`, () => { + return validateCookieName(valid) + }) +}) + +await run() diff --git a/benchmarks/cookies/validate-cookie-value.mjs b/benchmarks/cookies/validate-cookie-value.mjs new file mode 100644 index 0000000..a10db83 --- /dev/null +++ b/benchmarks/cookies/validate-cookie-value.mjs @@ -0,0 +1,16 @@ +import { bench, group, run } from 'mitata' +import { validateCookieValue } from '../../lib/web/cookies/util.js' + +const valid = 'Cat' +const wrappedValid = `"${valid}"` + +group('validateCookieValue', () => { + bench(`valid: ${valid}`, () => { + return validateCookieValue(valid) + }) + bench(`valid: ${wrappedValid}`, () => { + return validateCookieValue(wrappedValid) + }) +}) + +await run() diff --git a/benchmarks/core/is-blob-like.mjs b/benchmarks/core/is-blob-like.mjs new file mode 100644 index 0000000..a024ef1 --- /dev/null +++ b/benchmarks/core/is-blob-like.mjs @@ -0,0 +1,64 @@ +import { bench, group, run } from 'mitata' +import { isBlobLike } from '../../lib/core/util.js' + +const buffer = Buffer.alloc(1) + +const blob = new Blob(['asd'], { + type: 'application/json' +}) + +const file = new File(['asd'], 'file.txt', { + type: 'text/plain' +}) + +const blobLikeStream = { + [Symbol.toStringTag]: 'Blob', + stream: () => {} +} + +const fileLikeStream = { + stream: () => {}, + [Symbol.toStringTag]: 'File' +} + +const blobLikeArrayBuffer = { + [Symbol.toStringTag]: 'Blob', + arrayBuffer: () => {} +} + +const fileLikeArrayBuffer = { + [Symbol.toStringTag]: 'File', + arrayBuffer: () => {} +} + +group('isBlobLike', () => { + bench('blob', () => { + return isBlobLike(blob) + }) + bench('file', () => { + return isBlobLike(file) + }) + bench('blobLikeStream', () => { + return isBlobLike(blobLikeStream) + }) + bench('fileLikeStream', () => { + return isBlobLike(fileLikeStream) + }) + bench('fileLikeArrayBuffer', () => { + return isBlobLike(fileLikeArrayBuffer) + }) + bench('blobLikeArrayBuffer', () => { + return isBlobLike(blobLikeArrayBuffer) + }) + bench('buffer', () => { + return isBlobLike(buffer) + }) + bench('null', () => { + return isBlobLike(null) + }) + bench('string', () => { + return isBlobLike('invalid') + }) +}) + +await run() diff --git a/benchmarks/core/is-valid-header-char.mjs b/benchmarks/core/is-valid-header-char.mjs new file mode 100644 index 0000000..4734d11 --- /dev/null +++ b/benchmarks/core/is-valid-header-char.mjs @@ -0,0 +1,57 @@ +import { bench, group, run } from 'mitata' +import { isValidHeaderChar } from '../../lib/core/util.js' + +const html = 'text/html' +const json = 'application/json; charset=UTF-8' + +const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/ + +/** + * @param {string} characters + */ +function charCodeAtApproach (characters) { + // Validate if characters is a valid field-vchar. + // field-value = *( field-content / obs-fold ) + // field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + // field-vchar = VCHAR / obs-text + for (let i = 0; i < characters.length; ++i) { + const code = characters.charCodeAt(i) + // not \x20-\x7e, \t and \x80-\xff + if ((code < 0x20 && code !== 0x09) || code === 0x7f || code > 0xff) { + return false + } + } + return true +} + +group(`isValidHeaderChar# ${html}`, () => { + bench('regexp.test', () => { + return !headerCharRegex.test(html) + }) + bench('regexp.exec', () => { + return headerCharRegex.exec(html) === null + }) + bench('charCodeAt', () => { + return charCodeAtApproach(html) + }) + bench('isValidHeaderChar', () => { + return isValidHeaderChar(html) + }) +}) + +group(`isValidHeaderChar# ${json}`, () => { + bench('regexp.test', () => { + return !headerCharRegex.test(json) + }) + bench('regexp.exec', () => { + return headerCharRegex.exec(json) === null + }) + bench('charCodeAt', () => { + return charCodeAtApproach(json) + }) + bench('isValidHeaderChar', () => { + return isValidHeaderChar(json) + }) +}) + +await run() diff --git a/benchmarks/core/is-valid-port.mjs b/benchmarks/core/is-valid-port.mjs new file mode 100644 index 0000000..0c94bc0 --- /dev/null +++ b/benchmarks/core/is-valid-port.mjs @@ -0,0 +1,16 @@ +import { bench, group, run } from 'mitata' +import { isValidPort } from '../../lib/core/util.js' + +const string = '1234' +const number = 1234 + +group('isValidPort', () => { + bench('string', () => { + return isValidPort(string) + }) + bench('number', () => { + return isValidPort(number) + }) +}) + +await run() diff --git a/benchmarks/core/parse-headers.mjs b/benchmarks/core/parse-headers.mjs new file mode 100644 index 0000000..439986d --- /dev/null +++ b/benchmarks/core/parse-headers.mjs @@ -0,0 +1,105 @@ +import { bench, group, run } from 'mitata' +import { parseHeaders } from '../../lib/core/util.js' + +const target = [ + { + 'Content-Type': 'application/json', + Date: 'Wed, 01 Nov 2023 00:00:00 GMT', + 'Powered-By': 'NodeJS', + 'Content-Encoding': 'gzip', + 'Set-Cookie': '__Secure-ID=123; Secure; Domain=example.com', + 'Content-Length': '150', + Vary: 'Accept-Encoding, Accept, X-Requested-With' + }, + { + 'Content-Type': 'text/html; charset=UTF-8', + 'Content-Length': '1234', + Date: 'Wed, 06 Dec 2023 12:47:57 GMT', + Server: 'Bing' + }, + { + 'Content-Type': 'image/jpeg', + 'Content-Length': '56789', + Date: 'Wed, 06 Dec 2023 12:48:12 GMT', + Server: 'Bing', + ETag: '"a1b2c3d4e5f6g7h8i9j0"' + }, + { + Cookie: 'session_id=1234567890abcdef', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + Host: 'www.bing.com', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br' + }, + { + Location: 'https://www.bing.com/search?q=bing', + Status: '302 Found', + Date: 'Wed, 06 Dec 2023 12:48:27 GMT', + Server: 'Bing', + 'Content-Type': 'text/html; charset=UTF-8', + 'Content-Length': '0' + }, + { + 'Content-Type': + 'multipart/form-data; boundary=----WebKitFormBoundary1234567890', + 'Content-Length': '98765', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + Host: 'www.bing.com', + Accept: '*/*', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br' + }, + { + 'Content-Type': 'application/json; charset=UTF-8', + 'Content-Length': '2345', + Date: 'Wed, 06 Dec 2023 12:48:42 GMT', + Server: 'Bing', + Status: '200 OK', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }, + { + Host: 'www.example.com', + Connection: 'keep-alive', + Accept: 'text/html, application/xhtml+xml, application/xml;q=0.9,;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + } +] + +const headers = Array.from(target, (x) => + Object.entries(x) + .flat() + .map((c) => Buffer.from(c)) +) + +const headersIrregular = Array.from( + target, + (x) => Object.entries(x) + .flat() + .map((c) => Buffer.from(c.toUpperCase())) +) + +// avoid JIT bias +bench('noop', () => {}) +bench('noop', () => {}) +bench('noop', () => {}) +bench('noop', () => {}) +bench('noop', () => {}) +bench('noop', () => {}) + +group('parseHeaders', () => { + bench('parseHeaders', () => { + for (let i = 0; i < headers.length; ++i) { + parseHeaders(headers[i]) + } + }) + bench('parseHeaders (irregular)', () => { + for (let i = 0; i < headersIrregular.length; ++i) { + parseHeaders(headersIrregular[i]) + } + }) +}) + +await new Promise((resolve) => setTimeout(resolve, 7000)) + +await run() diff --git a/benchmarks/core/parse-raw-headers.mjs b/benchmarks/core/parse-raw-headers.mjs new file mode 100644 index 0000000..8f789df --- /dev/null +++ b/benchmarks/core/parse-raw-headers.mjs @@ -0,0 +1,24 @@ +import { bench, group, run } from 'mitata' +import { parseRawHeaders } from '../../lib/core/util.js' + +const rawHeadersMixed = ['key', 'value', Buffer.from('key'), Buffer.from('value')] +const rawHeadersOnlyStrings = ['key', 'value', 'key', 'value'] +const rawHeadersOnlyBuffers = [Buffer.from('key'), Buffer.from('value'), Buffer.from('key'), Buffer.from('value')] +const rawHeadersContent = ['content-length', 'value', 'content-disposition', 'form-data; name="fieldName"'] + +group('parseRawHeaders', () => { + bench('only strings', () => { + parseRawHeaders(rawHeadersOnlyStrings) + }) + bench('only buffers', () => { + parseRawHeaders(rawHeadersOnlyBuffers) + }) + bench('mixed', () => { + parseRawHeaders(rawHeadersMixed) + }) + bench('content-disposition special case', () => { + parseRawHeaders(rawHeadersContent) + }) +}) + +await run() diff --git a/benchmarks/core/request-instantiation.mjs b/benchmarks/core/request-instantiation.mjs new file mode 100644 index 0000000..4973a31 --- /dev/null +++ b/benchmarks/core/request-instantiation.mjs @@ -0,0 +1,12 @@ +import { bench, run } from 'mitata' + +import Request from '../../lib/core/request.js' +import DecoratorHandler from '../../lib/handler/decorator-handler.js' + +const handler = new DecoratorHandler({}) + +bench('new Request()', () => { + return new Request('https://localhost', { path: '/', method: 'get', body: null }, handler) +}) + +await run() diff --git a/benchmarks/core/tree.mjs b/benchmarks/core/tree.mjs new file mode 100644 index 0000000..1e268e4 --- /dev/null +++ b/benchmarks/core/tree.mjs @@ -0,0 +1,20 @@ +import { bench, group, run } from 'mitata' +import { tree } from '../../lib/core/tree.js' + +const contentLength = Buffer.from('Content-Length') +const contentLengthUpperCase = Buffer.from('Content-Length'.toUpperCase()) +const contentLengthLowerCase = Buffer.from('Content-Length'.toLowerCase()) + +group('tree.search', () => { + bench('content-length', () => { + tree.lookup(contentLengthLowerCase) + }) + bench('CONTENT-LENGTH', () => { + tree.lookup(contentLengthUpperCase) + }) + bench('Content-Length', () => { + tree.lookup(contentLength) + }) +}) + +await run() diff --git a/benchmarks/fetch/body-arraybuffer.mjs b/benchmarks/fetch/body-arraybuffer.mjs new file mode 100644 index 0000000..f05e62d --- /dev/null +++ b/benchmarks/fetch/body-arraybuffer.mjs @@ -0,0 +1,24 @@ +import { group, bench, run } from 'mitata' +import { Response } from '../../lib/web/fetch/response.js' + +const settings = { + small: 2 << 8, + middle: 2 << 12, + long: 2 << 16 +} + +for (const [name, length] of Object.entries(settings)) { + const buffer = Buffer.allocUnsafe(length).map(() => (Math.random() * 100) | 0) + group(`${name} (length ${length})`, () => { + bench('Response#arrayBuffer', async () => { + return await new Response(buffer).arrayBuffer() + }) + + // for comparison + bench('Response#text', async () => { + return await new Response(buffer).text() + }) + }) +} + +await run() diff --git a/benchmarks/fetch/bytes-match.mjs b/benchmarks/fetch/bytes-match.mjs new file mode 100644 index 0000000..6c6b263 --- /dev/null +++ b/benchmarks/fetch/bytes-match.mjs @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto' +import { bench, run } from 'mitata' +import { bytesMatch } from '../../lib/web/fetch/util.js' + +const body = Buffer.from('Hello world!') +const validSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}` +const invalidSha256Base64 = `sha256-${createHash('sha256').update(body).digest('base64')}` +const validSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}` +const invalidSha256Base64Url = `sha256-${createHash('sha256').update(body).digest('base64url')}` + +bench('bytesMatch valid sha256 and base64', () => { + bytesMatch(body, validSha256Base64) +}) +bench('bytesMatch invalid sha256 and base64', () => { + bytesMatch(body, invalidSha256Base64) +}) +bench('bytesMatch valid sha256 and base64url', () => { + bytesMatch(body, validSha256Base64Url) +}) +bench('bytesMatch invalid sha256 and base64url', () => { + bytesMatch(body, invalidSha256Base64Url) +}) + +await run() diff --git a/benchmarks/fetch/headers-length32.mjs b/benchmarks/fetch/headers-length32.mjs new file mode 100644 index 0000000..1606202 --- /dev/null +++ b/benchmarks/fetch/headers-length32.mjs @@ -0,0 +1,52 @@ +import { bench, run } from 'mitata' +import { Headers, getHeadersList } from '../../lib/web/fetch/headers.js' + +const headers = new Headers( + [ + 'Origin-Agent-Cluster', + 'RTT', + 'Accept-CH-Lifetime', + 'X-Frame-Options', + 'Sec-CH-UA-Platform-Version', + 'Digest', + 'Cache-Control', + 'Sec-CH-UA-Platform', + 'If-Range', + 'SourceMap', + 'Strict-Transport-Security', + 'Want-Digest', + 'Cross-Origin-Resource-Policy', + 'Width', + 'Accept-CH', + 'Via', + 'Set-Cookie', + 'Server', + 'Sec-Fetch-Dest', + 'Sec-CH-UA-Model', + 'Access-Control-Request-Method', + 'Access-Control-Request-Headers', + 'Date', + 'Expires', + 'DNT', + 'Proxy-Authorization', + 'Alt-Svc', + 'Alt-Used', + 'ETag', + 'Sec-Fetch-User', + 'Sec-CH-UA-Full-Version-List', + 'Referrer-Policy' + ].map((v) => [v, '']) +) + +const headersList = getHeadersList(headers) + +const kHeadersSortedMap = Reflect.ownKeys(headersList).find( + (c) => String(c) === 'Symbol(headers map sorted)' +) + +bench('Headers@@iterator', () => { + headersList[kHeadersSortedMap] = null + return [...headers] +}) + +await run() diff --git a/benchmarks/fetch/headers.mjs b/benchmarks/fetch/headers.mjs new file mode 100644 index 0000000..7f9047b --- /dev/null +++ b/benchmarks/fetch/headers.mjs @@ -0,0 +1,53 @@ +import { bench, group, run } from 'mitata' +import { Headers, getHeadersList } from '../../lib/web/fetch/headers.js' + +const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +const charactersLength = characters.length + +function generateAsciiString (length) { + let result = '' + for (let i = 0; i < length; ++i) { + result += characters[Math.floor(Math.random() * charactersLength)] + } + return result +} + +const settings = { + 'fast-path (tiny array)': 4, + 'fast-path (small array)': 8, + 'fast-path (middle array)': 16, + 'fast-path': 32, + 'slow-path': 64 +} + +for (const [name, length] of Object.entries(settings)) { + const headers = new Headers( + Array.from(Array(length), () => [generateAsciiString(12), '']) + ) + + const headersSorted = new Headers(headers) + + const headersList = getHeadersList(headers) + + const headersListSorted = getHeadersList(headersSorted) + + const kHeadersSortedMap = Reflect.ownKeys(headersList).find( + (c) => String(c) === 'Symbol(headers map sorted)' + ) + + group(`length ${length} #${name}`, () => { + bench('Headers@@iterator', () => { + // prevention of memoization of results + headersList[kHeadersSortedMap] = null + return [...headers] + }) + + bench('Headers@@iterator (sorted)', () => { + // prevention of memoization of results + headersListSorted[kHeadersSortedMap] = null + return [...headersSorted] + }) + }) +} + +await run() diff --git a/benchmarks/fetch/is-valid-encoded-url.mjs b/benchmarks/fetch/is-valid-encoded-url.mjs new file mode 100644 index 0000000..aeb9473 --- /dev/null +++ b/benchmarks/fetch/is-valid-encoded-url.mjs @@ -0,0 +1,14 @@ +import { bench, run } from 'mitata' +import { isValidEncodedURL } from '../../lib/web/fetch/util.js' + +const validUrl = 'https://example.com' +const invalidUrl = 'https://example.com\x00' + +bench('isValidEncodedURL valid', () => { + isValidEncodedURL(validUrl) +}) +bench('isValidEncodedURL invalid', () => { + isValidEncodedURL(invalidUrl) +}) + +await run() diff --git a/benchmarks/fetch/is-valid-header-value.mjs b/benchmarks/fetch/is-valid-header-value.mjs new file mode 100644 index 0000000..60a9a54 --- /dev/null +++ b/benchmarks/fetch/is-valid-header-value.mjs @@ -0,0 +1,38 @@ +import { bench, run } from 'mitata' +import { isValidHeaderValue } from '../../lib/web/fetch/util.js' + +const valid = 'valid123' +const invalidNUL = 'invalid\x00' +const invalidCR = 'invalid\r' +const invalidLF = 'invalid\n' +const invalidTrailingTab = 'invalid\t' +const invalidLeadingTab = '\tinvalid' +const invalidTrailingSpace = 'invalid ' +const invalidLeadingSpace = ' invalid' + +bench('isValidHeaderValue valid', () => { + isValidHeaderValue(valid) +}) +bench('isValidHeaderValue invalid containing NUL', () => { + isValidHeaderValue(invalidNUL) +}) +bench('isValidHeaderValue invalid containing CR', () => { + isValidHeaderValue(invalidCR) +}) +bench('isValidHeaderValue invalid containing LF', () => { + isValidHeaderValue(invalidLF) +}) +bench('isValidHeaderValue invalid trailing TAB', () => { + isValidHeaderValue(invalidTrailingTab) +}) +bench('isValidHeaderValue invalid leading TAB', () => { + isValidHeaderValue(invalidLeadingTab) +}) +bench('isValidHeaderValue invalid trailing SPACE', () => { + isValidHeaderValue(invalidTrailingSpace) +}) +bench('isValidHeaderValue invalid leading SPACE', () => { + isValidHeaderValue(invalidLeadingSpace) +}) + +await run() diff --git a/benchmarks/fetch/isomorphic-encode.mjs b/benchmarks/fetch/isomorphic-encode.mjs new file mode 100644 index 0000000..bf77239 --- /dev/null +++ b/benchmarks/fetch/isomorphic-encode.mjs @@ -0,0 +1,75 @@ +import { bench, group, run } from 'mitata' +import { isomorphicEncode } from '../../lib/web/fetch/util.js' + +const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +const charactersLength = characters.length + +function generateAsciiString (length) { + let result = '' + for (let i = 0; i < length; ++i) { + result += characters[Math.floor(Math.random() * charactersLength)] + } + return result +} + +const invalidIsomorphicEncodeValueRegex = /[^\x00-\xFF]/ // eslint-disable-line + +function isomorphicEncode1 (input) { + for (let i = 0; i < input.length; i++) { + if (input.charCodeAt(i) > 0xff) { + throw new TypeError('Unreachable') + } + } + return input +} + +function isomorphicEncode2 (input) { + if (invalidIsomorphicEncodeValueRegex.test(input)) { + throw new TypeError('Unreachable') + } + return input +} + +const settings = { + small: 10, + middle: 30, + long: 70 +} + +for (const [runName, length] of Object.entries(settings)) { + const value = generateAsciiString(length); + + [ + { name: `${runName} (valid)`, value }, + { + name: `${runName} (invalid)`, + value: `${value.slice(0, -1)}${String.fromCharCode(0xff + 1)}` + } + ].forEach(({ name, value }) => { + group(name, () => { + [ + { + name: 'original', + fn: isomorphicEncode + }, + { + name: 'String#charCodeAt', + fn: isomorphicEncode1 + }, + { + name: 'RegExp#test', + fn: isomorphicEncode2 + } + ].forEach(({ name, fn }) => { + bench(name, () => { + try { + return fn(value) + } catch (err) {} + }) + }) + }) + }) +} + +await run() diff --git a/benchmarks/fetch/request-creation.mjs b/benchmarks/fetch/request-creation.mjs new file mode 100644 index 0000000..c6ebf28 --- /dev/null +++ b/benchmarks/fetch/request-creation.mjs @@ -0,0 +1,8 @@ +import { bench, run } from 'mitata' +import { Request } from '../../lib/web/fetch/request.js' + +const input = 'https://example.com/post' + +bench('new Request(input)', () => new Request(input, undefined)) + +await run() diff --git a/benchmarks/fetch/url-has-https-scheme.mjs b/benchmarks/fetch/url-has-https-scheme.mjs new file mode 100644 index 0000000..9e32101 --- /dev/null +++ b/benchmarks/fetch/url-has-https-scheme.mjs @@ -0,0 +1,22 @@ +import { bench, run } from 'mitata' +import { urlHasHttpsScheme } from '../../lib/web/fetch/util.js' + +const httpString = 'http://example.com' +const httpObject = { protocol: 'http:' } +const httpsString = 'https://example.com' +const httpsObject = { protocol: 'https:' } + +bench('urlHasHttpsScheme "http:" String', () => { + urlHasHttpsScheme(httpString) +}) +bench('urlHasHttpsScheme "https:" String', () => { + urlHasHttpsScheme(httpsString) +}) +bench('urlHasHttpsScheme "http:" Object', () => { + urlHasHttpsScheme(httpObject) +}) +bench('urlHasHttpsScheme "https:" Object', () => { + urlHasHttpsScheme(httpsObject) +}) + +await run() diff --git a/benchmarks/fetch/webidl-is.mjs b/benchmarks/fetch/webidl-is.mjs new file mode 100644 index 0000000..733f634 --- /dev/null +++ b/benchmarks/fetch/webidl-is.mjs @@ -0,0 +1,26 @@ +import { bench, run, barplot } from 'mitata' +import { Headers, FormData } from '../../index.js' +import { webidl } from '../../lib/web/fetch/webidl.js' + +const headers = new Headers() +const fd = new FormData() + +barplot(() => { + bench('webidl.is.FormData (ok)', () => { + return webidl.is.FormData(fd) + }) + + bench('webidl.is.FormData (bad)', () => { + return !webidl.is.FormData(headers) + }) + + bench('instanceof (ok)', () => { + return fd instanceof FormData + }) + + bench('instanceof (bad)', () => { + return !(headers instanceof FormData) + }) +}) + +await run() diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 0000000..371e2d2 --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,26 @@ +{ + "name": "benchmarks", + "scripts": { + "bench": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench:run", + "bench:h2": "PORT=3052 concurrently -k -s first npm:bench:server:h2 npm:bench:run:h2", + "bench-post": "PORT=3042 concurrently -k -s first npm:bench:server npm:bench-post:run", + "bench:server": "node ./server.js", + "bench:server:h2": "node ./server-http2.js", + "prebench:run": "node ./wait.js", + "bench:run": "SAMPLES=100 CONNECTIONS=50 node ./benchmark.js", + "bench:run:h2": "SAMPLES=100 CONNECTIONS=50 node ./benchmark-http2.js", + "prebench-post:run": "node ./wait.js", + "bench-post:run": "SAMPLES=100 CONNECTIONS=50 node ./post-benchmark.js" + }, + "dependencies": { + "axios": "^1.6.7", + "concurrently": "^9.0.0", + "cronometro": "^4.0.1", + "got": "^14.2.0", + "mitata": "^1.0.4", + "node-fetch": "^3.3.2", + "request": "^2.88.2", + "superagent": "^10.0.0", + "wait-on": "^8.0.0" + } +} diff --git a/benchmarks/post-benchmark.js b/benchmarks/post-benchmark.js new file mode 100644 index 0000000..041c136 --- /dev/null +++ b/benchmarks/post-benchmark.js @@ -0,0 +1,384 @@ +'use strict' + +const http = require('node:http') +const os = require('node:os') +const path = require('node:path') +const { Writable, Readable, pipeline } = require('node:stream') +const { isMainThread } = require('node:worker_threads') + +const { Pool, Client, fetch, Agent, setGlobalDispatcher } = require('..') + +const { makeParallelRequests, printResults } = require('./_util') + +let nodeFetch +const axios = require('axios') +let superagent +let got + +const { promisify } = require('node:util') +const request = promisify(require('request')) + +const iterations = (parseInt(process.env.SAMPLES, 10) || 10) + 1 +const errorThreshold = parseInt(process.env.ERROR_THRESHOLD, 10) || 3 +const connections = parseInt(process.env.CONNECTIONS, 10) || 50 +const pipelining = parseInt(process.env.PIPELINING, 10) || 10 +const headersTimeout = parseInt(process.env.HEADERS_TIMEOUT, 10) || 0 +const bodyTimeout = parseInt(process.env.BODY_TIMEOUT, 10) || 0 +const dest = {} + +const data = '_'.repeat(128 * 1024) +const dataLength = `${Buffer.byteLength(data)}` + +if (process.env.PORT) { + dest.port = process.env.PORT + dest.url = `http://localhost:${process.env.PORT}` +} else { + dest.url = 'http://localhost' + dest.socketPath = path.join(os.tmpdir(), 'undici.sock') +} + +const headers = { + 'Content-Type': 'text/plain; charset=UTF-8', + 'Content-Length': dataLength +} + +/** @type {http.RequestOptions} */ +const httpBaseOptions = { + protocol: 'http:', + hostname: 'localhost', + method: 'POST', + path: '/', + headers, + ...dest +} + +/** @type {http.RequestOptions} */ +const httpNoKeepAliveOptions = { + ...httpBaseOptions, + agent: new http.Agent({ + keepAlive: false, + maxSockets: connections + }) +} + +/** @type {http.RequestOptions} */ +const httpKeepAliveOptions = { + ...httpBaseOptions, + agent: new http.Agent({ + keepAlive: true, + maxSockets: connections + }) +} + +const axiosAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const fetchAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const gotAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const requestAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +const superagentAgent = new http.Agent({ + keepAlive: true, + maxSockets: connections +}) + +/** @type {import("..").Dispatcher.DispatchOptions} */ +const undiciOptions = { + path: '/', + method: 'POST', + headersTimeout, + bodyTimeout, + body: data, + headers +} + +const Class = connections > 1 ? Pool : Client +const dispatcher = new Class(httpBaseOptions.url, { + pipelining, + connections, + ...dest +}) + +setGlobalDispatcher(new Agent({ + pipelining, + connections, + connect: { + rejectUnauthorized: false + } +})) + +class SimpleRequest { + constructor (resolve) { + this.dst = new Writable({ + write (chunk, encoding, callback) { + callback() + } + }).on('finish', resolve) + } + + onConnect (abort) { } + + onHeaders (statusCode, headers, resume) { + this.dst.on('drain', resume) + } + + onData (chunk) { + return this.dst.write(chunk) + } + + onComplete () { + this.dst.end() + } + + onError (err) { + throw err + } +} + +const experiments = { + 'http - no keepalive' () { + return makeParallelRequests(resolve => { + const request = http.request(httpNoKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + request.end(data) + }) + }, + 'http - keepalive' () { + return makeParallelRequests(resolve => { + const request = http.request(httpKeepAliveOptions, res => { + res + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + request.end(data) + }) + }, + 'undici - pipeline' () { + return makeParallelRequests(resolve => { + pipeline( + new Readable({ + read () { + this.push(data) + this.push(null) + } + }), + dispatcher.pipeline(undiciOptions, ({ body }) => { + return body + }), + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }), + (err) => { + if (err != null) { + console.log(err) + } + resolve() + } + ) + }) + }, + 'undici - request' () { + return makeParallelRequests(resolve => { + dispatcher.request(undiciOptions).then(({ body }) => { + body + .pipe( + new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + ) + .on('finish', resolve) + }) + }) + }, + 'undici - stream' () { + return makeParallelRequests(resolve => { + return dispatcher + .stream(undiciOptions, () => { + return new Writable({ + write (chunk, encoding, callback) { + callback() + } + }) + }) + .then(resolve) + }) + }, + 'undici - dispatch' () { + return makeParallelRequests(resolve => { + dispatcher.dispatch(undiciOptions, new SimpleRequest(resolve)) + }) + } +} + +if (process.env.PORT) { + /** @type {RequestInit} */ + const fetchOptions = { + method: 'POST', + body: data, + headers + } + // fetch does not support the socket + experiments['undici - fetch'] = () => { + return makeParallelRequests(resolve => { + fetch(dest.url, fetchOptions).then(res => { + res.body.pipeTo(new WritableStream({ write () { }, close () { resolve() } })) + }).catch(console.log) + }) + } + + const nodeFetchOptions = { + ...fetchOptions, + agent: fetchAgent + } + experiments['node-fetch'] = () => { + return makeParallelRequests(resolve => { + nodeFetch(dest.url, nodeFetchOptions).then(res => { + res.body.pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }).catch(console.log) + }) + } + + const axiosOptions = { + url: dest.url, + method: 'POST', + headers, + responseType: 'stream', + httpAgent: axiosAgent, + data + } + experiments.axios = () => { + return makeParallelRequests(resolve => { + axios.request(axiosOptions).then(res => { + res.data.pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }).catch(console.log) + }) + } + + const gotOptions = { + url: dest.url, + method: 'POST', + headers, + agent: { + http: gotAgent + }, + // avoid body processing + isStream: true, + body: data + } + experiments.got = () => { + return makeParallelRequests(resolve => { + got(gotOptions).pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }) + } + + const requestOptions = { + url: dest.url, + method: 'POST', + headers, + agent: requestAgent, + body: data, + // avoid body toString + encoding: null + } + experiments.request = () => { + return makeParallelRequests(resolve => { + request(requestOptions).then(() => { + // already body consumed + resolve() + }).catch(console.log) + }) + } + + experiments.superagent = () => { + return makeParallelRequests(resolve => { + superagent + .post(dest.url) + .send(data) + .set('Content-Type', 'text/plain; charset=UTF-8') + .set('Content-Length', dataLength) + .pipe(new Writable({ + write (chunk, encoding, callback) { + callback() + } + })).on('finish', resolve) + }) + } +} + +async function main () { + const { cronometro } = await import('cronometro') + const _nodeFetch = await import('node-fetch') + nodeFetch = _nodeFetch.default + const _got = await import('got') + got = _got.default + const _superagent = await import('superagent') + // https://github.com/ladjs/superagent/issues/1540#issue-561464561 + superagent = _superagent.agent().use((req) => req.agent(superagentAgent)) + + cronometro( + experiments, + { + iterations, + errorThreshold, + print: false + }, + (err, results) => { + if (err) { + throw err + } + + printResults(results) + dispatcher.destroy() + } + ) +} + +if (isMainThread) { + main() +} else { + module.exports = main +} diff --git a/benchmarks/server-http2.js b/benchmarks/server-http2.js new file mode 100644 index 0000000..6baf5e2 --- /dev/null +++ b/benchmarks/server-http2.js @@ -0,0 +1,55 @@ +'use strict' + +const { unlinkSync, readFileSync } = require('node:fs') +const { createSecureServer } = require('node:http2') +const os = require('node:os') +const path = require('node:path') +const cluster = require('node:cluster') + +const key = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'key.pem'), 'utf8') +const cert = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'cert.pem'), 'utf8') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +const port = process.env.PORT || socketPath +const timeout = parseInt(process.env.TIMEOUT, 10) || 1 +const workers = parseInt(process.env.WORKERS) || os.cpus().length + +const sessionTimeout = 600e3 // 10 minutes + +if (cluster.isPrimary) { + try { + unlinkSync(socketPath) + } catch (_) { + // Do nothing if the socket does not exist + } + + for (let i = 0; i < workers; i++) { + cluster.fork() + } +} else { + const buf = Buffer.alloc(64 * 1024, '_') + const server = createSecureServer( + { + key, + cert, + allowHTTP1: true, + sessionTimeout + } + ) + + server.on('stream', (stream) => { + setTimeout(() => { + stream.setEncoding('utf-8').end(buf) + }, timeout) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + ':status': 200 + }) + }) + + server.keepAliveTimeout = 600e3 + + server.listen(port) +} diff --git a/benchmarks/server-https.js b/benchmarks/server-https.js new file mode 100644 index 0000000..350ec2e --- /dev/null +++ b/benchmarks/server-https.js @@ -0,0 +1,41 @@ +'use strict' + +const { unlinkSync, readFileSync } = require('node:fs') +const { createServer } = require('node:https') +const os = require('node:os') +const path = require('node:path') +const cluster = require('node:cluster') + +const key = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'key.pem'), 'utf8') +const cert = readFileSync(path.join(__dirname, '..', 'test', 'fixtures', 'cert.pem'), 'utf8') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +const port = process.env.PORT || socketPath +const timeout = parseInt(process.env.TIMEOUT, 10) || 1 +const workers = parseInt(process.env.WORKERS) || os.cpus().length + +if (cluster.isPrimary) { + try { + unlinkSync(socketPath) + } catch (_) { + // Do nothing if the socket does not exist + } + + for (let i = 0; i < workers; i++) { + cluster.fork() + } +} else { + const buf = Buffer.alloc(64 * 1024, '_') + const server = createServer({ + key, + cert, + keepAliveTimeout: 600e3 + }, (req, res) => { + setTimeout(() => { + res.end(buf) + }, timeout) + }) + + server.listen(port) +} diff --git a/benchmarks/server.js b/benchmarks/server.js new file mode 100644 index 0000000..a853476 --- /dev/null +++ b/benchmarks/server.js @@ -0,0 +1,44 @@ +'use strict' + +const { unlinkSync } = require('node:fs') +const { createServer } = require('node:http') +const os = require('node:os') +const path = require('node:path') +const cluster = require('node:cluster') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +const port = process.env.PORT || socketPath +const timeout = parseInt(process.env.TIMEOUT, 10) || 1 +const workers = parseInt(process.env.WORKERS) || os.cpus().length + +if (cluster.isPrimary) { + try { + unlinkSync(socketPath) + } catch (_) { + // Do nothing if the socket does not exist + } + + for (let i = 0; i < workers; i++) { + cluster.fork() + } +} else { + const buf = Buffer.alloc(64 * 1024, '_') + + const headers = { + 'Content-Length': `${buf.byteLength}`, + 'Content-Type': 'text/plain; charset=UTF-8' + } + let i = 0 + const server = createServer((_req, res) => { + i++ + setTimeout(() => { + res.writeHead(200, headers) + res.end(buf) + }, timeout) + }).listen(port) + server.keepAliveTimeout = 600e3 + setInterval(() => { + console.log(`Worker ${process.pid} processed ${i} requests`) + }, 5000) +} diff --git a/benchmarks/timers/compare-timer-getters.mjs b/benchmarks/timers/compare-timer-getters.mjs new file mode 100644 index 0000000..aadff55 --- /dev/null +++ b/benchmarks/timers/compare-timer-getters.mjs @@ -0,0 +1,18 @@ +import { bench, group, run } from 'mitata' + +group('timers', () => { + bench('Date.now()', () => { + Date.now() + }) + bench('performance.now()', () => { + performance.now() + }) + bench('Math.trunc(performance.now())', () => { + Math.trunc(performance.now()) + }) + bench('process.uptime()', () => { + process.uptime() + }) +}) + +await run() diff --git a/benchmarks/wait.js b/benchmarks/wait.js new file mode 100644 index 0000000..95b9377 --- /dev/null +++ b/benchmarks/wait.js @@ -0,0 +1,22 @@ +'use strict' + +const os = require('node:os') +const path = require('node:path') +const waitOn = require('wait-on') + +const socketPath = path.join(os.tmpdir(), 'undici.sock') + +let resources +if (process.env.PORT) { + resources = [`http-get://localhost:${process.env.PORT}/`] +} else { + resources = [`http-get://unix:${socketPath}:/`] +} + +waitOn({ + resources, + timeout: 5000 +}).catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/benchmarks/websocket/generate-mask.mjs b/benchmarks/websocket/generate-mask.mjs new file mode 100644 index 0000000..032f05d --- /dev/null +++ b/benchmarks/websocket/generate-mask.mjs @@ -0,0 +1,22 @@ +import { randomFillSync, randomBytes } from 'node:crypto' +import { bench, group, run } from 'mitata' + +const BUFFER_SIZE = 16384 + +const buf = Buffer.allocUnsafe(BUFFER_SIZE) +let bufIdx = BUFFER_SIZE + +function generateMask () { + if (bufIdx === BUFFER_SIZE) { + bufIdx = 0 + randomFillSync(buf, 0, BUFFER_SIZE) + } + return [buf[bufIdx++], buf[bufIdx++], buf[bufIdx++], buf[bufIdx++]] +} + +group('generate', () => { + bench('generateMask', () => generateMask()) + bench('crypto.randomBytes(4)', () => randomBytes(4)) +}) + +await run() diff --git a/benchmarks/websocket/is-valid-subprotocol.mjs b/benchmarks/websocket/is-valid-subprotocol.mjs new file mode 100644 index 0000000..8929f1f --- /dev/null +++ b/benchmarks/websocket/is-valid-subprotocol.mjs @@ -0,0 +1,17 @@ +import { bench, group, run } from 'mitata' +import { isValidSubprotocol } from '../../lib/web/websocket/util.js' + +const valid = 'valid' +const invalid = 'invalid ' + +group('isValidSubprotocol', () => { + bench(`valid: ${valid}`, () => { + return isValidSubprotocol(valid) + }) + + bench(`invalid: ${invalid}`, () => { + return isValidSubprotocol(invalid) + }) +}) + +await run() diff --git a/benchmarks/websocket/messageevent.mjs b/benchmarks/websocket/messageevent.mjs new file mode 100644 index 0000000..b146cbc --- /dev/null +++ b/benchmarks/websocket/messageevent.mjs @@ -0,0 +1,20 @@ +import { bench, group, run } from 'mitata' +import { createFastMessageEvent, MessageEvent as UndiciMessageEvent } from '../../lib/web/websocket/events.js' + +const { port1, port2 } = new MessageChannel() + +group('MessageEvent instantiation', () => { + bench('undici - fast MessageEvent init', () => { + return createFastMessageEvent('event', { data: null, ports: [port1, port2] }) + }) + + bench('undici - MessageEvent init', () => { + return new UndiciMessageEvent('event', { data: null, ports: [port1, port2] }) + }) + + bench('global - MessageEvent init', () => { + return new MessageEvent('event', { data: null, ports: [port1, port2] }) + }) +}) + +await run() diff --git a/build/wasm.js b/build/wasm.js new file mode 100644 index 0000000..46cd273 --- /dev/null +++ b/build/wasm.js @@ -0,0 +1,119 @@ +'use strict' + +const WASM_BUILDER_CONTAINER = 'ghcr.io/nodejs/wasm-builder@sha256:975f391d907e42a75b8c72eb77c782181e941608687d4d8694c3e9df415a0970' // v0.0.9 + +const { execSync } = require('node:child_process') +const { writeFileSync, readFileSync } = require('node:fs') +const { join, resolve } = require('node:path') + +const ROOT = resolve(__dirname, '../') +const WASM_SRC = resolve(__dirname, '../deps/llhttp') +const WASM_OUT = resolve(__dirname, '../lib/llhttp') + +// These are defined by build environment +const WASM_CC = process.env.WASM_CC || 'clang' +let WASM_CFLAGS = process.env.WASM_CFLAGS || '--sysroot=/usr/share/wasi-sysroot -target wasm32-unknown-wasi' +let WASM_LDFLAGS = process.env.WASM_LDFLAGS || '' +const WASM_LDLIBS = process.env.WASM_LDLIBS || '' +const WASM_OPT = process.env.WASM_OPT || './wasm-opt' + +// For compatibility with Node.js' `configure --shared-builtin-undici/undici-path ...` +const EXTERNAL_PATH = process.env.EXTERNAL_PATH + +// These are relevant for undici and should not be overridden +WASM_CFLAGS += ' -Ofast -fno-exceptions -fvisibility=hidden -mexec-model=reactor' +WASM_LDFLAGS += ' -Wl,-error-limit=0 -Wl,-O3 -Wl,--lto-O3 -Wl,--strip-all' +WASM_LDFLAGS += ' -Wl,--allow-undefined -Wl,--export-dynamic -Wl,--export-table' +WASM_LDFLAGS += ' -Wl,--export=malloc -Wl,--export=free -Wl,--no-entry' + +const WASM_OPT_FLAGS = '-O4 --converge --strip-debug --strip-dwarf --strip-producers' + +const writeWasmChunk = (path, dest) => { + const base64 = readFileSync(join(WASM_OUT, path)).toString('base64') + writeFileSync(join(WASM_OUT, dest), `'use strict' + +const { Buffer } = require('node:buffer') + +const wasmBase64 = '${base64}' + +let wasmBuffer + +Object.defineProperty(module, 'exports', { + get: () => { + return wasmBuffer + ? wasmBuffer + : (wasmBuffer = Buffer.from(wasmBase64, 'base64')) + } +}) +`) +} + +let platform = process.env.WASM_PLATFORM +if (!platform && process.argv[2]) { + platform = execSync('docker info -f "{{.OSType}}/{{.Architecture}}"').toString().trim() +} + +if (process.argv[2] === '--docker') { + let cmd = `docker run --rm --platform=${platform.toString().trim()} ` + if (process.platform === 'linux') { + cmd += ` --user ${process.getuid()}:${process.getegid()}` + } + + cmd += ` --mount type=bind,source=${ROOT}/lib/llhttp,target=/home/node/build/lib/llhttp \ + --mount type=bind,source=${ROOT}/build,target=/home/node/build/build \ + --mount type=bind,source=${ROOT}/deps,target=/home/node/build/deps \ + -t ${WASM_BUILDER_CONTAINER} node build/wasm.js` + console.log(`> ${cmd}\n\n`) + execSync(cmd, { stdio: 'inherit' }) + process.exit(0) +} + +const hasApk = (function () { + try { execSync('command -v apk'); return true } catch (error) { return false } +})() +const hasOptimizer = (function () { + try { execSync(`${WASM_OPT} --version`); return true } catch (error) { return false } +})() +if (hasApk) { + // Gather information about the tools used for the build + const buildInfo = execSync('apk info -v').toString() + if (!buildInfo.includes('wasi-sdk')) { + console.log('Failed to generate build environment information') + process.exit(-1) + } + console.log(buildInfo) +} + +// Build wasm binary +execSync(`${WASM_CC} ${WASM_CFLAGS} ${WASM_LDFLAGS} \ +${join(WASM_SRC, 'src')}/*.c \ +-I${join(WASM_SRC, 'include')} \ +-o ${join(WASM_OUT, 'llhttp.wasm')} \ +${WASM_LDLIBS}`, { stdio: 'inherit' }) + +if (hasOptimizer) { + execSync(`${WASM_OPT} ${WASM_OPT_FLAGS} -o ${join(WASM_OUT, 'llhttp.wasm')} ${join(WASM_OUT, 'llhttp.wasm')}`, { stdio: 'inherit' }) +} +writeWasmChunk('llhttp.wasm', 'llhttp-wasm.js') + +// Build wasm simd binary +execSync(`${WASM_CC} ${WASM_CFLAGS} -msimd128 ${WASM_LDFLAGS} \ +${join(WASM_SRC, 'src')}/*.c \ +-I${join(WASM_SRC, 'include')} \ +-o ${join(WASM_OUT, 'llhttp_simd.wasm')} \ +${WASM_LDLIBS}`, { stdio: 'inherit' }) + +if (hasOptimizer) { + execSync(`${WASM_OPT} ${WASM_OPT_FLAGS} --enable-simd -o ${join(WASM_OUT, 'llhttp_simd.wasm')} ${join(WASM_OUT, 'llhttp_simd.wasm')}`, { stdio: 'inherit' }) +} +writeWasmChunk('llhttp_simd.wasm', 'llhttp_simd-wasm.js') + +// For compatibility with Node.js' `configure --shared-builtin-undici/undici-path ...` +if (EXTERNAL_PATH) { + writeFileSync(join(ROOT, 'loader.js'), ` +'use strict' +globalThis.__UNDICI_IS_NODE__ = true +module.exports = require('node:module').createRequire('${EXTERNAL_PATH}/loader.js')('./index-fetch.js') +delete globalThis.__UNDICI_IS_NODE__ +`) +} diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..27d813e --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +undici.nodejs.org \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/docs/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/docs/docs/api/Agent.md b/docs/docs/api/Agent.md new file mode 100644 index 0000000..43b8d30 --- /dev/null +++ b/docs/docs/api/Agent.md @@ -0,0 +1,77 @@ +# Agent + +Extends: `undici.Dispatcher` + +Agent allow dispatching requests against multiple different origins. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new undici.Agent([options])` + +Arguments: + +* **options** `AgentOptions` (optional) + +Returns: `Agent` + +### Parameter: `AgentOptions` + +Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions) + +* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)` + +## Instance Properties + +### `Agent.closed` + +Implements [Client.closed](/docs/docs/api/Client.md#clientclosed) + +### `Agent.destroyed` + +Implements [Client.destroyed](/docs/docs/api/Client.md#clientdestroyed) + +## Instance Methods + +### `Agent.close([callback])` + +Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise). + +### `Agent.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `Agent.dispatch(options, handler: AgentDispatchOptions)` + +Implements [`Dispatcher.dispatch(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +#### Parameter: `AgentDispatchOptions` + +Extends: [`DispatchOptions`](/docs/docs/api/Dispatcher.md#parameter-dispatchoptions) + +* **origin** `string | URL` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `Agent.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback). + +### `Agent.dispatch(options, handler)` + +Implements [`Dispatcher.dispatch(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `Agent.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler). + +### `Agent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +### `Agent.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `Agent.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). diff --git a/docs/docs/api/BalancedPool.md b/docs/docs/api/BalancedPool.md new file mode 100644 index 0000000..df267fe --- /dev/null +++ b/docs/docs/api/BalancedPool.md @@ -0,0 +1,99 @@ +# Class: BalancedPool + +Extends: `undici.Dispatcher` + +A pool of [Pool](/docs/docs/api/Pool.md) instances connected to multiple upstreams. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new BalancedPool(upstreams [, options])` + +Arguments: + +* **upstreams** `URL | string | string[]` - It should only include the **protocol, hostname, and port**. +* **options** `BalancedPoolOptions` (optional) + +### Parameter: `BalancedPoolOptions` + +Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions) + +* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)` + +The `PoolOptions` are passed to each of the `Pool` instances being created. +## Instance Properties + +### `BalancedPool.upstreams` + +Returns an array of upstreams that were previously added. + +### `BalancedPool.closed` + +Implements [Client.closed](/docs/docs/api/Client.md#clientclosed) + +### `BalancedPool.destroyed` + +Implements [Client.destroyed](/docs/docs/api/Client.md#clientdestroyed) + +### `Pool.stats` + +Returns [`PoolStats`](/docs/docs/api/PoolStats.md) instance for this pool. + +## Instance Methods + +### `BalancedPool.addUpstream(upstream)` + +Add an upstream. + +Arguments: + +* **upstream** `string` - It should only include the **protocol, hostname, and port**. + +### `BalancedPool.removeUpstream(upstream)` + +Removes an upstream that was previously added. + +### `BalancedPool.close([callback])` + +Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise). + +### `BalancedPool.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `BalancedPool.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback). + +### `BalancedPool.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `BalancedPool.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler). + +### `BalancedPool.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +### `BalancedPool.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `BalancedPool.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](/docs/docs/api/Dispatcher.md#event-connect). + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](/docs/docs/api/Dispatcher.md#event-disconnect). + +### Event: `'drain'` + +See [Dispatcher Event: `'drain'`](/docs/docs/api/Dispatcher.md#event-drain). diff --git a/docs/docs/api/CacheStorage.md b/docs/docs/api/CacheStorage.md new file mode 100644 index 0000000..08ee99f --- /dev/null +++ b/docs/docs/api/CacheStorage.md @@ -0,0 +1,30 @@ +# CacheStorage + +Undici exposes a W3C spec-compliant implementation of [CacheStorage](https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage) and [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + +## Opening a Cache + +Undici exports a top-level CacheStorage instance. You can open a new Cache, or duplicate a Cache with an existing name, by using `CacheStorage.prototype.open`. If you open a Cache with the same name as an already-existing Cache, its list of cached Responses will be shared between both instances. + +```mjs +import { caches } from 'undici' + +const cache_1 = await caches.open('v1') +const cache_2 = await caches.open('v1') + +// Although .open() creates a new instance, +assert(cache_1 !== cache_2) +// The same Response is matched in both. +assert.deepStrictEqual(await cache_1.match('/req'), await cache_2.match('/req')) +``` + +## Deleting a Cache + +If a Cache is deleted, the cached Responses/Requests can still be used. + +```mjs +const response = await cache_1.match('/req') +await caches.delete('v1') + +await response.text() // the Response's body +``` diff --git a/docs/docs/api/CacheStore.md b/docs/docs/api/CacheStore.md new file mode 100644 index 0000000..7cd19e0 --- /dev/null +++ b/docs/docs/api/CacheStore.md @@ -0,0 +1,131 @@ +# Cache Store + +A Cache Store is responsible for storing and retrieving cached responses. +It is also responsible for deciding which specific response to use based off of +a response's `Vary` header (if present). It is expected to be compliant with +[RFC-9111](https://www.rfc-editor.org/rfc/rfc9111.html). + +## Pre-built Cache Stores + +### `MemoryCacheStore` + +The `MemoryCacheStore` stores the responses in-memory. + +**Options** + +- `maxCount` - The maximum amount of responses to store. Default `Infinity`. +- `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached. + +### `SqliteCacheStore` + +The `SqliteCacheStore` stores the responses in a SQLite database. +Under the hood, it uses Node.js' [`node:sqlite`](https://nodejs.org/api/sqlite.html) api. +The `SqliteCacheStore` is only exposed if the `node:sqlite` api is present. + +**Options** + +- `location` - The location of the SQLite database to use. Default `:memory:`. +- `maxCount` - The maximum number of entries to store in the database. Default `Infinity`. +- `maxEntrySize` - The maximum size in bytes that a resposne's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`. + +## Defining a Custom Cache Store + +The store must implement the following functions: + +### Getter: `isFull` + +Optional. This tells the cache interceptor if the store is full or not. If this is true, +the cache interceptor will not attempt to cache the response. + +### Function: `get` + +Parameters: + +* **req** `Dispatcher.RequestOptions` - Incoming request + +Returns: `GetResult | Promise | undefined` - If the request is cached, the cached response is returned. If the request's method is anything other than HEAD, the response is also returned. +If the request isn't cached, `undefined` is returned. + +Response properties: + +* **response** `CacheValue` - The cached response data. +* **body** `Readable | undefined` - The response's body. + +### Function: `createWriteStream` + +Parameters: + +* **req** `Dispatcher.RequestOptions` - Incoming request +* **value** `CacheValue` - Response to store + +Returns: `Writable | undefined` - If the store is full, return `undefined`. Otherwise, return a writable so that the cache interceptor can stream the body and trailers to the store. + +## `CacheValue` + +This is an interface containing the majority of a response's data (minus the body). + +### Property `statusCode` + +`number` - The response's HTTP status code. + +### Property `statusMessage` + +`string` - The response's HTTP status message. + +### Property `rawHeaders` + +`Buffer[]` - The response's headers. + +### Property `vary` + +`Record | undefined` - The headers defined by the response's `Vary` header +and their respective values for later comparison + +For example, for a response like +``` +Vary: content-encoding, accepts +content-encoding: utf8 +accepts: application/json +``` + +This would be +```js +{ + 'content-encoding': 'utf8', + accepts: 'application/json' +} +``` + +### Property `cachedAt` + +`number` - Time in millis that this value was cached. + +### Property `staleAt` + +`number` - Time in millis that this value is considered stale. + +### Property `deleteAt` + +`number` - Time in millis that this value is to be deleted from the cache. This +is either the same sa staleAt or the `max-stale` caching directive. + +The store must not return a response after the time defined in this property. + +## `CacheStoreReadable` + +This extends Node's [`Readable`](https://nodejs.org/api/stream.html#class-streamreadable) +and defines extra properties relevant to the cache interceptor. + +### Getter: `value` + +The response's [`CacheStoreValue`](/docs/docs/api/CacheStore.md#cachestorevalue) + +## `CacheStoreWriteable` + +This extends Node's [`Writable`](https://nodejs.org/api/stream.html#class-streamwritable) +and defines extra properties relevant to the cache interceptor. + +### Setter: `rawTrailers` + +If the response has trailers, the cache interceptor will pass them to the cache +interceptor through this method. diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md new file mode 100644 index 0000000..eab6ddc --- /dev/null +++ b/docs/docs/api/Client.md @@ -0,0 +1,281 @@ +# Class: Client + +Extends: `undici.Dispatcher` + +A basic HTTP/1.1 client, mapped on top a single TCP/TLS connection. Pipelining is disabled by default. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new Client(url[, options])` + +Arguments: + +* **url** `URL | string` - Should only include the **protocol, hostname, and port**. +* **options** `ClientOptions` (optional) + +Returns: `Client` + +### Parameter: `ClientOptions` + +* **bodyTimeout** `number | null` (optional) - Default: `300e3` - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. Please note the `timeout` will be reset if you keep writing data to the socket everytime. +* **headersTimeout** `number | null` (optional) - Default: `300e3` - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds. +* **keepAliveMaxTimeout** `number | null` (optional) - Default: `600e3` - The maximum allowed `keepAliveTimeout`, in milliseconds, when overridden by *keep-alive* hints from the server. Defaults to 10 minutes. +* **keepAliveTimeout** `number | null` (optional) - Default: `4e3` - The timeout, in milliseconds, after which a socket without active requests will time out. Monitors time between activity on a connected socket. This value may be overridden by *keep-alive* hints from the server. See [MDN: HTTP - Headers - Keep-Alive directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive#directives) for more details. Defaults to 4 seconds. +* **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server *keep-alive* hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds. +* **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. +* **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. +* **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. +* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. +* **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. +* **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version. +* **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. +* **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. +* **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. + +> **Notes about HTTP/2** +> - It only works under TLS connections. h2c is not supported. +> - The server must support HTTP/2 and choose it as the protocol during the ALPN negotiation. +> - The server must not have a bigger priority for HTTP/1.1 than HTTP/2. +> - Pseudo headers are automatically attached to the request. If you try to set them, they will be overwritten. +> - The `:path` header is automatically set to the request path. +> - The `:method` header is automatically set to the request method. +> - The `:scheme` header is automatically set to the request scheme. +> - The `:authority` header is automatically set to the request `host[:port]`. +> - `PUSH` frames are yet not supported. + +#### Parameter: `ConnectOptions` + +Every Tls option, see [here](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback). +Furthermore, the following options can be passed: + +* **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe. +* **maxCachedSessions** `number | null` (optional) - Default: `100` - Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. Default: 100. +* **timeout** `number | null` (optional) - In milliseconds, Default `10e3`. +* **servername** `string | null` (optional) +* **keepAlive** `boolean | null` (optional) - Default: `true` - TCP keep-alive enabled +* **keepAliveInitialDelay** `number | null` (optional) - Default: `60000` - TCP keep-alive interval for the socket in milliseconds + +### Example - Basic Client instantiation + +This will instantiate the undici Client, but it will not connect to the origin until something is queued. Consider using `client.connect` to prematurely connect to the origin, or just call `client.request`. + +```js +'use strict' +import { Client } from 'undici' + +const client = new Client('http://localhost:3000') +``` + +### Example - Custom connector + +This will allow you to perform some additional check on the socket that will be used for the next request. + +```js +'use strict' +import { Client, buildConnector } from 'undici' + +const connector = buildConnector({ rejectUnauthorized: false }) +const client = new Client('https://localhost:3000', { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (/* assertion */) { + socket.destroy() + cb(new Error('kaboom')) + } else { + cb(null, socket) + } + }) + } +}) +``` + +## Instance Methods + +### `Client.close([callback])` + +Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise). + +### `Client.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +Waits until socket is closed before invoking the callback (or returning a promise if no callback is provided). + +### `Client.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback). + +### `Client.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `Client.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler). + +### `Client.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +### `Client.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `Client.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Properties + +### `Client.closed` + +* `boolean` + +`true` after `client.close()` has been called. + +### `Client.destroyed` + +* `boolean` + +`true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed. + +### `Client.pipelining` + +* `number` + +Property to get and set the pipelining factor. + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](/docs/docs/api/Dispatcher.md#event-connect). + +Parameters: + +* **origin** `URL` +* **targets** `Array` + +Emitted when a socket has been created and connected. The client will connect once `client.size > 0`. + +#### Example - Client connect event + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +client.on('connect', (origin) => { + console.log(`Connected to ${origin}`) // should print before the request body statement +}) + +try { + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('utf-8') + body.on('data', console.log) + client.close() + server.close() +} catch (error) { + console.error(error) + client.close() + server.close() +} +``` + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](/docs/docs/api/Dispatcher.md#event-disconnect). + +Parameters: + +* **origin** `URL` +* **targets** `Array` +* **error** `Error` + +Emitted when socket has disconnected. The error argument of the event is the error which caused the socket to disconnect. The client will reconnect if or once `client.size > 0`. + +#### Example - Client disconnect event + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.destroy() +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +client.on('disconnect', (origin) => { + console.log(`Disconnected from ${origin}`) +}) + +try { + await client.request({ + path: '/', + method: 'GET' + }) +} catch (error) { + console.error(error.message) + client.close() + server.close() +} +``` + +### Event: `'drain'` + +Emitted when pipeline is no longer busy. + +See [Dispatcher Event: `'drain'`](/docs/docs/api/Dispatcher.md#event-drain). + +#### Example - Client drain event + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +client.on('drain', () => { + console.log('drain event') + client.close() + server.close() +}) + +const requests = [ + client.request({ path: '/', method: 'GET' }), + client.request({ path: '/', method: 'GET' }), + client.request({ path: '/', method: 'GET' }) +] + +await Promise.all(requests) + +console.log('requests completed') +``` + +### Event: `'error'` + +Invoked for users errors such as throwing in the `onError` handler. diff --git a/docs/docs/api/Connector.md b/docs/docs/api/Connector.md new file mode 100644 index 0000000..56821bd --- /dev/null +++ b/docs/docs/api/Connector.md @@ -0,0 +1,115 @@ +# Connector + +Undici creates the underlying socket via the connector builder. +Normally, this happens automatically and you don't need to care about this, +but if you need to perform some additional check over the currently used socket, +this is the right place. + +If you want to create a custom connector, you must import the `buildConnector` utility. + +#### Parameter: `buildConnector.BuildOptions` + +Every Tls option, see [here](https://nodejs.org/api/tls.html#tls_tls_connect_options_callback). +Furthermore, the following options can be passed: + +* **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe. +* **maxCachedSessions** `number | null` (optional) - Default: `100` - Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. Default: `100`. +* **timeout** `number | null` (optional) - In milliseconds. Default `10e3`. +* **servername** `string | null` (optional) + +Once you call `buildConnector`, it will return a connector function, which takes the following parameters. + +#### Parameter: `connector.Options` + +* **hostname** `string` (required) +* **host** `string` (optional) +* **protocol** `string` (required) +* **port** `string` (required) +* **servername** `string` (optional) +* **localAddress** `string | null` (optional) Local address the socket should connect from. +* **httpSocket** `Socket` (optional) Establish secure connection on a given socket rather than creating a new socket. It can only be sent on TLS update. + +### Basic example + +```js +'use strict' + +import { Client, buildConnector } from 'undici' + +const connector = buildConnector({ rejectUnauthorized: false }) +const client = new Client('https://localhost:3000', { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (/* assertion */) { + socket.destroy() + cb(new Error('kaboom')) + } else { + cb(null, socket) + } + }) + } +}) +``` + +### Example: validate the CA fingerprint + +```js +'use strict' + +import { Client, buildConnector } from 'undici' + +const caFingerprint = 'FO:OB:AR' +const connector = buildConnector({ rejectUnauthorized: false }) +const client = new Client('https://localhost:3000', { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) { + socket.destroy() + cb(new Error('Fingerprint does not match or malformed certificate')) + } else { + cb(null, socket) + } + }) + } +}) + +client.request({ + path: '/', + method: 'GET' +}, (err, data) => { + if (err) throw err + + const bufs = [] + data.body.on('data', (buf) => { + bufs.push(buf) + }) + data.body.on('end', () => { + console.log(Buffer.concat(bufs).toString('utf8')) + client.close() + }) +}) + +function getIssuerCertificate (socket) { + let certificate = socket.getPeerCertificate(true) + while (certificate && Object.keys(certificate).length > 0) { + // invalid certificate + if (certificate.issuerCertificate == null) { + return null + } + + // We have reached the root certificate. + // In case of self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) { + break + } + + // continue the loop + certificate = certificate.issuerCertificate + } + return certificate +} +``` diff --git a/docs/docs/api/ContentType.md b/docs/docs/api/ContentType.md new file mode 100644 index 0000000..2bcc9f7 --- /dev/null +++ b/docs/docs/api/ContentType.md @@ -0,0 +1,57 @@ +# MIME Type Parsing + +## `MIMEType` interface + +* **type** `string` +* **subtype** `string` +* **parameters** `Map` +* **essence** `string` + +## `parseMIMEType(input)` + +Implements [parse a MIME type](https://mimesniff.spec.whatwg.org/#parse-a-mime-type). + +Parses a MIME type, returning its type, subtype, and any associated parameters. If the parser can't parse an input it returns the string literal `'failure'`. + +```js +import { parseMIMEType } from 'undici' + +parseMIMEType('text/html; charset=gbk') +// { +// type: 'text', +// subtype: 'html', +// parameters: Map(1) { 'charset' => 'gbk' }, +// essence: 'text/html' +// } +``` + +Arguments: + +* **input** `string` + +Returns: `MIMEType|'failure'` + +## `serializeAMimeType(input)` + +Implements [serialize a MIME type](https://mimesniff.spec.whatwg.org/#serialize-a-mime-type). + +Serializes a MIMEType object. + +```js +import { serializeAMimeType } from 'undici' + +serializeAMimeType({ + type: 'text', + subtype: 'html', + parameters: new Map([['charset', 'gbk']]), + essence: 'text/html' +}) +// text/html;charset=gbk + +``` + +Arguments: + +* **mimeType** `MIMEType` + +Returns: `string` diff --git a/docs/docs/api/Cookies.md b/docs/docs/api/Cookies.md new file mode 100644 index 0000000..0cad379 --- /dev/null +++ b/docs/docs/api/Cookies.md @@ -0,0 +1,101 @@ +# Cookie Handling + +## `Cookie` interface + +* **name** `string` +* **value** `string` +* **expires** `Date|number` (optional) +* **maxAge** `number` (optional) +* **domain** `string` (optional) +* **path** `string` (optional) +* **secure** `boolean` (optional) +* **httpOnly** `boolean` (optional) +* **sameSite** `'String'|'Lax'|'None'` (optional) +* **unparsed** `string[]` (optional) Left over attributes that weren't parsed. + +## `deleteCookie(headers, name[, attributes])` + +Sets the expiry time of the cookie to the unix epoch, causing browsers to delete it when received. + +```js +import { deleteCookie, Headers } from 'undici' + +const headers = new Headers() +deleteCookie(headers, 'name') + +console.log(headers.get('set-cookie')) // name=; Expires=Thu, 01 Jan 1970 00:00:00 GMT +``` + +Arguments: + +* **headers** `Headers` +* **name** `string` +* **attributes** `{ path?: string, domain?: string }` (optional) + +Returns: `void` + +## `getCookies(headers)` + +Parses the `Cookie` header and returns a list of attributes and values. + +```js +import { getCookies, Headers } from 'undici' + +const headers = new Headers({ + cookie: 'get=cookies; and=attributes' +}) + +console.log(getCookies(headers)) // { get: 'cookies', and: 'attributes' } +``` + +Arguments: + +* **headers** `Headers` + +Returns: `Record` + +## `getSetCookies(headers)` + +Parses all `Set-Cookie` headers. + +```js +import { getSetCookies, Headers } from 'undici' + +const headers = new Headers({ 'set-cookie': 'undici=getSetCookies; Secure' }) + +console.log(getSetCookies(headers)) +// [ +// { +// name: 'undici', +// value: 'getSetCookies', +// secure: true +// } +// ] + +``` + +Arguments: + +* **headers** `Headers` + +Returns: `Cookie[]` + +## `setCookie(headers, cookie)` + +Appends a cookie to the `Set-Cookie` header. + +```js +import { setCookie, Headers } from 'undici' + +const headers = new Headers() +setCookie(headers, { name: 'undici', value: 'setCookie' }) + +console.log(headers.get('Set-Cookie')) // undici=setCookie +``` + +Arguments: + +* **headers** `Headers` +* **cookie** `Cookie` + +Returns: `void` diff --git a/docs/docs/api/Debug.md b/docs/docs/api/Debug.md new file mode 100644 index 0000000..f7a8864 --- /dev/null +++ b/docs/docs/api/Debug.md @@ -0,0 +1,62 @@ +# Debug + +Undici (and subsenquently `fetch` and `websocket`) exposes a debug statement that can be enabled by setting `NODE_DEBUG` within the environment. + +The flags available are: + +## `undici` + +This flag enables debug statements for the core undici library. + +```sh +NODE_DEBUG=undici node script.js + +UNDICI 16241: connecting to nodejs.org using https:h1 +UNDICI 16241: connecting to nodejs.org using https:h1 +UNDICI 16241: connected to nodejs.org using https:h1 +UNDICI 16241: sending request to GET https://nodejs.org// +UNDICI 16241: received response to GET https://nodejs.org// - HTTP 307 +UNDICI 16241: connecting to nodejs.org using https:h1 +UNDICI 16241: trailers received from GET https://nodejs.org// +UNDICI 16241: connected to nodejs.org using https:h1 +UNDICI 16241: sending request to GET https://nodejs.org//en +UNDICI 16241: received response to GET https://nodejs.org//en - HTTP 200 +UNDICI 16241: trailers received from GET https://nodejs.org//en +``` + +## `fetch` + +This flag enables debug statements for the `fetch` API. + +> **Note**: statements are pretty similar to the ones in the `undici` flag, but scoped to `fetch` + +```sh +NODE_DEBUG=fetch node script.js + +FETCH 16241: connecting to nodejs.org using https:h1 +FETCH 16241: connecting to nodejs.org using https:h1 +FETCH 16241: connected to nodejs.org using https:h1 +FETCH 16241: sending request to GET https://nodejs.org// +FETCH 16241: received response to GET https://nodejs.org// - HTTP 307 +FETCH 16241: connecting to nodejs.org using https:h1 +FETCH 16241: trailers received from GET https://nodejs.org// +FETCH 16241: connected to nodejs.org using https:h1 +FETCH 16241: sending request to GET https://nodejs.org//en +FETCH 16241: received response to GET https://nodejs.org//en - HTTP 200 +FETCH 16241: trailers received from GET https://nodejs.org//en +``` + +## `websocket` + +This flag enables debug statements for the `Websocket` API. + +> **Note**: statements can overlap with `UNDICI` ones if `undici` or `fetch` flag has been enabled as well. + +```sh +NODE_DEBUG=websocket node script.js + +WEBSOCKET 18309: connecting to echo.websocket.org using https:h1 +WEBSOCKET 18309: connected to echo.websocket.org using https:h1 +WEBSOCKET 18309: sending request to GET https://echo.websocket.org// +WEBSOCKET 18309: connection opened +``` diff --git a/docs/docs/api/DiagnosticsChannel.md b/docs/docs/api/DiagnosticsChannel.md new file mode 100644 index 0000000..a3635cb --- /dev/null +++ b/docs/docs/api/DiagnosticsChannel.md @@ -0,0 +1,204 @@ +# Diagnostics Channel Support + +Stability: Experimental. + +Undici supports the [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) (currently available only on Node.js v16+). +It is the preferred way to instrument Undici and retrieve internal information. + +The channels available are the following. + +## `undici:request:create` + +This message is published when a new outgoing request is created. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => { + console.log('origin', request.origin) + console.log('completed', request.completed) + console.log('method', request.method) + console.log('path', request.path) + console.log('headers') // array of strings, e.g: ['foo', 'bar'] + request.addHeader('hello', 'world') + console.log('headers', request.headers) // e.g. ['foo', 'bar', 'hello', 'world'] +}) +``` + +Note: a request is only loosely completed to a given socket. + + +## `undici:request:bodySent` + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:bodySent').subscribe(({ request }) => { + // request is the same object undici:request:create +}) +``` + +## `undici:request:headers` + +This message is published after the response headers have been received. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:headers').subscribe(({ request, response }) => { + // request is the same object undici:request:create + console.log('statusCode', response.statusCode) + console.log(response.statusText) + // response.headers are buffers. + console.log(response.headers.map((x) => x.toString())) +}) +``` + +## `undici:request:trailers` + +This message is published after the response body and trailers have been received, i.e. the response has been completed. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:trailers').subscribe(({ request, trailers }) => { + // request is the same object undici:request:create + console.log('completed', request.completed) + // trailers are buffers. + console.log(trailers.map((x) => x.toString())) +}) +``` + +## `undici:request:error` + +This message is published if the request is going to error, but it has not errored yet. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:request:error').subscribe(({ request, error }) => { + // request is the same object undici:request:create +}) +``` + +## `undici:client:sendHeaders` + +This message is published right before the first byte of the request is written to the socket. + +*Note*: It will publish the exact headers that will be sent to the server in raw format. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:sendHeaders').subscribe(({ request, headers, socket }) => { + // request is the same object undici:request:create + console.log(`Full headers list ${headers.split('\r\n')}`); +}) +``` + +## `undici:client:beforeConnect` + +This message is published before creating a new connection for **any** request. +You can not assume that this event is related to any specific request. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:beforeConnect').subscribe(({ connectParams, connector }) => { + // const { host, hostname, protocol, port, servername, version } = connectParams + // connector is a function that creates the socket +}) +``` + +## `undici:client:connected` + +This message is published after a connection is established. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:connected').subscribe(({ socket, connectParams, connector }) => { + // const { host, hostname, protocol, port, servername, version } = connectParams + // connector is a function that creates the socket +}) +``` + +## `undici:client:connectError` + +This message is published if it did not succeed to create new connection + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:client:connectError').subscribe(({ error, socket, connectParams, connector }) => { + // const { host, hostname, protocol, port, servername, version } = connectParams + // connector is a function that creates the socket + console.log(`Connect failed with ${error.message}`) +}) +``` + +## `undici:websocket:open` + +This message is published after the client has successfully connected to a server. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:open').subscribe(({ address, protocol, extensions }) => { + console.log(address) // address, family, and port + console.log(protocol) // negotiated subprotocols + console.log(extensions) // negotiated extensions +}) +``` + +## `undici:websocket:close` + +This message is published after the connection has closed. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:close').subscribe(({ websocket, code, reason }) => { + console.log(websocket) // the WebSocket object + console.log(code) // the closing status code + console.log(reason) // the closing reason +}) +``` + +## `undici:websocket:socket_error` + +This message is published if the socket experiences an error. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:socket_error').subscribe((error) => { + console.log(error) +}) +``` + +## `undici:websocket:ping` + +This message is published after the client receives a ping frame, if the connection is not closing. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:ping').subscribe(({ payload }) => { + // a Buffer or undefined, containing the optional application data of the frame + console.log(payload) +}) +``` + +## `undici:websocket:pong` + +This message is published after the client receives a pong frame. + +```js +import diagnosticsChannel from 'diagnostics_channel' + +diagnosticsChannel.channel('undici:websocket:pong').subscribe(({ payload }) => { + // a Buffer or undefined, containing the optional application data of the frame + console.log(payload) +}) +``` diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md new file mode 100644 index 0000000..fb7e87d --- /dev/null +++ b/docs/docs/api/Dispatcher.md @@ -0,0 +1,1200 @@ +# Dispatcher + +Extends: `events.EventEmitter` + +Dispatcher is the core API used to dispatch requests. + +Requests are not guaranteed to be dispatched in order of invocation. + +## Instance Methods + +### `Dispatcher.close([callback]): Promise` + +Closes the dispatcher and gracefully waits for enqueued requests to complete before resolving. + +Arguments: + +* **callback** `(error: Error | null, data: null) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +```js +dispatcher.close() // -> Promise +dispatcher.close(() => {}) // -> void +``` + +#### Example - Request resolves before Client closes + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('undici') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('utf8') + body.on('data', console.log) +} catch (error) {} + +await client.close() + +console.log('Client closed') +server.close() +``` + +### `Dispatcher.connect(options[, callback])` + +Starts two-way communications with the requested resource using [HTTP CONNECT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT). + +Arguments: + +* **options** `ConnectOptions` +* **callback** `(err: Error | null, data: ConnectData | null) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +#### Parameter: `ConnectOptions` + +* **path** `string` +* **headers** `UndiciHeaders` (optional) - Default: `null` +* **signal** `AbortSignal | events.EventEmitter | null` (optional) - Default: `null` +* **opaque** `unknown` (optional) - This argument parameter is passed through to `ConnectData` + +#### Parameter: `ConnectData` + +* **statusCode** `number` +* **headers** `Record` +* **socket** `stream.Duplex` +* **opaque** `unknown` + +#### Example - Connect request with echo + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + throw Error('should never get here') +}).listen() + +server.on('connect', (req, socket, head) => { + socket.write('HTTP/1.1 200 Connection established\r\n\r\n') + + let data = head.toString() + socket.on('data', (buf) => { + data += buf.toString() + }) + + socket.on('end', () => { + socket.end(data) + }) +}) + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { socket } = await client.connect({ + path: '/' + }) + const wanted = 'Body' + let data = '' + socket.on('data', d => { data += d }) + socket.on('end', () => { + console.log(`Data received: ${data.toString()} | Data wanted: ${wanted}`) + client.close() + server.close() + }) + socket.write(wanted) + socket.end() +} catch (error) { } +``` + +### `Dispatcher.destroy([error, callback]): Promise` + +Destroy the dispatcher abruptly with the given error. All the pending and running requests will be asynchronously aborted and error. Since this operation is asynchronously dispatched there might still be some progress on dispatched requests. + +Both arguments are optional; the method can be called in four different ways: + +Arguments: + +* **error** `Error | null` (optional) +* **callback** `(error: Error | null, data: null) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +```js +dispatcher.destroy() // -> Promise +dispatcher.destroy(new Error()) // -> Promise +dispatcher.destroy(() => {}) // -> void +dispatcher.destroy(new Error(), () => {}) // -> void +``` + +#### Example - Request is aborted when Client is destroyed + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end() +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const request = client.request({ + path: '/', + method: 'GET' + }) + client.destroy() + .then(() => { + console.log('Client destroyed') + server.close() + }) + await request +} catch (error) { + console.error(error) +} +``` + +### `Dispatcher.dispatch(options, handler)` + +This is the low level API which all the preceding APIs are implemented on top of. +This API is expected to evolve through semver-major versions and is less stable than the preceding higher level APIs. +It is primarily intended for library developers who implement higher level APIs on top of this. + +Arguments: + +* **options** `DispatchOptions` +* **handler** `DispatchHandler` + +Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls won't make any progress until the `'drain'` event has been emitted. + +#### Parameter: `DispatchOptions` + +* **origin** `string | URL` +* **path** `string` +* **method** `string` +* **reset** `boolean` (optional) - Default: `false` - If `false`, the request will attempt to create a long-living connection by sending the `connection: keep-alive` header,otherwise will attempt to close it immediately after response by sending `connection: close` within the request and closing the socket afterwards. +* **body** `string | Buffer | Uint8Array | stream.Readable | Iterable | AsyncIterable | null` (optional) - Default: `null` +* **headers** `UndiciHeaders | string[]` (optional) - Default: `null`. +* **query** `Record | null` (optional) - Default: `null` - Query string params to be embedded in the request URL. Note that both keys and values of query are encoded using `encodeURIComponent`. If for some reason you need to send them unencoded, embed query params into path directly instead. +* **idempotent** `boolean` (optional) - Default: `true` if `method` is `'HEAD'` or `'GET'` - Whether the requests can be safely retried or not. If `false` the request won't be sent until all preceding requests in the pipeline has completed. +* **blocking** `boolean` (optional) - Default: `method !== 'HEAD'` - Whether the response is expected to take a long time and would end up blocking the pipeline. When this is set to `true` further pipelining will be avoided on the same connection until headers have been received. +* **upgrade** `string | null` (optional) - Default: `null` - Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`. +* **bodyTimeout** `number | null` (optional) - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. +* **headersTimeout** `number | null` (optional) - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds. +* **expectContinue** `boolean` (optional) - Default: `false` - For H2, it appends the expect: 100-continue header, and halts the request body until a 100-continue is received from the remote server + +#### Parameter: `DispatchHandler` + +* **onRequestStart** `(controller: DispatchController, context: object) => void` - Invoked before request is dispatched on socket. May be invoked multiple times when a request is retried when the request at the head of the pipeline fails. +* **onRequestUpgrade** `(controller: DispatchController, statusCode: number, headers: Record, socket: Duplex) => void` (optional) - Invoked when request is upgraded. Required if `DispatchOptions.upgrade` is defined or `DispatchOptions.method === 'CONNECT'`. +* **onResponseStart** `(controller: DispatchController, statusCode: number, headers: Record, statusMessage?: string) => void` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests. +* **onResponseData** `(controller: DispatchController, chunk: Buffer) => void` - Invoked when response payload data is received. Not required for `upgrade` requests. +* **onResponseEnd** `(controller: DispatchController, trailers: Record) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests. +* **onResponseError** `(error: Error) => void` - Invoked when an error has occurred. May not throw. + +#### Example 1 - Dispatch GET request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +const data = [] + +client.dispatch({ + path: '/', + method: 'GET', + headers: { + 'x-foo': 'bar' + } +}, { + onConnect: () => { + console.log('Connected!') + }, + onError: (error) => { + console.error(error) + }, + onHeaders: (statusCode, headers) => { + console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`) + }, + onData: (chunk) => { + console.log('onData: chunk received') + data.push(chunk) + }, + onComplete: (trailers) => { + console.log(`onComplete | trailers: ${trailers}`) + const res = Buffer.concat(data).toString('utf8') + console.log(`Data: ${res}`) + client.close() + server.close() + } +}) +``` + +#### Example 2 - Dispatch Upgrade Request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end() +}).listen() + +await once(server, 'listening') + +server.on('upgrade', (request, socket, head) => { + console.log('Node.js Server - upgrade event') + socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n') + socket.write('Upgrade: WebSocket\r\n') + socket.write('Connection: Upgrade\r\n') + socket.write('\r\n') + socket.end() +}) + +const client = new Client(`http://localhost:${server.address().port}`) + +client.dispatch({ + path: '/', + method: 'GET', + upgrade: 'websocket' +}, { + onConnect: () => { + console.log('Undici Client - onConnect') + }, + onError: (error) => { + console.log('onError') // shouldn't print + }, + onUpgrade: (statusCode, headers, socket) => { + console.log('Undici Client - onUpgrade') + console.log(`onUpgrade Headers: ${headers}`) + socket.on('data', buffer => { + console.log(buffer.toString('utf8')) + }) + socket.on('end', () => { + client.close() + server.close() + }) + socket.end() + } +}) +``` + +#### Example 3 - Dispatch POST request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + request.on('data', (data) => { + console.log(`Request Data: ${data.toString('utf8')}`) + const body = JSON.parse(data) + body.message = 'World' + response.end(JSON.stringify(body)) + }) +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +const data = [] + +client.dispatch({ + path: '/', + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ message: 'Hello' }) +}, { + onConnect: () => { + console.log('Connected!') + }, + onError: (error) => { + console.error(error) + }, + onHeaders: (statusCode, headers) => { + console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`) + }, + onData: (chunk) => { + console.log('onData: chunk received') + data.push(chunk) + }, + onComplete: (trailers) => { + console.log(`onComplete | trailers: ${trailers}`) + const res = Buffer.concat(data).toString('utf8') + console.log(`Response Data: ${res}`) + client.close() + server.close() + } +}) +``` + +### `Dispatcher.pipeline(options, handler)` + +For easy use with [stream.pipeline](https://nodejs.org/api/stream.html#stream_stream_pipeline_source_transforms_destination_callback). The `handler` argument should return a `Readable` from which the result will be read. Usually it should just return the `body` argument unless some kind of transformation needs to be performed based on e.g. `headers` or `statusCode`. The `handler` should validate the response and save any required state. If there is an error, it should be thrown. The function returns a `Duplex` which writes to the request and reads from the response. + +Arguments: + +* **options** `PipelineOptions` +* **handler** `(data: PipelineHandlerData) => stream.Readable` + +Returns: `stream.Duplex` + +#### Parameter: PipelineOptions + +Extends: [`RequestOptions`](/docs/docs/api/Dispatcher.md#parameter-requestoptions) + +* **objectMode** `boolean` (optional) - Default: `false` - Set to `true` if the `handler` will return an object stream. + +#### Parameter: PipelineHandlerData + +* **statusCode** `number` +* **headers** `Record` +* **opaque** `unknown` +* **body** `stream.Readable` +* **context** `object` +* **onInfo** `({statusCode: number, headers: Record}) => void | null` (optional) - Default: `null` - Callback collecting all the info headers (HTTP 100-199) received. + +#### Example 1 - Pipeline Echo + +```js +import { Readable, Writable, PassThrough, pipeline } from 'stream' +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + request.pipe(response) +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +let res = '' + +pipeline( + new Readable({ + read () { + this.push(Buffer.from('undici')) + this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'GET' + }, ({ statusCode, headers, body }) => { + console.log(`response received ${statusCode}`) + console.log('headers', headers) + return pipeline(body, new PassThrough(), () => {}) + }), + new Writable({ + write (chunk, _, callback) { + res += chunk.toString() + callback() + }, + final (callback) { + console.log(`Response pipelined to writable: ${res}`) + callback() + } + }), + error => { + if (error) { + console.error(error) + } + + client.close() + server.close() + } +) +``` + +### `Dispatcher.request(options[, callback])` + +Performs a HTTP request. + +Non-idempotent requests will not be pipelined in order +to avoid indirect failures. + +Idempotent requests will be automatically retried if +they fail due to indirect failure from the request +at the head of the pipeline. This does not apply to +idempotent requests with a stream request body. + +All response bodies must always be fully consumed or destroyed. + +Arguments: + +* **options** `RequestOptions` +* **callback** `(error: Error | null, data: ResponseData) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed. + +#### Parameter: `RequestOptions` + +Extends: [`DispatchOptions`](/docs/docs/api/Dispatcher.md#parameter-dispatchoptions) + +* **opaque** `unknown` (optional) - Default: `null` - Used for passing through context to `ResponseData`. +* **signal** `AbortSignal | events.EventEmitter | null` (optional) - Default: `null`. +* **onInfo** `({statusCode: number, headers: Record}) => void | null` (optional) - Default: `null` - Callback collecting all the info headers (HTTP 100-199) received. + +The `RequestOptions.method` property should not be value `'CONNECT'`. + +#### Parameter: `ResponseData` + +* **statusCode** `number` +* **headers** `Record` - Note that all header keys are lower-cased, e.g. `content-type`. +* **body** `stream.Readable` which also implements [the body mixin from the Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin). +* **trailers** `Record` - This object starts out + as empty and will be mutated to contain trailers after `body` has emitted `'end'`. +* **opaque** `unknown` +* **context** `object` + +`body` contains the following additional [body mixin](https://fetch.spec.whatwg.org/#body-mixin) methods and properties: + +* [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) +* [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +* [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) +* [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) +* [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) +* `body` +* `bodyUsed` + +`body` can not be consumed twice. For example, calling `text()` after `json()` throws `TypeError`. + +`body` contains the following additional extensions: + +- `dump({ limit: Integer })`, dump the response by reading up to `limit` bytes without killing the socket (optional) - Default: 262144. + +Note that body will still be a `Readable` even if it is empty, but attempting to deserialize it with `json()` will result in an exception. Recommended way to ensure there is a body to deserialize is to check if status code is not 204, and `content-type` header starts with `application/json`. + +#### Example 1 - Basic GET Request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { body, headers, statusCode, trailers } = await client.request({ + path: '/', + method: 'GET' + }) + console.log(`response received ${statusCode}`) + console.log('headers', headers) + body.setEncoding('utf8') + body.on('data', console.log) + body.on('error', console.error) + body.on('end', () => { + console.log('trailers', trailers) + }) + + client.close() + server.close() +} catch (error) { + console.error(error) +} +``` + +#### Example 2 - Aborting a request + +> Node.js v15+ is required to run this example + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) +const abortController = new AbortController() + +try { + client.request({ + path: '/', + method: 'GET', + signal: abortController.signal + }) +} catch (error) { + console.error(error) // should print an RequestAbortedError + client.close() + server.close() +} + +abortController.abort() +``` + +Alternatively, any `EventEmitter` that emits an `'abort'` event may be used as an abort controller: + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import EventEmitter, { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) +const ee = new EventEmitter() + +try { + client.request({ + path: '/', + method: 'GET', + signal: ee + }) +} catch (error) { + console.error(error) // should print an RequestAbortedError + client.close() + server.close() +} + +ee.emit('abort') +``` + +Destroying the request or response body will have the same effect. + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.destroy() +} catch (error) { + console.error(error) // should print an RequestAbortedError + client.close() + server.close() +} +``` + +#### Example 3 - Conditionally reading the body + +Remember to fully consume the body even in the case when it is not read. + +```js +const { body, statusCode } = await client.request({ + path: '/', + method: 'GET' +}) + +if (statusCode === 200) { + return await body.arrayBuffer() +} + +await body.dump() + +return null +``` + +### `Dispatcher.stream(options, factory[, callback])` + +A faster version of `Dispatcher.request`. This method expects the second argument `factory` to return a [`stream.Writable`](https://nodejs.org/api/stream.html#stream_class_stream_writable) stream which the response will be written to. This improves performance by avoiding creating an intermediate [`stream.Readable`](https://nodejs.org/api/stream.html#stream_readable_streams) stream when the user expects to directly pipe the response body to a [`stream.Writable`](https://nodejs.org/api/stream.html#stream_class_stream_writable) stream. + +As demonstrated in [Example 1 - Basic GET stream request](/docs/docs/api/Dispatcher.md#example-1-basic-get-stream-request), it is recommended to use the `option.opaque` property to avoid creating a closure for the `factory` method. This pattern works well with Node.js Web Frameworks such as [Fastify](https://fastify.io). See [Example 2 - Stream to Fastify Response](/docs/docs/api/Dispatch.md#example-2-stream-to-fastify-response) for more details. + +Arguments: + +* **options** `RequestOptions` +* **factory** `(data: StreamFactoryData) => stream.Writable` +* **callback** `(error: Error | null, data: StreamData) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +#### Parameter: `StreamFactoryData` + +* **statusCode** `number` +* **headers** `Record` +* **opaque** `unknown` +* **onInfo** `({statusCode: number, headers: Record}) => void | null` (optional) - Default: `null` - Callback collecting all the info headers (HTTP 100-199) received. + +#### Parameter: `StreamData` + +* **opaque** `unknown` +* **trailers** `Record` +* **context** `object` + +#### Example 1 - Basic GET stream request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' +import { Writable } from 'stream' + +const server = createServer((request, response) => { + response.end('Hello, World!') +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +const bufs = [] + +try { + await client.stream({ + path: '/', + method: 'GET', + opaque: { bufs } + }, ({ statusCode, headers, opaque: { bufs } }) => { + console.log(`response received ${statusCode}`) + console.log('headers', headers) + return new Writable({ + write (chunk, encoding, callback) { + bufs.push(chunk) + callback() + } + }) + }) + + console.log(Buffer.concat(bufs).toString('utf-8')) + + client.close() + server.close() +} catch (error) { + console.error(error) +} +``` + +#### Example 2 - Stream to Fastify Response + +In this example, a (fake) request is made to the fastify server using `fastify.inject()`. This request then executes the fastify route handler which makes a subsequent request to the raw Node.js http server using `undici.dispatcher.stream()`. The fastify response is passed to the `opaque` option so that undici can tap into the underlying writable stream using `response.raw`. This methodology demonstrates how one could use undici and fastify together to create fast-as-possible requests from one backend server to another. + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' +import fastify from 'fastify' + +const nodeServer = createServer((request, response) => { + response.end('Hello, World! From Node.js HTTP Server') +}).listen() + +await once(nodeServer, 'listening') + +console.log('Node Server listening') + +const nodeServerUndiciClient = new Client(`http://localhost:${nodeServer.address().port}`) + +const fastifyServer = fastify() + +fastifyServer.route({ + url: '/', + method: 'GET', + handler: (request, response) => { + nodeServerUndiciClient.stream({ + path: '/', + method: 'GET', + opaque: response + }, ({ opaque }) => opaque.raw) + } +}) + +await fastifyServer.listen() + +console.log('Fastify Server listening') + +const fastifyServerUndiciClient = new Client(`http://localhost:${fastifyServer.server.address().port}`) + +try { + const { statusCode, body } = await fastifyServerUndiciClient.request({ + path: '/', + method: 'GET' + }) + + console.log(`response received ${statusCode}`) + body.setEncoding('utf8') + body.on('data', console.log) + + nodeServerUndiciClient.close() + fastifyServerUndiciClient.close() + fastifyServer.close() + nodeServer.close() +} catch (error) { } +``` + +### `Dispatcher.upgrade(options[, callback])` + +Upgrade to a different protocol. Visit [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details. + +Arguments: + +* **options** `UpgradeOptions` + +* **callback** `(error: Error | null, data: UpgradeData) => void` (optional) + +Returns: `void | Promise` - Only returns a `Promise` if no `callback` argument was passed + +#### Parameter: `UpgradeOptions` + +* **path** `string` +* **method** `string` (optional) - Default: `'GET'` +* **headers** `UndiciHeaders` (optional) - Default: `null` +* **protocol** `string` (optional) - Default: `'Websocket'` - A string of comma separated protocols, in descending preference order. +* **signal** `AbortSignal | EventEmitter | null` (optional) - Default: `null` + +#### Parameter: `UpgradeData` + +* **headers** `http.IncomingHeaders` +* **socket** `stream.Duplex` +* **opaque** `unknown` + +#### Example 1 - Basic Upgrade Request + +```js +import { createServer } from 'http' +import { Client } from 'undici' +import { once } from 'events' + +const server = createServer((request, response) => { + response.statusCode = 101 + response.setHeader('connection', 'upgrade') + response.setHeader('upgrade', request.headers.upgrade) + response.end() +}).listen() + +await once(server, 'listening') + +const client = new Client(`http://localhost:${server.address().port}`) + +try { + const { headers, socket } = await client.upgrade({ + path: '/', + }) + socket.on('end', () => { + console.log(`upgrade: ${headers.upgrade}`) // upgrade: Websocket + client.close() + server.close() + }) + socket.end() +} catch (error) { + console.error(error) + client.close() + server.close() +} +``` + +### `Dispatcher.compose(interceptors[, interceptor])` + +Compose a new dispatcher from the current dispatcher and the given interceptors. + +> _Notes_: +> - The order of the interceptors matters. The first interceptor will be the first to be called. +> - It is important to note that the `interceptor` function should return a function that follows the `Dispatcher.dispatch` signature. +> - Any fork of the chain of `interceptors` can lead to unexpected results. + +Arguments: + +* **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. + +Returns: `Dispatcher`. + +#### Parameter: `Interceptor` + +A function that takes a `dispatch` method and returns a `dispatch`-like function. + +#### Example 1 - Basic Compose + +```js +const { Client, RedirectHandler } = require('undici') + +const redirectInterceptor = dispatch => { + return (opts, handler) => { + const { maxRedirections } = opts + + if (!maxRedirections) { + return dispatch(opts, handler) + } + + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + return dispatch(opts, redirectHandler) + } +} + +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) + +await client.request({ path: '/', method: 'GET' }) +``` + +#### Example 2 - Chained Compose + +```js +const { Client, RedirectHandler, RetryHandler } = require('undici') + +const redirectInterceptor = dispatch => { + return (opts, handler) => { + const { maxRedirections } = opts + + if (!maxRedirections) { + return dispatch(opts, handler) + } + + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + opts = { ...opts, maxRedirections: 0 } + return dispatch(opts, redirectHandler) + } +} + +const retryInterceptor = dispatch => { + return function retryInterceptor (opts, handler) { + return dispatch( + opts, + new RetryHandler(opts, { + handler, + dispatch + }) + ) + } +} + +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) + .compose(retryInterceptor) + +await client.request({ path: '/', method: 'GET' }) +``` + +#### Pre-built interceptors + +##### `redirect` + +The `redirect` interceptor allows you to customize the way your dispatcher handles redirects. + +It accepts the same arguments as the [`RedirectHandler` constructor](/docs/docs/api/RedirectHandler.md). + +**Example - Basic Redirect Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { redirect } = interceptors; + +const client = new Client("http://example.com").compose( + redirect({ maxRedirections: 3, throwOnMaxRedirects: true }) +); +client.request({ path: "/" }) +``` + +##### `retry` + +The `retry` interceptor allows you to customize the way your dispatcher handles retries. + +It accepts the same arguments as the [`RetryHandler` constructor](/docs/docs/api/RetryHandler.md). + +**Example - Basic Redirect Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { retry } = interceptors; + +const client = new Client("http://example.com").compose( + retry({ + maxRetries: 3, + minTimeout: 1000, + maxTimeout: 10000, + timeoutFactor: 2, + retryAfter: true, + }) +); +``` + +##### `dump` + +The `dump` interceptor enables you to dump the response body from a request upon a given limit. + +**Options** +- `maxSize` - The maximum size (in bytes) of the response body to dump. If the size of the request's body exceeds this value then the connection will be closed. Default: `1048576`. + +> The `Dispatcher#options` also gets extended with the options `dumpMaxSize`, `abortOnDumped`, and `waitForTrailers` which can be used to configure the interceptor at a request-per-request basis. + +**Example - Basic Dump Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { dump } = interceptors; + +const client = new Client("http://example.com").compose( + dump({ + maxSize: 1024, + }) +); + +// or +client.dispatch( + { + path: "/", + method: "GET", + dumpMaxSize: 1024, + }, + handler +); +``` + +##### `dns` + +The `dns` interceptor enables you to cache DNS lookups for a given duration, per origin. + +>It is well suited for scenarios where you want to cache DNS lookups to avoid the overhead of resolving the same domain multiple times + +**Options** +- `maxTTL` - The maximum time-to-live (in milliseconds) of the DNS cache. It should be a positive integer. Default: `10000`. + - Set `0` to disable TTL. +- `maxItems` - The maximum number of items to cache. It should be a positive integer. Default: `Infinity`. +- `dualStack` - Whether to resolve both IPv4 and IPv6 addresses. Default: `true`. + - It will also attempt a happy-eyeballs-like approach to connect to the available addresses in case of a connection failure. +- `affinity` - Whether to use IPv4 or IPv6 addresses. Default: `4`. + - It can be either `'4` or `6`. + - It will only take effect if `dualStack` is `false`. +- `lookup: (hostname: string, options: LookupOptions, callback: (err: NodeJS.ErrnoException | null, addresses: DNSInterceptorRecord[]) => void) => void` - Custom lookup function. Default: `dns.lookup`. + - For more info see [dns.lookup](https://nodejs.org/api/dns.html#dns_dns_lookup_hostname_options_callback). +- `pick: (origin: URL, records: DNSInterceptorRecords, affinity: 4 | 6) => DNSInterceptorRecord` - Custom pick function. Default: `RoundRobin`. + - The function should return a single record from the records array. + - By default a simplified version of Round Robin is used. + - The `records` property can be mutated to store the state of the balancing algorithm. + +> The `Dispatcher#options` also gets extended with the options `dns.affinity`, `dns.dualStack`, `dns.lookup` and `dns.pick` which can be used to configure the interceptor at a request-per-request basis. + + +**DNSInterceptorRecord** +It represents a DNS record. +- `family` - (`number`) The IP family of the address. It can be either `4` or `6`. +- `address` - (`string`) The IP address. + +**DNSInterceptorOriginRecords** +It represents a map of DNS IP addresses records for a single origin. +- `4.ips` - (`DNSInterceptorRecord[] | null`) The IPv4 addresses. +- `6.ips` - (`DNSInterceptorRecord[] | null`) The IPv6 addresses. + +**Example - Basic DNS Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { dns } = interceptors; + +const client = new Agent().compose([ + dns({ ...opts }) +]) + +const response = await client.request({ + origin: `http://localhost:3030`, + ...requestOpts +}) +``` + +##### `responseError` + +The `responseError` interceptor throws an error for responses with status code errors (>= 400). + +**Example** + +```js +const { Client, interceptors } = require("undici"); +const { responseError } = interceptors; + +const client = new Client("http://example.com").compose( + responseError() +); + +// Will throw a ResponseError for status codes >= 400 +await client.request({ + method: "GET", + path: "/" +}); +``` + +##### `Cache Interceptor` + +The `cache` interceptor implements client-side response caching as described in +[RFC9111](https://www.rfc-editor.org/rfc/rfc9111.html). + +**Options** + +- `store` - The [`CacheStore`](/docs/docs/api/CacheStore.md) to store and retrieve responses from. Default is [`MemoryCacheStore`](/docs/docs/api/CacheStore.md#memorycachestore). +- `methods` - The [**safe** HTTP methods](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) to cache the response of. +- `cacheByDefault` - The default expiration time to cache responses by if they don't have an explicit expiration. If this isn't present, responses without explicit expiration will not be cached. Default `undefined`. +- `type` - The type of cache for Undici to act as. Can be `shared` or `private`. Default `shared`. + +## Instance Events + +### Event: `'connect'` + +Parameters: + +* **origin** `URL` +* **targets** `Array` + +### Event: `'disconnect'` + +Parameters: + +* **origin** `URL` +* **targets** `Array` +* **error** `Error` + +Emitted when the dispatcher has been disconnected from the origin. + +> **Note**: For HTTP/2, this event is also emitted when the dispatcher has received the [GOAWAY Frame](https://webconcepts.info/concepts/http2-frame-type/0x7) with an Error with the message `HTTP/2: "GOAWAY" frame received` and the code `UND_ERR_INFO`. +> Due to nature of the protocol of using binary frames, it is possible that requests gets hanging as a frame can be received between the `HEADER` and `DATA` frames. +> It is recommended to handle this event and close the dispatcher to create a new HTTP/2 session. + +### Event: `'connectionError'` + +Parameters: + +* **origin** `URL` +* **targets** `Array` +* **error** `Error` + +Emitted when dispatcher fails to connect to +origin. + +### Event: `'drain'` + +Parameters: + +* **origin** `URL` + +Emitted when dispatcher is no longer busy. + +## Parameter: `UndiciHeaders` + +* `Record | string[] | Iterable<[string, string | string[] | undefined]> | null` + +Header arguments such as `options.headers` in [`Client.dispatch`](/docs/docs/api/Client.md#clientdispatchoptions-handlers) can be specified in three forms: +* As an object specified by the `Record` (`IncomingHttpHeaders`) type. +* As an array of strings. An array representation of a header list must have an even length, or an `InvalidArgumentError` will be thrown. +* As an iterable that can encompass `Headers`, `Map`, or a custom iterator returning key-value pairs. +Keys are lowercase and values are not modified. + +Response headers will derive a `host` from the `url` of the [Client](/docs/docs/api/Client.md#class-client) instance if no `host` header was previously specified. + +### Example 1 - Object + +```js +{ + 'content-length': '123', + 'content-type': 'text/plain', + connection: 'keep-alive', + host: 'mysite.com', + accept: '*/*' +} +``` + +### Example 2 - Array + +```js +[ + 'content-length', '123', + 'content-type', 'text/plain', + 'connection', 'keep-alive', + 'host', 'mysite.com', + 'accept', '*/*' +] +``` + +### Example 3 - Iterable + +```js +new Headers({ + 'content-length': '123', + 'content-type': 'text/plain', + connection: 'keep-alive', + host: 'mysite.com', + accept: '*/*' +}) +``` +or +```js +new Map([ + ['content-length', '123'], + ['content-type', 'text/plain'], + ['connection', 'keep-alive'], + ['host', 'mysite.com'], + ['accept', '*/*'] +]) +``` +or +```js +{ + *[Symbol.iterator] () { + yield ['content-length', '123'] + yield ['content-type', 'text/plain'] + yield ['connection', 'keep-alive'] + yield ['host', 'mysite.com'] + yield ['accept', '*/*'] + } +} +``` diff --git a/docs/docs/api/EnvHttpProxyAgent.md b/docs/docs/api/EnvHttpProxyAgent.md new file mode 100644 index 0000000..0bcbf25 --- /dev/null +++ b/docs/docs/api/EnvHttpProxyAgent.md @@ -0,0 +1,161 @@ +# Class: EnvHttpProxyAgent + +Stability: Experimental. + +Extends: `undici.Dispatcher` + +EnvHttpProxyAgent automatically reads the proxy configuration from the environment variables `http_proxy`, `https_proxy`, and `no_proxy` and sets up the proxy agents accordingly. When `http_proxy` and `https_proxy` are set, `http_proxy` is used for HTTP requests and `https_proxy` is used for HTTPS requests. If only `http_proxy` is set, `http_proxy` is used for both HTTP and HTTPS requests. If only `https_proxy` is set, it is only used for HTTPS requests. + +`no_proxy` is a comma or space-separated list of hostnames that should not be proxied. The list may contain leading wildcard characters (`*`). If `no_proxy` is set, the EnvHttpProxyAgent will bypass the proxy for requests to hosts that match the list. If `no_proxy` is set to `"*"`, the EnvHttpProxyAgent will bypass the proxy for all requests. + +Uppercase environment variables are also supported: `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. However, if both the lowercase and uppercase environment variables are set, the uppercase environment variables will be ignored. + +## `new EnvHttpProxyAgent([options])` + +Arguments: + +* **options** `EnvHttpProxyAgentOptions` (optional) - extends the `Agent` options. + +Returns: `EnvHttpProxyAgent` + +### Parameter: `EnvHttpProxyAgentOptions` + +Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions) + +* **httpProxy** `string` (optional) - When set, it will override the `HTTP_PROXY` environment variable. +* **httpsProxy** `string` (optional) - When set, it will override the `HTTPS_PROXY` environment variable. +* **noProxy** `string` (optional) - When set, it will override the `NO_PROXY` environment variable. + +Examples: + +```js +import { EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +// or +const envHttpProxyAgent = new EnvHttpProxyAgent({ httpProxy: 'my.proxy.server:8080', httpsProxy: 'my.proxy.server:8443', noProxy: 'localhost' }) +``` + +#### Example - EnvHttpProxyAgent instantiation + +This will instantiate the EnvHttpProxyAgent. It will not do anything until registered as the agent to use with requests. + +```js +import { EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +``` + +#### Example - Basic Proxy Fetch with global agent dispatcher + +```js +import { setGlobalDispatcher, fetch, EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +setGlobalDispatcher(envHttpProxyAgent) + +const { status, json } = await fetch('http://localhost:3000/foo') + +console.log('response received', status) // response received 200 + +const data = await json() // data { foo: "bar" } +``` + +#### Example - Basic Proxy Request with global agent dispatcher + +```js +import { setGlobalDispatcher, request, EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +setGlobalDispatcher(envHttpProxyAgent) + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Request with local agent dispatcher + +```js +import { EnvHttpProxyAgent, request } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: envHttpProxyAgent }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Fetch with local agent dispatcher + +```js +import { EnvHttpProxyAgent, fetch } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() + +const { + status, + json +} = await fetch('http://localhost:3000/foo', { dispatcher: envHttpProxyAgent }) + +console.log('response received', status) // response received 200 + +const data = await json() // data { foo: "bar" } +``` + +## Instance Methods + +### `EnvHttpProxyAgent.close([callback])` + +Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise). + +### `EnvHttpProxyAgent.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `EnvHttpProxyAgent.dispatch(options, handler: AgentDispatchOptions)` + +Implements [`Dispatcher.dispatch(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +#### Parameter: `AgentDispatchOptions` + +Extends: [`DispatchOptions`](/docs/docs/api/Dispatcher.md#parameter-dispatchoptions) + +* **origin** `string | URL` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `EnvHttpProxyAgent.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback). + +### `EnvHttpProxyAgent.dispatch(options, handler)` + +Implements [`Dispatcher.dispatch(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `EnvHttpProxyAgent.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler). + +### `EnvHttpProxyAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +### `EnvHttpProxyAgent.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `EnvHttpProxyAgent.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). diff --git a/docs/docs/api/Errors.md b/docs/docs/api/Errors.md new file mode 100644 index 0000000..c328689 --- /dev/null +++ b/docs/docs/api/Errors.md @@ -0,0 +1,48 @@ +# Errors + +Undici exposes a variety of error objects that you can use to enhance your error handling. +You can find all the error objects inside the `errors` key. + +```js +import { errors } from 'undici' +``` + +| Error | Error Codes | Description | +| ------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------- | +| `UndiciError` | `UND_ERR` | all errors below are extended from `UndiciError`. | +| `ConnectTimeoutError` | `UND_ERR_CONNECT_TIMEOUT` | socket is destroyed due to connect timeout. | +| `HeadersTimeoutError` | `UND_ERR_HEADERS_TIMEOUT` | socket is destroyed due to headers timeout. | +| `HeadersOverflowError` | `UND_ERR_HEADERS_OVERFLOW` | socket is destroyed due to headers' max size being exceeded. | +| `BodyTimeoutError` | `UND_ERR_BODY_TIMEOUT` | socket is destroyed due to body timeout. | +| `ResponseStatusCodeError` | `UND_ERR_RESPONSE_STATUS_CODE` | an error is thrown when `throwOnError` is `true` for status codes >= 400. | +| `InvalidArgumentError` | `UND_ERR_INVALID_ARG` | passed an invalid argument. | +| `InvalidReturnValueError` | `UND_ERR_INVALID_RETURN_VALUE` | returned an invalid value. | +| `RequestAbortedError` | `UND_ERR_ABORTED` | the request has been aborted by the user | +| `ClientDestroyedError` | `UND_ERR_DESTROYED` | trying to use a destroyed client. | +| `ClientClosedError` | `UND_ERR_CLOSED` | trying to use a closed client. | +| `SocketError` | `UND_ERR_SOCKET` | there is an error with the socket. | +| `NotSupportedError` | `UND_ERR_NOT_SUPPORTED` | encountered unsupported functionality. | +| `RequestContentLengthMismatchError` | `UND_ERR_REQ_CONTENT_LENGTH_MISMATCH` | request body does not match content-length header | +| `ResponseContentLengthMismatchError` | `UND_ERR_RES_CONTENT_LENGTH_MISMATCH` | response body does not match content-length header | +| `InformationalError` | `UND_ERR_INFO` | expected error with reason | +| `ResponseExceededMaxSizeError` | `UND_ERR_RES_EXCEEDED_MAX_SIZE` | response body exceed the max size allowed | +| `SecureProxyConnectionError` | `UND_ERR_PRX_TLS` | tls connection to a proxy failed | + +### `SocketError` + +The `SocketError` has a `.socket` property which holds socket metadata: + +```ts +interface SocketInfo { + localAddress?: string + localPort?: number + remoteAddress?: string + remotePort?: number + remoteFamily?: string + timeout?: number + bytesWritten?: number + bytesRead?: number +} +``` + +Be aware that in some cases the `.socket` property can be `null`. diff --git a/docs/docs/api/EventSource.md b/docs/docs/api/EventSource.md new file mode 100644 index 0000000..8244aa7 --- /dev/null +++ b/docs/docs/api/EventSource.md @@ -0,0 +1,45 @@ +# EventSource + +> ⚠️ Warning: the EventSource API is experimental. + +Undici exposes a WHATWG spec-compliant implementation of [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) +for [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events). + +## Instantiating EventSource + +Undici exports a EventSource class. You can instantiate the EventSource as +follows: + +```mjs +import { EventSource } from 'undici' + +const eventSource = new EventSource('http://localhost:3000') +eventSource.onmessage = (event) => { + console.log(event.data) +} +``` + +## Using a custom Dispatcher + +undici allows you to set your own Dispatcher in the EventSource constructor. + +An example which allows you to modify the request headers is: + +```mjs +import { EventSource, Agent } from 'undici' + +class CustomHeaderAgent extends Agent { + dispatch (opts) { + opts.headers['x-custom-header'] = 'hello world' + return super.dispatch(...arguments) + } +} + +const eventSource = new EventSource('http://localhost:3000', { + dispatcher: new CustomHeaderAgent() +}) + +``` + +More information about the EventSource API can be found on +[MDN](https://developer.mozilla.org/en-US/docs/Web/API/EventSource). diff --git a/docs/docs/api/Fetch.md b/docs/docs/api/Fetch.md new file mode 100644 index 0000000..00c3498 --- /dev/null +++ b/docs/docs/api/Fetch.md @@ -0,0 +1,52 @@ +# Fetch + +Undici exposes a fetch() method starts the process of fetching a resource from the network. + +Documentation and examples can be found on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/fetch). + +## FormData + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/FormData). + +If any parameters are passed to the FormData constructor other than `undefined`, an error will be thrown. Other parameters are ignored. + +## Response + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Response) + +## Request + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Request) + +## Header + +This API is implemented as per the standard, you can find documentation on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Headers) + +# Body Mixins + +`Response` and `Request` body inherit body mixin methods. These methods include: + +- [`.arrayBuffer()`](https://fetch.spec.whatwg.org/#dom-body-arraybuffer) +- [`.blob()`](https://fetch.spec.whatwg.org/#dom-body-blob) +- [`.bytes()`](https://fetch.spec.whatwg.org/#dom-body-bytes) +- [`.formData()`](https://fetch.spec.whatwg.org/#dom-body-formdata) +- [`.json()`](https://fetch.spec.whatwg.org/#dom-body-json) +- [`.text()`](https://fetch.spec.whatwg.org/#dom-body-text) + +There is an ongoing discussion regarding `.formData()` and its usefulness and performance in server environments. It is recommended to use a dedicated library for parsing `multipart/form-data` bodies, such as [Busboy](https://www.npmjs.com/package/busboy) or [@fastify/busboy](https://www.npmjs.com/package/@fastify/busboy). + +These libraries can be interfaced with fetch with the following example code: + +```mjs +import { Busboy } from '@fastify/busboy' +import { Readable } from 'node:stream' + +const response = await fetch('...') +const busboy = new Busboy({ + headers: { + 'content-type': response.headers.get('content-type') + } +}) + +Readable.fromWeb(response.body).pipe(busboy) +``` diff --git a/docs/docs/api/MockAgent.md b/docs/docs/api/MockAgent.md new file mode 100644 index 0000000..70d479a --- /dev/null +++ b/docs/docs/api/MockAgent.md @@ -0,0 +1,542 @@ +# Class: MockAgent + +Extends: `undici.Dispatcher` + +A mocked Agent class that implements the Agent API. It allows one to intercept HTTP requests made through undici and return mocked responses instead. + +## `new MockAgent([options])` + +Arguments: + +* **options** `MockAgentOptions` (optional) - It extends the `Agent` options. + +Returns: `MockAgent` + +### Parameter: `MockAgentOptions` + +Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions) + +* **agent** `Agent` (optional) - Default: `new Agent([options])` - a custom agent encapsulated by the MockAgent. + +* **ignoreTrailingSlash** `boolean` (optional) - Default: `false` - set the default value for `ignoreTrailingSlash` for interceptors. + +### Example - Basic MockAgent instantiation + +This will instantiate the MockAgent. It will not do anything until registered as the agent to use with requests and mock interceptions are added. + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() +``` + +### Example - Basic MockAgent instantiation with custom agent + +```js +import { Agent, MockAgent } from 'undici' + +const agent = new Agent() + +const mockAgent = new MockAgent({ agent }) +``` + +## Instance Methods + +### `MockAgent.get(origin)` + +This method creates and retrieves MockPool or MockClient instances which can then be used to intercept HTTP requests. If the number of connections on the mock agent is set to 1, a MockClient instance is returned. Otherwise a MockPool instance is returned. + +For subsequent `MockAgent.get` calls on the same origin, the same mock instance will be returned. + +Arguments: + +* **origin** `string | RegExp | (value) => boolean` - a matcher for the pool origin to be retrieved from the MockAgent. + +| Matcher type | Condition to pass | +|:------------:| -------------------------- | +| `string` | Exact match against string | +| `RegExp` | Regex must pass | +| `Function` | Function must return true | + +Returns: `MockClient | MockPool`. + +| `MockAgentOptions` | Mock instance returned | +| -------------------- | ---------------------- | +| `connections === 1` | `MockClient` | +| `connections` > `1` | `MockPool` | + +#### Example - Basic Mocked Request + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked Request with local mock agent dispatcher + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: mockAgent }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked Request with local mock pool dispatcher + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: mockPool }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked Request with local mock client dispatcher + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent({ connections: 1 }) + +const mockClient = mockAgent.get('http://localhost:3000') +mockClient.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: mockClient }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Mocked requests with multiple intercepts + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') +mockPool.intercept({ path: '/hello'}).reply(200, 'hello') + +const result1 = await request('http://localhost:3000/foo') + +console.log('response received', result1.statusCode) // response received 200 + +for await (const data of result1.body) { + console.log('data', data.toString('utf8')) // data foo +} + +const result2 = await request('http://localhost:3000/hello') + +console.log('response received', result2.statusCode) // response received 200 + +for await (const data of result2.body) { + console.log('data', data.toString('utf8')) // data hello +} +``` +#### Example - Mock different requests within the same file +```js +const { MockAgent, setGlobalDispatcher } = require('undici'); +const agent = new MockAgent(); +agent.disableNetConnect(); +setGlobalDispatcher(agent); +describe('Test', () => { + it('200', async () => { + const mockAgent = agent.get('http://test.com'); + // your test + }); + it('200', async () => { + const mockAgent = agent.get('http://testing.com'); + // your test + }); +}); +``` + +#### Example - Mocked request with query body, headers and trailers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2' +}).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } +}) + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo?hello=there&see=ya', { + method: 'POST', + body: 'form1=data1&form2=data2' +}) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // '{"foo":"bar"}' +} + +console.log('trailers', trailers) // { 'content-md5': 'test' } +``` + +#### Example - Mocked request with origin regex + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get(new RegExp('http://localhost:3000')) +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Mocked request with origin function + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get((origin) => origin === 'http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +### `MockAgent.close()` + +Closes the mock agent and waits for registered mock pools and clients to also close before resolving. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { MockAgent, setGlobalDispatcher } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +await mockAgent.close() +``` + +### `MockAgent.dispatch(options, handlers)` + +Implements [`Agent.dispatch(options, handlers)`](/docs/docs/api/Agent.md#parameter-agentdispatchoptions). + +### `MockAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +#### Example - MockAgent request + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await mockAgent.request({ + origin: 'http://localhost:3000', + path: '/foo', + method: 'GET' +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +### `MockAgent.deactivate()` + +This method disables mocking in MockAgent. + +Returns: `void` + +#### Example - Deactivate Mocking + +```js +import { MockAgent, setGlobalDispatcher } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.deactivate() +``` + +### `MockAgent.activate()` + +This method enables mocking in a MockAgent instance. When instantiated, a MockAgent is automatically activated. Therefore, this method is only effective after `MockAgent.deactivate` has been called. + +Returns: `void` + +#### Example - Activate Mocking + +```js +import { MockAgent, setGlobalDispatcher } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.deactivate() +// No mocking will occur + +// Later +mockAgent.activate() +``` + +### `MockAgent.enableNetConnect([host])` + +When requests are not matched in a MockAgent intercept, a real HTTP request is attempted. We can control this further through the use of `enableNetConnect`. This is achieved by defining host matchers so only matching requests will be attempted. + +When using a string, it should only include the **hostname and optionally, the port**. In addition, calling this method multiple times with a string will allow all HTTP requests that match these values. + +Arguments: + +* **host** `string | RegExp | (value) => boolean` - (optional) + +Returns: `void` + +#### Example - Allow all non-matching urls to be dispatched in a real HTTP request + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect() + +await request('http://example.com') +// A real request is made +``` + +#### Example - Allow requests matching a host string to make real requests + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect('example-1.com') +mockAgent.enableNetConnect('example-2.com:8080') + +await request('http://example-1.com') +// A real request is made + +await request('http://example-2.com:8080') +// A real request is made + +await request('http://example-3.com') +// Will throw +``` + +#### Example - Allow requests matching a host regex to make real requests + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect(new RegExp('example.com')) + +await request('http://example.com') +// A real request is made +``` + +#### Example - Allow requests matching a host function to make real requests + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +mockAgent.enableNetConnect((value) => value === 'example.com') + +await request('http://example.com') +// A real request is made +``` + +### `MockAgent.disableNetConnect()` + +This method causes all requests to throw when requests are not matched in a MockAgent intercept. + +Returns: `void` + +#### Example - Disable all non-matching requests by throwing an error for each + +```js +import { MockAgent, request } from 'undici' + +const mockAgent = new MockAgent() + +mockAgent.disableNetConnect() + +await request('http://example.com') +// Will throw +``` + +### `MockAgent.pendingInterceptors()` + +This method returns any pending interceptors registered on a mock agent. A pending interceptor meets one of the following criteria: + +- Is registered with neither `.times()` nor `.persist()`, and has not been invoked; +- Is persistent (i.e., registered with `.persist()`) and has not been invoked; +- Is registered with `.times()` and has not been invoked `` of times. + +Returns: `PendingInterceptor[]` (where `PendingInterceptor` is a `MockDispatch` with an additional `origin: string`) + +#### Example - List all pending inteceptors + +```js +const agent = new MockAgent() +agent.disableNetConnect() + +agent + .get('https://example.com') + .intercept({ method: 'GET', path: '/' }) + .reply(200) + +const pendingInterceptors = agent.pendingInterceptors() +// Returns [ +// { +// timesInvoked: 0, +// times: 1, +// persist: false, +// consumed: false, +// pending: true, +// path: '/', +// method: 'GET', +// body: undefined, +// headers: undefined, +// data: { +// error: null, +// statusCode: 200, +// data: '', +// headers: {}, +// trailers: {} +// }, +// origin: 'https://example.com' +// } +// ] +``` + +### `MockAgent.assertNoPendingInterceptors([options])` + +This method throws if the mock agent has any pending interceptors. A pending interceptor meets one of the following criteria: + +- Is registered with neither `.times()` nor `.persist()`, and has not been invoked; +- Is persistent (i.e., registered with `.persist()`) and has not been invoked; +- Is registered with `.times()` and has not been invoked `` of times. + +#### Example - Check that there are no pending interceptors + +```js +const agent = new MockAgent() +agent.disableNetConnect() + +agent + .get('https://example.com') + .intercept({ method: 'GET', path: '/' }) + .reply(200) + +agent.assertNoPendingInterceptors() +// Throws an UndiciError with the following message: +// +// 1 interceptor is pending: +// +// ┌─────────┬────────┬───────────────────────┬──────┬─────────────┬────────────┬─────────────┬───────────┐ +// │ (index) │ Method │ Origin │ Path │ Status code │ Persistent │ Invocations │ Remaining │ +// ├─────────┼────────┼───────────────────────┼──────┼─────────────┼────────────┼─────────────┼───────────┤ +// │ 0 │ 'GET' │ 'https://example.com' │ '/' │ 200 │ '❌' │ 0 │ 1 │ +// └─────────┴────────┴───────────────────────┴──────┴─────────────┴────────────┴─────────────┴───────────┘ +``` diff --git a/docs/docs/api/MockClient.md b/docs/docs/api/MockClient.md new file mode 100644 index 0000000..52ee3e3 --- /dev/null +++ b/docs/docs/api/MockClient.md @@ -0,0 +1,77 @@ +# Class: MockClient + +Extends: `undici.Client` + +A mock client class that implements the same api as [MockPool](/docs/docs/api/MockPool.md). + +## `new MockClient(origin, [options])` + +Arguments: + +* **origin** `string` - It should only include the **protocol, hostname, and port**. +* **options** `MockClientOptions` - It extends the `Client` options. + +Returns: `MockClient` + +### Parameter: `MockClientOptions` + +Extends: `ClientOptions` + +* **agent** `Agent` - the agent to associate this MockClient with. + +### Example - Basic MockClient instantiation + +We can use MockAgent to instantiate a MockClient ready to be used to intercept specified requests. It will not do anything until registered as the agent to use and any mock request are registered. + +```js +import { MockAgent } from 'undici' + +// Connections must be set to 1 to return a MockClient instance +const mockAgent = new MockAgent({ connections: 1 }) + +const mockClient = mockAgent.get('http://localhost:3000') +``` + +## Instance Methods + +### `MockClient.intercept(options)` + +Implements: [`MockPool.intercept(options)`](/docs/docs/api/MockPool.md#mockpoolinterceptoptions) + +### `MockClient.close()` + +Implements: [`MockPool.close()`](/docs/docs/api/MockPool.md#mockpoolclose) + +### `MockClient.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `MockClient.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +#### Example - MockClient request + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent({ connections: 1 }) + +const mockClient = mockAgent.get('http://localhost:3000') +mockClient.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await mockClient.request({ + origin: 'http://localhost:3000', + path: '/foo', + method: 'GET' +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` diff --git a/docs/docs/api/MockErrors.md b/docs/docs/api/MockErrors.md new file mode 100644 index 0000000..c1aa3db --- /dev/null +++ b/docs/docs/api/MockErrors.md @@ -0,0 +1,12 @@ +# MockErrors + +Undici exposes a variety of mock error objects that you can use to enhance your mock error handling. +You can find all the mock error objects inside the `mockErrors` key. + +```js +import { mockErrors } from 'undici' +``` + +| Mock Error | Mock Error Codes | Description | +| --------------------- | ------------------------------- | ---------------------------------------------------------- | +| `MockNotMatchedError` | `UND_MOCK_ERR_MOCK_NOT_MATCHED` | The request does not match any registered mock dispatches. | diff --git a/docs/docs/api/MockPool.md b/docs/docs/api/MockPool.md new file mode 100644 index 0000000..ff01b9a --- /dev/null +++ b/docs/docs/api/MockPool.md @@ -0,0 +1,548 @@ +# Class: MockPool + +Extends: `undici.Pool` + +A mock Pool class that implements the Pool API and is used by MockAgent to intercept real requests and return mocked responses. + +## `new MockPool(origin, [options])` + +Arguments: + +* **origin** `string` - It should only include the **protocol, hostname, and port**. +* **options** `MockPoolOptions` - It extends the `Pool` options. + +Returns: `MockPool` + +### Parameter: `MockPoolOptions` + +Extends: `PoolOptions` + +* **agent** `Agent` - the agent to associate this MockPool with. + +### Example - Basic MockPool instantiation + +We can use MockAgent to instantiate a MockPool ready to be used to intercept specified requests. It will not do anything until registered as the agent to use and any mock request are registered. + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +``` + +## Instance Methods + +### `MockPool.intercept(options)` + +This method defines the interception rules for matching against requests for a MockPool or MockPool. We can intercept multiple times on a single instance, but each intercept is only used once. For example if you expect to make 2 requests inside a test, you need to call `intercept()` twice. Assuming you use `disableNetConnect()` you will get `MockNotMatchedError` on the second request when you only call `intercept()` once. + +When defining interception rules, all the rules must pass for a request to be intercepted. If a request is not intercepted, a real request will be attempted. + +| Matcher type | Condition to pass | +|:------------:| -------------------------- | +| `string` | Exact match against string | +| `RegExp` | Regex must pass | +| `Function` | Function must return true | + +Arguments: + +* **options** `MockPoolInterceptOptions` - Interception options. + +Returns: `MockInterceptor` corresponding to the input options. + +### Parameter: `MockPoolInterceptOptions` + +* **path** `string | RegExp | (path: string) => boolean` - a matcher for the HTTP request path. When a `RegExp` or callback is used, it will match against the request path including all query parameters in alphabetical order. When a `string` is provided, the query parameters can be conveniently specified through the `MockPoolInterceptOptions.query` setting. +* **method** `string | RegExp | (method: string) => boolean` - (optional) - a matcher for the HTTP request method. Defaults to `GET`. +* **body** `string | RegExp | (body: string) => boolean` - (optional) - a matcher for the HTTP request body. +* **headers** `Record boolean`> - (optional) - a matcher for the HTTP request headers. To be intercepted, a request must match all defined headers. Extra headers not defined here may (or may not) be included in the request and do not affect the interception in any way. +* **query** `Record | null` - (optional) - a matcher for the HTTP request query string params. Only applies when a `string` was provided for `MockPoolInterceptOptions.path`. +* **ignoreTrailingSlash** `boolean` - (optional) - set to `true` if the matcher should also match by ignoring potential trailing slashes in `MockPoolInterceptOptions.path`. + +### Return: `MockInterceptor` + +We can define the behaviour of an intercepted request with the following options. + +* **reply** `(statusCode: number, replyData: string | Buffer | object | MockInterceptor.MockResponseDataHandler, responseOptions?: MockResponseOptions) => MockScope` - define a reply for a matching request. You can define the replyData as a callback to read incoming request data. Default for `responseOptions` is `{}`. +* **reply** `(callback: MockInterceptor.MockReplyOptionsCallback) => MockScope` - define a reply for a matching request, allowing dynamic mocking of all reply options rather than just the data. +* **replyWithError** `(error: Error) => MockScope` - define an error for a matching request to throw. +* **defaultReplyHeaders** `(headers: Record) => MockInterceptor` - define default headers to be included in subsequent replies. These are in addition to headers on a specific reply. +* **defaultReplyTrailers** `(trailers: Record) => MockInterceptor` - define default trailers to be included in subsequent replies. These are in addition to trailers on a specific reply. +* **replyContentLength** `() => MockInterceptor` - define automatically calculated `content-length` headers to be included in subsequent replies. + +The reply data of an intercepted request may either be a string, buffer, or JavaScript object. Objects are converted to JSON while strings and buffers are sent as-is. + +By default, `reply` and `replyWithError` define the behaviour for the first matching request only. Subsequent requests will not be affected (this can be changed using the returned `MockScope`). + +### Parameter: `MockResponseOptions` + +* **headers** `Record` - headers to be included on the mocked reply. +* **trailers** `Record` - trailers to be included on the mocked reply. + +### Return: `MockScope` + +A `MockScope` is associated with a single `MockInterceptor`. With this, we can configure the default behaviour of an intercepted reply. + +* **delay** `(waitInMs: number) => MockScope` - delay the associated reply by a set amount in ms. +* **persist** `() => MockScope` - any matching request will always reply with the defined response indefinitely. +* **times** `(repeatTimes: number) => MockScope` - any matching request will reply with the defined response a fixed amount of times. This is overridden by **persist**. + +#### Example - Basic Mocked Request + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +// MockPool +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ path: '/foo' }).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Mocked request using reply data callbacks + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/echo', + method: 'GET', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } +}).reply(200, ({ headers }) => ({ message: headers.get('message') })) + +const { statusCode, body, headers } = await request('http://localhost:3000', { + headers: { + message: 'hello world!' + } +}) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // { "message":"hello world!" } +} +``` + +#### Example - Mocked request using reply options callback + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/echo', + method: 'GET', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } +}).reply(({ headers }) => ({ statusCode: 200, data: { message: headers.get('message') }}))) + +const { statusCode, body, headers } = await request('http://localhost:3000', { + headers: { + message: 'hello world!' + } +}) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // { "message":"hello world!" } +} +``` + +#### Example - Basic Mocked requests with multiple intercepts + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).reply(200, 'foo') + +mockPool.intercept({ + path: '/hello', + method: 'GET', +}).reply(200, 'hello') + +const result1 = await request('http://localhost:3000/foo') + +console.log('response received', result1.statusCode) // response received 200 + +for await (const data of result1.body) { + console.log('data', data.toString('utf8')) // data foo +} + +const result2 = await request('http://localhost:3000/hello') + +console.log('response received', result2.statusCode) // response received 200 + +for await (const data of result2.body) { + console.log('data', data.toString('utf8')) // data hello +} +``` + +#### Example - Mocked request with query body, request headers and response headers and trailers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo?hello=there&see=ya', + method: 'POST', + body: 'form1=data1&form2=data2', + headers: { + 'User-Agent': 'undici', + Host: 'example.com' + } +}).reply(200, { foo: 'bar' }, { + headers: { 'content-type': 'application/json' }, + trailers: { 'Content-MD5': 'test' } +}) + +const { + statusCode, + headers, + trailers, + body +} = await request('http://localhost:3000/foo?hello=there&see=ya', { + method: 'POST', + body: 'form1=data1&form2=data2', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } + }) + +console.log('response received', statusCode) // response received 200 +console.log('headers', headers) // { 'content-type': 'application/json' } + +for await (const data of body) { + console.log('data', data.toString('utf8')) // '{"foo":"bar"}' +} + +console.log('trailers', trailers) // { 'content-md5': 'test' } +``` + +#### Example - Mocked request using different matchers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: /^GET$/, + body: (value) => value === 'form=data', + headers: { + 'User-Agent': 'undici', + Host: /^example.com$/ + } +}).reply(200, 'foo') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { + method: 'GET', + body: 'form=data', + headers: { + foo: 'bar', + 'User-Agent': 'undici', + Host: 'example.com' + } +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Mocked request with reply with a defined error + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).replyWithError(new Error('kaboom')) + +try { + await request('http://localhost:3000/foo', { + method: 'GET' + }) +} catch (error) { + console.error(error) // Error: kaboom +} +``` + +#### Example - Mocked request with defaultReplyHeaders + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).defaultReplyHeaders({ foo: 'bar' }) + .reply(200, 'foo') + +const { headers } = await request('http://localhost:3000/foo') + +console.log('headers', headers) // headers { foo: 'bar' } +``` + +#### Example - Mocked request with defaultReplyTrailers + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).defaultReplyTrailers({ foo: 'bar' }) + .reply(200, 'foo') + +const { trailers } = await request('http://localhost:3000/foo') + +console.log('trailers', trailers) // trailers { foo: 'bar' } +``` + +#### Example - Mocked request with automatic content-length calculation + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).replyContentLength().reply(200, 'foo') + +const { headers } = await request('http://localhost:3000/foo') + +console.log('headers', headers) // headers { 'content-length': '3' } +``` + +#### Example - Mocked request with automatic content-length calculation on an object + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).replyContentLength().reply(200, { foo: 'bar' }) + +const { headers } = await request('http://localhost:3000/foo') + +console.log('headers', headers) // headers { 'content-length': '13' } +``` + +#### Example - Mocked request with persist enabled + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).reply(200, 'foo').persist() + +const result1 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +const result2 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +// Etc +``` + +#### Example - Mocked request with times enabled + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +mockPool.intercept({ + path: '/foo', + method: 'GET' +}).reply(200, 'foo').times(2) + +const result1 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +const result2 = await request('http://localhost:3000/foo') +// Will match and return mocked data + +const result3 = await request('http://localhost:3000/foo') +// Will not match and make attempt a real request +``` + +#### Example - Mocked request with path callback + +```js +import { MockAgent, setGlobalDispatcher, request } from 'undici' +import querystring from 'querystring' + +const mockAgent = new MockAgent() +setGlobalDispatcher(mockAgent) + +const mockPool = mockAgent.get('http://localhost:3000') + +const matchPath = requestPath => { + const [pathname, search] = requestPath.split('?') + const requestQuery = querystring.parse(search) + + if (!pathname.startsWith('/foo')) { + return false + } + + if (!Object.keys(requestQuery).includes('foo') || requestQuery.foo !== 'bar') { + return false + } + + return true +} + +mockPool.intercept({ + path: matchPath, + method: 'GET' +}).reply(200, 'foo') + +const result = await request('http://localhost:3000/foo?foo=bar') +// Will match and return mocked data +``` + +### `MockPool.close()` + +Closes the mock pool and de-registers from associated MockAgent. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() +const mockPool = mockAgent.get('http://localhost:3000') + +await mockPool.close() +``` + +### `MockPool.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `MockPool.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +#### Example - MockPool request + +```js +import { MockAgent } from 'undici' + +const mockAgent = new MockAgent() + +const mockPool = mockAgent.get('http://localhost:3000') +mockPool.intercept({ + path: '/foo', + method: 'GET', +}).reply(200, 'foo') + +const { + statusCode, + body +} = await mockPool.request({ + origin: 'http://localhost:3000', + path: '/foo', + method: 'GET' +}) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` diff --git a/docs/docs/api/Pool.md b/docs/docs/api/Pool.md new file mode 100644 index 0000000..9c43282 --- /dev/null +++ b/docs/docs/api/Pool.md @@ -0,0 +1,83 @@ +# Class: Pool + +Extends: `undici.Dispatcher` + +A pool of [Client](/docs/docs/api/Client.md) instances connected to the same upstream target. + +Requests are not guaranteed to be dispatched in order of invocation. + +## `new Pool(url[, options])` + +Arguments: + +* **url** `URL | string` - It should only include the **protocol, hostname, and port**. +* **options** `PoolOptions` (optional) + +### Parameter: `PoolOptions` + +Extends: [`ClientOptions`](/docs/docs/api/Client.md#parameter-clientoptions) + +* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Client(origin, opts)` +* **connections** `number | null` (optional) - Default: `null` - The number of `Client` instances to create. When set to `null`, the `Pool` instance will create an unlimited amount of `Client` instances. + +## Instance Properties + +### `Pool.closed` + +Implements [Client.closed](/docs/docs/api/Client.md#clientclosed) + +### `Pool.destroyed` + +Implements [Client.destroyed](/docs/docs/api/Client.md#clientdestroyed) + +### `Pool.stats` + +Returns [`PoolStats`](PoolStats.md) instance for this pool. + +## Instance Methods + +### `Pool.close([callback])` + +Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise). + +### `Pool.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `Pool.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback). + +### `Pool.dispatch(options, handler)` + +Implements [`Dispatcher.dispatch(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `Pool.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler). + +### `Pool.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +### `Pool.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `Pool.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](/docs/docs/api/Dispatcher.md#event-connect). + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](/docs/docs/api/Dispatcher.md#event-disconnect). + +### Event: `'drain'` + +See [Dispatcher Event: `'drain'`](/docs/docs/api/Dispatcher.md#event-drain). diff --git a/docs/docs/api/PoolStats.md b/docs/docs/api/PoolStats.md new file mode 100644 index 0000000..3cbe0d8 --- /dev/null +++ b/docs/docs/api/PoolStats.md @@ -0,0 +1,35 @@ +# Class: PoolStats + +Aggregate stats for a [Pool](/docs/docs/api/Pool.md) or [BalancedPool](/docs/docs/api/BalancedPool.md). + +## `new PoolStats(pool)` + +Arguments: + +* **pool** `Pool` - Pool or BalancedPool from which to return stats. + +## Instance Properties + +### `PoolStats.connected` + +Number of open socket connections in this pool. + +### `PoolStats.free` + +Number of open socket connections in this pool that do not have an active request. + +### `PoolStats.pending` + +Number of pending requests across all clients in this pool. + +### `PoolStats.queued` + +Number of queued requests across all clients in this pool. + +### `PoolStats.running` + +Number of currently active requests across all clients in this pool. + +### `PoolStats.size` + +Number of active, pending, or queued requests across all clients in this pool. diff --git a/docs/docs/api/ProxyAgent.md b/docs/docs/api/ProxyAgent.md new file mode 100644 index 0000000..932716a --- /dev/null +++ b/docs/docs/api/ProxyAgent.md @@ -0,0 +1,226 @@ +# Class: ProxyAgent + +Extends: `undici.Dispatcher` + +A Proxy Agent class that implements the Agent API. It allows the connection through proxy in a simple way. + +## `new ProxyAgent([options])` + +Arguments: + +* **options** `ProxyAgentOptions` (required) - It extends the `Agent` options. + +Returns: `ProxyAgent` + +### Parameter: `ProxyAgentOptions` + +Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions) +> It ommits `AgentOptions#connect`. + +* **uri** `string | URL` (required) - The URI of the proxy server. This can be provided as a string, as an instance of the URL class, or as an object with a `uri` property of type string. +If the `uri` is provided as a string or `uri` is an object with an `uri` property of type string, then it will be parsed into a `URL` object according to the [WHATWG URL Specification](https://url.spec.whatwg.org). +For detailed information on the parsing process and potential validation errors, please refer to the ["Writing" section](https://url.spec.whatwg.org/#writing) of the WHATWG URL Specification. +* **token** `string` (optional) - It can be passed by a string of token for authentication. +* **auth** `string` (**deprecated**) - Use token. +* **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)` +* **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions). +* **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions). + +Examples: + +```js +import { ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +// or +const proxyAgent = new ProxyAgent(new URL('my.proxy.server')) +// or +const proxyAgent = new ProxyAgent({ uri: 'my.proxy.server' }) +// or +const proxyAgent = new ProxyAgent({ + uri: new URL('my.proxy.server'), + proxyTls: { + signal: AbortSignal.timeout(1000) + } +}) +``` + +#### Example - Basic ProxyAgent instantiation + +This will instantiate the ProxyAgent. It will not do anything until registered as the agent to use with requests. + +```js +import { ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +``` + +#### Example - Basic Proxy Request with global agent dispatcher + +```js +import { setGlobalDispatcher, request, ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +setGlobalDispatcher(proxyAgent) + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Request with local agent dispatcher + +```js +import { ProxyAgent, request } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: proxyAgent }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Request with authentication + +```js +import { setGlobalDispatcher, request, ProxyAgent } from 'undici'; + +const proxyAgent = new ProxyAgent({ + uri: 'my.proxy.server', + // token: 'Bearer xxxx' + token: `Basic ${Buffer.from('username:password').toString('base64')}` +}); +setGlobalDispatcher(proxyAgent); + +const { statusCode, body } = await request('http://localhost:3000/foo'); + +console.log('response received', statusCode); // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')); // data foo +} +``` + +### `ProxyAgent.close()` + +Closes the proxy agent and waits for registered pools and clients to also close before resolving. + +Returns: `Promise` + +#### Example - clean up after tests are complete + +```js +import { ProxyAgent, setGlobalDispatcher } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') +setGlobalDispatcher(proxyAgent) + +await proxyAgent.close() +``` + +### `ProxyAgent.dispatch(options, handlers)` + +Implements [`Agent.dispatch(options, handlers)`](/docs/docs/api/Agent.md#parameter-agentdispatchoptions). + +### `ProxyAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + + +#### Example - ProxyAgent with Fetch + +This example demonstrates how to use `fetch` with a proxy via `ProxyAgent`. It is particularly useful for scenarios requiring proxy tunneling. + +```javascript +import { ProxyAgent, fetch } from 'undici'; + +// Define the ProxyAgent +const proxyAgent = new ProxyAgent('http://localhost:8000'); + +// Make a GET request through the proxy +const response = await fetch('http://localhost:3000/foo', { + dispatcher: proxyAgent, + method: 'GET', +}); + +console.log('Response status:', response.status); +console.log('Response data:', await response.text()); +``` + +--- + +#### Example - ProxyAgent with a Custom Proxy Server + +This example shows how to create a custom proxy server and use it with `ProxyAgent`. + +```javascript +import * as http from 'node:http'; +import { createProxy } from 'proxy'; +import { ProxyAgent, fetch } from 'undici'; + +// Create a proxy server +const proxyServer = createProxy(http.createServer()); +proxyServer.listen(8000, () => { + console.log('Proxy server running on port 8000'); +}); + +// Define and use the ProxyAgent +const proxyAgent = new ProxyAgent('http://localhost:8000'); + +const response = await fetch('http://example.com', { + dispatcher: proxyAgent, + method: 'GET', +}); + +console.log('Response status:', response.status); +console.log('Response data:', await response.text()); +``` + +--- + +#### Example - ProxyAgent with HTTPS Tunneling + +This example demonstrates how to perform HTTPS tunneling using a proxy. + +```javascript +import { ProxyAgent, fetch } from 'undici'; + +// Define a ProxyAgent for HTTPS proxy +const proxyAgent = new ProxyAgent('https://secure.proxy.server'); + +// Make a request to an HTTPS endpoint via the proxy +const response = await fetch('https://secure.endpoint.com/api/data', { + dispatcher: proxyAgent, + method: 'GET', +}); + +console.log('Response status:', response.status); +console.log('Response data:', await response.json()); +``` + +#### Example - ProxyAgent as a Global Dispatcher + +`ProxyAgent` can be configured as a global dispatcher, making it available for all requests without explicitly passing it. This simplifies code and is useful when a single proxy configuration applies to all requests. + +```javascript +import { ProxyAgent, setGlobalDispatcher, fetch } from 'undici'; + +// Define and configure the ProxyAgent +const proxyAgent = new ProxyAgent('http://localhost:8000'); +setGlobalDispatcher(proxyAgent); + +// Make requests without specifying the dispatcher +const response = await fetch('http://example.com'); +console.log('Response status:', response.status); +console.log('Response data:', await response.text()); diff --git a/docs/docs/api/RedirectHandler.md b/docs/docs/api/RedirectHandler.md new file mode 100644 index 0000000..bb16284 --- /dev/null +++ b/docs/docs/api/RedirectHandler.md @@ -0,0 +1,96 @@ +# Class: RedirectHandler + +A class that handles redirection logic for HTTP requests. + +## `new RedirectHandler(dispatch, maxRedirections, opts, handler, redirectionLimitReached)` + +Arguments: + +- **dispatch** `function` - The dispatch function to be called after every retry. +- **maxRedirections** `number` - Maximum number of redirections allowed. +- **opts** `object` - Options for handling redirection. +- **handler** `object` - An object containing handlers for different stages of the request lifecycle. +- **redirectionLimitReached** `boolean` (default: `false`) - A flag that the implementer can provide to enable or disable the feature. If set to `false`, it indicates that the caller doesn't want to use the feature and prefers the old behavior. + +Returns: `RedirectHandler` + +### Parameters + +- **dispatch** `(options: Dispatch.DispatchOptions, handlers: Dispatch.DispatchHandler) => Promise` (required) - Dispatch function to be called after every redirection. +- **maxRedirections** `number` (required) - Maximum number of redirections allowed. +- **opts** `object` (required) - Options for handling redirection. +- **handler** `object` (required) - Handlers for different stages of the request lifecycle. +- **redirectionLimitReached** `boolean` (default: `false`) - A flag that the implementer can provide to enable or disable the feature. If set to `false`, it indicates that the caller doesn't want to use the feature and prefers the old behavior. + +### Properties + +- **location** `string` - The current redirection location. +- **abort** `function` - The abort function. +- **opts** `object` - The options for handling redirection. +- **maxRedirections** `number` - Maximum number of redirections allowed. +- **handler** `object` - Handlers for different stages of the request lifecycle. +- **history** `Array` - An array representing the history of URLs during redirection. +- **redirectionLimitReached** `boolean` - Indicates whether the redirection limit has been reached. + +### Methods + +#### `onConnect(abort)` + +Called when the connection is established. + +Parameters: + +- **abort** `function` - The abort function. + +#### `onUpgrade(statusCode, headers, socket)` + +Called when an upgrade is requested. + +Parameters: + +- **statusCode** `number` - The HTTP status code. +- **headers** `object` - The headers received in the response. +- **socket** `object` - The socket object. + +#### `onError(error)` + +Called when an error occurs. + +Parameters: + +- **error** `Error` - The error that occurred. + +#### `onHeaders(statusCode, headers, resume, statusText)` + +Called when headers are received. + +Parameters: + +- **statusCode** `number` - The HTTP status code. +- **headers** `object` - The headers received in the response. +- **resume** `function` - The resume function. +- **statusText** `string` - The status text. + +#### `onData(chunk)` + +Called when data is received. + +Parameters: + +- **chunk** `Buffer` - The data chunk received. + +#### `onComplete(trailers)` + +Called when the request is complete. + +Parameters: + +- **trailers** `object` - The trailers received. + +#### `onBodySent(chunk)` + +Called when the request body is sent. + +Parameters: + +- **chunk** `Buffer` - The chunk of the request body sent. diff --git a/docs/docs/api/RetryAgent.md b/docs/docs/api/RetryAgent.md new file mode 100644 index 0000000..2d9200f --- /dev/null +++ b/docs/docs/api/RetryAgent.md @@ -0,0 +1,45 @@ +# Class: RetryAgent + +Extends: `undici.Dispatcher` + +A `undici.Dispatcher` that allows to automatically retry a request. +Wraps a `undici.RetryHandler`. + +## `new RetryAgent(dispatcher, [options])` + +Arguments: + +* **dispatcher** `undici.Dispatcher` (required) - the dispatcher to wrap +* **options** `RetryHandlerOptions` (optional) - the options + +Returns: `ProxyAgent` + +### Parameter: `RetryHandlerOptions` + +- **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed. +- **maxRetries** `number` (optional) - Maximum number of retries. Default: `5` +- **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds) +- **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second) +- **timeoutFactor** `number` (optional) - Factor to multiply the timeout by for each retry attempt. Default: `2` +- **retryAfter** `boolean` (optional) - It enables automatic retry after the `Retry-After` header is received. Default: `true` +- +- **methods** `string[]` (optional) - Array of HTTP methods to retry. Default: `['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']` +- **statusCodes** `number[]` (optional) - Array of HTTP status codes to retry. Default: `[429, 500, 502, 503, 504]` +- **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN', 'UND_ERR_SOCKET']` + +**`RetryContext`** + +- `state`: `RetryState` - Current retry state. It can be mutated. +- `opts`: `Dispatch.DispatchOptions & RetryOptions` - Options passed to the retry handler. + +Example: + +```js +import { Agent, RetryAgent } from 'undici' + +const agent = new RetryAgent(new Agent()) + +const res = await agent.request('http://example.com') +console.log(res.statusCode) +console.log(await res.body.text()) +``` diff --git a/docs/docs/api/RetryHandler.md b/docs/docs/api/RetryHandler.md new file mode 100644 index 0000000..04a621d --- /dev/null +++ b/docs/docs/api/RetryHandler.md @@ -0,0 +1,117 @@ +# Class: RetryHandler + +Extends: `undici.DispatcherHandlers` + +A handler class that implements the retry logic for a request. + +## `new RetryHandler(dispatchOptions, retryHandlers, [retryOptions])` + +Arguments: + +- **options** `Dispatch.DispatchOptions & RetryOptions` (required) - It is an intersection of `Dispatcher.DispatchOptions` and `RetryOptions`. +- **retryHandlers** `RetryHandlers` (required) - Object containing the `dispatch` to be used on every retry, and `handler` for handling the `dispatch` lifecycle. + +Returns: `retryHandler` + +### Parameter: `Dispatch.DispatchOptions & RetryOptions` + +Extends: [`Dispatch.DispatchOptions`](/docs/docs/api/Dispatcher.md#parameter-dispatchoptions). + +#### `RetryOptions` + +- **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => number | null` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed. +- **maxRetries** `number` (optional) - Maximum number of retries. Default: `5` +- **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds) +- **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second) +- **timeoutFactor** `number` (optional) - Factor to multiply the timeout by for each retry attempt. Default: `2` +- **retryAfter** `boolean` (optional) - It enables automatic retry after the `Retry-After` header is received. Default: `true` +- +- **methods** `string[]` (optional) - Array of HTTP methods to retry. Default: `['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']` +- **statusCodes** `number[]` (optional) - Array of HTTP status codes to retry. Default: `[429, 500, 502, 503, 504]` +- **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN', 'UND_ERR_SOCKET']` + +**`RetryContext`** + +- `state`: `RetryState` - Current retry state. It can be mutated. +- `opts`: `Dispatch.DispatchOptions & RetryOptions` - Options passed to the retry handler. + +**`RetryState`** + +It represents the retry state for a given request. + +- `counter`: `number` - Current retry attempt. + +### Parameter `RetryHandlers` + +- **dispatch** `(options: Dispatch.DispatchOptions, handlers: Dispatch.DispatchHandler) => Promise` (required) - Dispatch function to be called after every retry. +- **handler** Extends [`Dispatch.DispatchHandler`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler) (required) - Handler function to be called after the request is successful or the retries are exhausted. + +>__Note__: The `RetryHandler` does not retry over stateful bodies (e.g. streams, AsyncIterable) as those, once consumed, are left in a state that cannot be reutilized. For these situations the `RetryHandler` will identify +>the body as stateful and will not retry the request rejecting with the error `UND_ERR_REQ_RETRY`. + +Examples: + +```js +const client = new Client(`http://localhost:${server.address().port}`); +const chunks = []; +const handler = new RetryHandler( + { + ...dispatchOptions, + retryOptions: { + // custom retry function + retry: function (err, state, callback) { + counter++; + + if (err.code && err.code === "UND_ERR_DESTROYED") { + callback(err); + return; + } + + if (err.statusCode === 206) { + callback(err); + return; + } + + setTimeout(() => callback(null), 1000); + }, + }, + }, + { + dispatch: (...args) => { + return client.dispatch(...args); + }, + handler: { + onConnect() {}, + onBodySent() {}, + onHeaders(status, _rawHeaders, resume, _statusMessage) { + // do something with headers + }, + onData(chunk) { + chunks.push(chunk); + return true; + }, + onComplete() {}, + onError() { + // handle error properly + }, + }, + } +); +``` + +#### Example - Basic RetryHandler with defaults + +```js +const client = new Client(`http://localhost:${server.address().port}`); +const handler = new RetryHandler(dispatchOptions, { + dispatch: client.dispatch.bind(client), + handler: { + onConnect() {}, + onBodySent() {}, + onHeaders(status, _rawHeaders, resume, _statusMessage) {}, + onData(chunk) {}, + onComplete() {}, + onError(err) {}, + }, +}); +``` diff --git a/docs/docs/api/Util.md b/docs/docs/api/Util.md new file mode 100644 index 0000000..53b96e3 --- /dev/null +++ b/docs/docs/api/Util.md @@ -0,0 +1,25 @@ +# Util + +Utility API for third-party implementations of the dispatcher API. + +## `parseHeaders(headers, [obj])` + +Receives a header object and returns the parsed value. + +Arguments: + +- **headers** `(Buffer | string | (Buffer | string)[])[]` (required) - Header object. + +- **obj** `Record` (optional) - Object to specify a proxy object. The parsed value is assigned to this object. But, if **headers** is an object, it is not used. + +Returns: `Record` If **obj** is specified, it is equivalent to **obj**. + +## `headerNameToString(value)` + +Retrieves a header name and returns its lowercase value. + +Arguments: + +- **value** `string | Buffer` (required) - Header name. + +Returns: `string` diff --git a/docs/docs/api/WebSocket.md b/docs/docs/api/WebSocket.md new file mode 100644 index 0000000..6f6836f --- /dev/null +++ b/docs/docs/api/WebSocket.md @@ -0,0 +1,85 @@ +# Class: WebSocket + +Extends: [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) + +The WebSocket object provides a way to manage a WebSocket connection to a server, allowing bidirectional communication. The API follows the [WebSocket spec](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) and [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455). + +## `new WebSocket(url[, protocol])` + +Arguments: + +* **url** `URL | string` +* **protocol** `string | string[] | WebSocketInit` (optional) - Subprotocol(s) to request the server use, or a [`Dispatcher`](/docs/docs/api/Dispatcher.md). + +### Example: + +This example will not work in browsers or other platforms that don't allow passing an object. + +```mjs +import { WebSocket, ProxyAgent } from 'undici' + +const proxyAgent = new ProxyAgent('my.proxy.server') + +const ws = new WebSocket('wss://echo.websocket.events', { + dispatcher: proxyAgent, + protocols: ['echo', 'chat'] +}) +``` + +If you do not need a custom Dispatcher, it's recommended to use the following pattern: + +```mjs +import { WebSocket } from 'undici' + +const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat']) +``` + +# Class: WebSocketStream + +> ⚠️ Warning: the WebSocketStream API has not been finalized and is likely to change. + +See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSocketStream) for more information. + +## `new WebSocketStream(url[, protocol])` + +Arguments: + +* **url** `URL | string` +* **options** `WebSocketStreamOptions` (optional) + +### WebSocketStream Example + +```js +const stream = new WebSocketStream('https://echo.websocket.org/') +const { readable, writable } = await stream.opened + +async function read () { + /** @type {ReadableStreamReader} */ + const reader = readable.getReader() + + while (true) { + const { done, value } = await reader.read() + if (done) break + + // do something with value + } +} + +async function write () { + /** @type {WritableStreamDefaultWriter} */ + const writer = writable.getWriter() + writer.write('Hello, world!') + writer.releaseLock() +} + +read() + +setInterval(() => write(), 5000) + +``` + +## Read More + +- [MDN - WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +- [The WebSocket Specification](https://www.rfc-editor.org/rfc/rfc6455) +- [The WHATWG WebSocket Specification](https://websockets.spec.whatwg.org/) diff --git a/docs/docs/api/api-lifecycle.md b/docs/docs/api/api-lifecycle.md new file mode 100644 index 0000000..ee08292 --- /dev/null +++ b/docs/docs/api/api-lifecycle.md @@ -0,0 +1,91 @@ +# Client Lifecycle + +An Undici [Client](/docs/docs/api/Client.md) can be best described as a state machine. The following list is a summary of the various state transitions the `Client` will go through in its lifecycle. This document also contains detailed breakdowns of each state. + +> This diagram is not a perfect representation of the undici Client. Since the Client class is not actually implemented as a state-machine, actual execution may deviate slightly from what is described below. Consider this as a general resource for understanding the inner workings of the Undici client rather than some kind of formal specification. + +## State Transition Overview + +* A `Client` begins in the **idle** state with no socket connection and no requests in queue. + * The *connect* event transitions the `Client` to the **pending** state where requests can be queued prior to processing. + * The *close* and *destroy* events transition the `Client` to the **destroyed** state. Since there are no requests in the queue, the *close* event immediately transitions to the **destroyed** state. +* The **pending** state indicates the underlying socket connection has been successfully established and requests are queueing. + * The *process* event transitions the `Client` to the **processing** state where requests are processed. + * If requests are queued, the *close* event transitions to the **processing** state; otherwise, it transitions to the **destroyed** state. + * The *destroy* event transitions to the **destroyed** state. +* The **processing** state initializes to the **processing.running** state. + * If the current request requires draining, the *needDrain* event transitions the `Client` into the **processing.busy** state which will return to the **processing.running** state with the *drainComplete* event. + * After all queued requests are completed, the *keepalive* event transitions the `Client` back to the **pending** state. If no requests are queued during the timeout, the **close** event transitions the `Client` to the **destroyed** state. + * If the *close* event is fired while the `Client` still has queued requests, the `Client` transitions to the **process.closing** state where it will complete all existing requests before firing the *done* event. + * The *done* event gracefully transitions the `Client` to the **destroyed** state. + * At any point in time, the *destroy* event will transition the `Client` from the **processing** state to the **destroyed** state, destroying any queued requests. +* The **destroyed** state is a final state and the `Client` is no longer functional. + +A state diagram representing an Undici Client instance: + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> pending : connect + idle --> destroyed : destroy/close + + pending --> idle : timeout + pending --> destroyed : destroy + + state close_fork <> + pending --> close_fork : close + close_fork --> processing + close_fork --> destroyed + + pending --> processing : process + + processing --> pending : keepalive + processing --> destroyed : done + processing --> destroyed : destroy + + destroyed --> [*] + + state processing { + [*] --> running + running --> closing : close + running --> busy : needDrain + busy --> running : drainComplete + running --> [*] : keepalive + closing --> [*] : done + } +``` +## State details + +### idle + +The **idle** state is the initial state of a `Client` instance. While an `origin` is required for instantiating a `Client` instance, the underlying socket connection will not be established until a request is queued using [`Client.dispatch()`](/docs/docs/api/Client.md#clientdispatchoptions-handlers). By calling `Client.dispatch()` directly or using one of the multiple implementations ([`Client.connect()`](Client.md#clientconnectoptions-callback), [`Client.pipeline()`](Client.md#clientpipelineoptions-handler), [`Client.request()`](Client.md#clientrequestoptions-callback), [`Client.stream()`](Client.md#clientstreamoptions-factory-callback), and [`Client.upgrade()`](/docs/docs/api/Client.md#clientupgradeoptions-callback)), the `Client` instance will transition from **idle** to [**pending**](/docs/docs/api/Client.md#pending) and then most likely directly to [**processing**](/docs/docs/api/Client.md#processing). + +Calling [`Client.close()`](/docs/docs/api/Client.md#clientclosecallback) or [`Client.destroy()`](Client.md#clientdestroyerror-callback) transitions directly to the [**destroyed**](/docs/docs/api/Client.md#destroyed) state since the `Client` instance will have no queued requests in this state. + +### pending + +The **pending** state signifies a non-processing `Client`. Upon entering this state, the `Client` establishes a socket connection and emits the [`'connect'`](/docs/docs/api/Client.md#event-connect) event signalling a connection was successfully established with the `origin` provided during `Client` instantiation. The internal queue is initially empty, and requests can start queueing. + +Calling [`Client.close()`](/docs/docs/api/Client.md#clientclosecallback) with queued requests, transitions the `Client` to the [**processing**](/docs/docs/api/Client.md#processing) state. Without queued requests, it transitions to the [**destroyed**](/docs/docs/api/Client.md#destroyed) state. + +Calling [`Client.destroy()`](/docs/docs/api/Client.md#clientdestroyerror-callback) transitions directly to the [**destroyed**](/docs/docs/api/Client.md#destroyed) state regardless of existing requests. + +### processing + +The **processing** state is a state machine within itself. It initializes to the [**processing.running**](/docs/docs/api/Client.md#running) state. The [`Client.dispatch()`](/docs/docs/api/Client.md#clientdispatchoptions-handlers), [`Client.close()`](Client.md#clientclosecallback), and [`Client.destroy()`](Client.md#clientdestroyerror-callback) can be called at any time while the `Client` is in this state. `Client.dispatch()` will add more requests to the queue while existing requests continue to be processed. `Client.close()` will transition to the [**processing.closing**](/docs/docs/api/Client.md#closing) state. And `Client.destroy()` will transition to [**destroyed**](/docs/docs/api/Client.md#destroyed). + +#### running + +In the **processing.running** sub-state, queued requests are being processed in a FIFO order. If a request body requires draining, the *needDrain* event transitions to the [**processing.busy**](/docs/docs/api/Client.md#busy) sub-state. The *close* event transitions the Client to the [**process.closing**](/docs/docs/api/Client.md#closing) sub-state. If all queued requests are processed and neither [`Client.close()`](/docs/docs/api/Client.md#clientclosecallback) nor [`Client.destroy()`](Client.md#clientdestroyerror-callback) are called, then the [**processing**](/docs/docs/api/Client.md#processing) machine will trigger a *keepalive* event transitioning the `Client` back to the [**pending**](/docs/docs/api/Client.md#pending) state. During this time, the `Client` is waiting for the socket connection to timeout, and once it does, it triggers the *timeout* event and transitions to the [**idle**](/docs/docs/api/Client.md#idle) state. + +#### busy + +This sub-state is only entered when a request body is an instance of [Stream](https://nodejs.org/api/stream.html) and requires draining. The `Client` cannot process additional requests while in this state and must wait until the currently processing request body is completely drained before transitioning back to [**processing.running**](/docs/docs/api/Client.md#running). + +#### closing + +This sub-state is only entered when a `Client` instance has queued requests and the [`Client.close()`](/docs/docs/api/Client.md#clientclosecallback) method is called. In this state, the `Client` instance continues to process requests as usual, with the one exception that no additional requests can be queued. Once all of the queued requests are processed, the `Client` will trigger the *done* event gracefully entering the [**destroyed**](/docs/docs/api/Client.md#destroyed) state without an error. + +### destroyed + +The **destroyed** state is a final state for the `Client` instance. Once in this state, a `Client` is nonfunctional. Calling any other `Client` methods will result in an `ClientDestroyedError`. diff --git a/docs/docs/best-practices/client-certificate.md b/docs/docs/best-practices/client-certificate.md new file mode 100644 index 0000000..9ead733 --- /dev/null +++ b/docs/docs/best-practices/client-certificate.md @@ -0,0 +1,64 @@ +# Client certificate + +Client certificate authentication can be configured with the `Client`, the required options are passed along through the `connect` option. + +The client certificates must be signed by a trusted CA. The Node.js default is to trust the well-known CAs curated by Mozilla. + +Setting the server option `requestCert: true` tells the server to request the client certificate. + +The server option `rejectUnauthorized: false` allows us to handle any invalid certificate errors in client code. The `authorized` property on the socket of the incoming request will show if the client certificate was valid. The `authorizationError` property will give the reason if the certificate was not valid. + +### Client Certificate Authentication + +```js +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const { createServer } = require('node:https') +const { Client } = require('undici') + +const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'server-key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'server-crt.pem'), 'utf8'), + requestCert: true, + rejectUnauthorized: false +} + +const server = createServer(serverOptions, (req, res) => { + // true if client cert is valid + if(req.client.authorized === true) { + console.log('valid') + } else { + console.error(req.client.authorizationError) + } + res.end() +}) + +server.listen(0, function () { + const tls = { + ca: [ + readFileSync(join(__dirname, 'server-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'client-key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'client-crt.pem'), 'utf8'), + rejectUnauthorized: false, + servername: 'agent1' + } + const client = new Client(`https://localhost:${server.address().port}`, { + connect: tls + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.on('data', (buf) => {}) + body.on('end', () => { + client.close() + server.close() + }) + }) +}) +``` diff --git a/docs/docs/best-practices/mocking-request.md b/docs/docs/best-practices/mocking-request.md new file mode 100644 index 0000000..6883193 --- /dev/null +++ b/docs/docs/best-practices/mocking-request.md @@ -0,0 +1,136 @@ +# Mocking Request + +Undici has its own mocking [utility](/docs/docs/api/MockAgent.md). It allow us to intercept undici HTTP requests and return mocked values instead. It can be useful for testing purposes. + +Example: + +```js +// bank.mjs +import { request } from 'undici' + +export async function bankTransfer(recipient, amount) { + const { body } = await request('http://localhost:3000/bank-transfer', + { + method: 'POST', + headers: { + 'X-TOKEN-SECRET': 'SuperSecretToken', + }, + body: JSON.stringify({ + recipient, + amount + }) + } + ) + return await body.json() +} +``` + +And this is what the test file looks like: + +```js +// index.test.mjs +import { strict as assert } from 'assert' +import { MockAgent, setGlobalDispatcher, } from 'undici' +import { bankTransfer } from './bank.mjs' + +const mockAgent = new MockAgent(); + +setGlobalDispatcher(mockAgent); + +// Provide the base url to the request +const mockPool = mockAgent.get('http://localhost:3000'); + +// intercept the request +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', + headers: { + 'X-TOKEN-SECRET': 'SuperSecretToken', + }, + body: JSON.stringify({ + recipient: '1234567890', + amount: '100' + }) +}).reply(200, { + message: 'transaction processed' +}) + +const success = await bankTransfer('1234567890', '100') + +assert.deepEqual(success, { message: 'transaction processed' }) + +// if you dont want to check whether the body or the headers contain the same value +// just remove it from interceptor +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', +}).reply(400, { + message: 'bank account not found' +}) + +const badRequest = await bankTransfer('1234567890', '100') + +assert.deepEqual(badRequest, { message: 'bank account not found' }) +``` + +Explore other MockAgent functionality [here](/docs/docs/api/MockAgent.md) + +## Debug Mock Value + +When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`: + +```js +const mockAgent = new MockAgent(); + +setGlobalDispatcher(mockAgent); +mockAgent.disableNetConnect() + +// Provide the base url to the request +const mockPool = mockAgent.get('http://localhost:3000'); + +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', +}).reply(200, { + message: 'transaction processed' +}) + +const badRequest = await bankTransfer('1234567890', '100') +// Will throw an error +// MockNotMatchedError: Mock dispatch not matched for path '/bank-transfer': +// subsequent request to origin http://localhost:3000 was not allowed (net.connect disabled) +``` + +## Reply with data based on request + +If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply`: + +```js +mockPool.intercept({ + path: '/bank-transfer', + method: 'POST', + headers: { + 'X-TOKEN-SECRET': 'SuperSecretToken', + }, + body: JSON.stringify({ + recipient: '1234567890', + amount: '100' + }) +}).reply(200, (opts) => { + // do something with opts + + return { message: 'transaction processed' } +}) +``` + +in this case opts will be + +``` +{ + method: 'POST', + headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' }, + body: '{"recipient":"1234567890","amount":"100"}', + origin: 'http://localhost:3000', + path: '/bank-transfer' +} +``` diff --git a/docs/docs/best-practices/proxy.md b/docs/docs/best-practices/proxy.md new file mode 100644 index 0000000..8b1a721 --- /dev/null +++ b/docs/docs/best-practices/proxy.md @@ -0,0 +1,127 @@ +# Connecting through a proxy + +Connecting through a proxy is possible by: + +- Using [ProxyAgent](/docs/docs/api/ProxyAgent.md). +- Configuring `Client` or `Pool` constructor. + +The proxy url should be passed to the `Client` or `Pool` constructor, while the upstream server url +should be added to every request call in the `path`. +For instance, if you need to send a request to the `/hello` route of your upstream server, +the `path` should be `path: 'http://upstream.server:port/hello?foo=bar'`. + +If you proxy requires basic authentication, you can send it via the `proxy-authorization` header. + +### Connect without authentication + +```js +import { Client } from 'undici' +import { createServer } from 'http' +import { createProxy } from 'proxy' + +const server = await buildServer() +const proxyServer = await buildProxy() + +const serverUrl = `http://localhost:${server.address().port}` +const proxyUrl = `http://localhost:${proxyServer.address().port}` + +server.on('request', (req, res) => { + console.log(req.url) // '/hello?foo=bar' + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) +}) + +const client = new Client(proxyUrl) + +const response = await client.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar' +}) + +response.body.setEncoding('utf8') +let data = '' +for await (const chunk of response.body) { + data += chunk +} +console.log(response.statusCode) // 200 +console.log(JSON.parse(data)) // { hello: 'world' } + +server.close() +proxyServer.close() +client.close() + +function buildServer () { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy () { + return new Promise((resolve, reject) => { + const server = createProxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} +``` + +### Connect with authentication + +```js +import { Client } from 'undici' +import { createServer } from 'http' +import { createProxy } from 'proxy' + +const server = await buildServer() +const proxyServer = await buildProxy() + +const serverUrl = `http://localhost:${server.address().port}` +const proxyUrl = `http://localhost:${proxyServer.address().port}` + +proxyServer.authenticate = function (req) { + return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` +} + +server.on('request', (req, res) => { + console.log(req.url) // '/hello?foo=bar' + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) +}) + +const client = new Client(proxyUrl) + +const response = await client.request({ + method: 'GET', + path: serverUrl + '/hello?foo=bar', + headers: { + 'proxy-authorization': `Basic ${Buffer.from('user:pass').toString('base64')}` + } +}) + +response.body.setEncoding('utf8') +let data = '' +for await (const chunk of response.body) { + data += chunk +} +console.log(response.statusCode) // 200 +console.log(JSON.parse(data)) // { hello: 'world' } + +server.close() +proxyServer.close() +client.close() + +function buildServer () { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy () { + return new Promise((resolve, reject) => { + const server = createProxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} +``` + diff --git a/docs/docs/best-practices/writing-tests.md b/docs/docs/best-practices/writing-tests.md new file mode 100644 index 0000000..57549de --- /dev/null +++ b/docs/docs/best-practices/writing-tests.md @@ -0,0 +1,20 @@ +# Writing tests + +Undici is tuned for a production use case and its default will keep +a socket open for a few seconds after an HTTP request is completed to +remove the overhead of opening up a new socket. These settings that makes +Undici shine in production are not a good fit for using Undici in automated +tests, as it will result in longer execution times. + +The following are good defaults that will keep the socket open for only 10ms: + +```js +import { request, setGlobalDispatcher, Agent } from 'undici' + +const agent = new Agent({ + keepAliveTimeout: 10, // milliseconds + keepAliveMaxTimeout: 10 // milliseconds +}) + +setGlobalDispatcher(agent) +``` diff --git a/docs/docsify/sidebar.md b/docs/docsify/sidebar.md new file mode 100644 index 0000000..4a4ae67 --- /dev/null +++ b/docs/docsify/sidebar.md @@ -0,0 +1,39 @@ + + +* [**Home**](/ "Node.js Undici") +* API + * [Dispatcher](/docs/api/Dispatcher.md "Undici API - Dispatcher") + * [Client](/docs/api/Client.md "Undici API - Client") + * [Pool](/docs/api/Pool.md "Undici API - Pool") + * [BalancedPool](/docs/api/BalancedPool.md "Undici API - BalancedPool") + * [Agent](/docs/api/Agent.md "Undici API - Agent") + * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") + * [RetryAgent](/docs/api/RetryAgent.md "Undici API - RetryAgent") + * [Connector](/docs/api/Connector.md "Custom connector") + * [Errors](/docs/api/Errors.md "Undici API - Errors") + * [EventSource](/docs/api/EventSource.md "Undici API - EventSource") + * [Fetch](/docs/api/Fetch.md "Undici API - Fetch") + * [Cookies](/docs/api/Cookies.md "Undici API - Cookies") + * [MockClient](/docs/api/MockClient.md "Undici API - MockClient") + * [MockPool](/docs/api/MockPool.md "Undici API - MockPool") + * [MockAgent](/docs/api/MockAgent.md "Undici API - MockAgent") + * [MockErrors](/docs/api/MockErrors.md "Undici API - MockErrors") + * [API Lifecycle](/docs/api/api-lifecycle.md "Undici API - Lifecycle") + * [Diagnostics Channel Support](/docs/api/DiagnosticsChannel.md "Diagnostics Channel Support") + * [Debug](/docs/api/Debug.md "Undici API - Debugging Undici") + * [WebSocket](/docs/api/WebSocket.md "Undici API - WebSocket") + * [MIME Type Parsing](/docs/api/ContentType.md "Undici API - MIME Type Parsing") + * [CacheStorage](/docs/api/CacheStorage.md "Undici API - CacheStorage") + * [Util](/docs/api/Util.md "Undici API - Util") + * [RedirectHandler](/docs/api/RedirectHandler.md "Undici API - RedirectHandler") + * [RetryHandler](/docs/api/RetryHandler.md "Undici API - RetryHandler") + * [DiagnosticsChannel](/docs/api/DiagnosticsChannel.md "Undici API - DiagnosticsChannel") + * [EnvHttpProxyAgent](/docs/api/EnvHttpProxyAgent.md "Undici API - EnvHttpProxyAgent") + * [PoolStats](/docs/api/PoolStats.md "Undici API - PoolStats") +* Examples + * [Undici Examples](/examples/ "Undici Examples") +* Best Practices + * [Proxy](/docs/best-practices/proxy.md "Connecting through a proxy") + * [Client Certificate](/docs/best-practices/client-certificate.md "Connect using a client certificate") + * [Writing Tests](/docs/best-practices/writing-tests.md "Using Undici inside tests") + * [Mocking Request](/docs/best-practices/mocking-request.md "Using Undici inside tests") diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..a8a14cf --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,142 @@ + +## undici.request() examples + +### A simple GET request, read the response body as text: +```js +const { request } = require('undici') +async function getRequest (port = 3001) { + // A simple GET request + const { + statusCode, + headers, + body + } = await request(`http://localhost:${port}/`) + + const data = await body.text() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', data) +} +``` + +### A JSON POST request, read the response body as json: +```js +const { request } = require('undici') +async function postJSONRequest (port = 3001) { + const requestBody = { + hello: 'JSON POST Example body' + } + + const { + statusCode, + headers, + body + } = await request( + `http://localhost:${port}/json`, + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(requestBody) } + ) + + // .json() will fail if we did not receive a valid json body in response: + const decodedJson = await body.json() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', decodedJson) +} +``` + +### A Form POST request, read the response body as text: +```js +const { request } = require('undici') +async function postFormRequest (port = 3001) { + // Make a URL-encoded form POST request: + const qs = require('node:querystring') + + const requestBody = { + hello: 'URL Encoded Example body' + } + + const { + statusCode, + headers, + body + } = await request( + `http://localhost:${port}/form`, + { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: qs.stringify(requestBody) } + ) + + const data = await body.text() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', data) +} +``` + +### A FormData request with file stream, read the response body as text + +```js +const { request } = require('undici') +const { openAsBlob } = require('fs') + +async function formDataBlobRequest () { + // Make a FormData request with file stream: + + const formData = new FormData() + formData.append('field', 42) + formData.set('file', await openAsBlob('./index.mjs')) + + const response = await request('http://127.0.0.1:3000', { + method: 'POST', + body: formData + }) + console.log(await response.body.text()) + + const data = await body.text() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', data) +} + +``` + +### A DELETE request +```js +const { request } = require('undici') +async function deleteRequest (port = 3001) { + // Make a DELETE request + const { + statusCode, + headers, + body + } = await request( + `http://localhost:${port}/something`, + { method: 'DELETE' } + ) + + console.log('response received', statusCode) + console.log('headers', headers) + // For a DELETE request we expect a 204 response with no body if successful, in which case getting the body content with .json() will fail + if (statusCode === 204) { + console.log('delete successful') + // always consume the body if there is one: + await body.dump() + } else { + const data = await body.text() + console.log('received unexpected data', data) + } +} +``` + +## Cacheable DNS Lookup + +### Using CacheableLookup to cache DNS lookups in undici + +```js +import { Agent } from 'undici' +import CacheableLookup from 'cacheable-lookup'; + +const cacheable = new CacheableLookup(opts) + +const agent = new Agent({ + connect: { lookup: cacheable.lookup } +}) +``` diff --git a/docs/examples/ca-fingerprint/index.js b/docs/examples/ca-fingerprint/index.js new file mode 100644 index 0000000..b4dfc41 --- /dev/null +++ b/docs/examples/ca-fingerprint/index.js @@ -0,0 +1,80 @@ +'use strict' + +const crypto = require('node:crypto') +const https = require('node:https') +const { Client, buildConnector } = require('../../../') +const pem = require('https-pem') + +const caFingerprint = getFingerprint(pem.cert.toString() + .split('\n') + .slice(1, -1) + .map(line => line.trim()) + .join('') +) + +const server = https.createServer(pem, (req, res) => { + res.setHeader('Content-Type', 'text/plain') + res.end('hello') +}) + +server.listen(0, function () { + const connector = buildConnector({ rejectUnauthorized: false }) + const client = new Client(`https://localhost:${server.address().port}`, { + connect (opts, cb) { + connector(opts, (err, socket) => { + if (err) { + cb(err) + } else if (getIssuerCertificate(socket).fingerprint256 !== caFingerprint) { + socket.destroy() + cb(new Error('Fingerprint does not match or malformed certificate')) + } else { + cb(null, socket) + } + }) + } + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + if (err) throw err + + const bufs = [] + data.body.on('data', (buf) => { + bufs.push(buf) + }) + data.body.on('end', () => { + console.log(Buffer.concat(bufs).toString('utf8')) + client.close() + server.close() + }) + }) +}) + +function getIssuerCertificate (socket) { + let certificate = socket.getPeerCertificate(true) + while (certificate && Object.keys(certificate).length > 0) { + // invalid certificate + if (certificate.issuerCertificate == null) { + return null + } + + // We have reached the root certificate. + // In case of self-signed certificates, `issuerCertificate` may be a circular reference. + if (certificate.fingerprint256 === certificate.issuerCertificate.fingerprint256) { + break + } + + // continue the loop + certificate = certificate.issuerCertificate + } + return certificate +} + +function getFingerprint (content, inputEncoding = 'base64', outputEncoding = 'hex') { + const shasum = crypto.createHash('sha256') + shasum.update(content, inputEncoding) + const res = shasum.digest(outputEncoding) + return res.toUpperCase().match(/.{1,2}/g).join(':') +} diff --git a/docs/examples/eventsource.js b/docs/examples/eventsource.js new file mode 100644 index 0000000..3aa8255 --- /dev/null +++ b/docs/examples/eventsource.js @@ -0,0 +1,20 @@ +'use strict' + +const { randomBytes } = require('node:crypto') +const { EventSource } = require('../../') + +async function main () { + const url = `https://smee.io/${randomBytes(8).toString('base64url')}` + console.log(`Connecting to event source server ${url}`) + const ev = new EventSource(url) + ev.onmessage = console.log + ev.onerror = console.log + ev.onopen = console.log + + // Special event of smee.io + ev.addEventListener('ready', console.log) + + // Ping event is sent every 30 seconds by smee.io + ev.addEventListener('ping', console.log) +} +main() diff --git a/docs/examples/fetch.js b/docs/examples/fetch.js new file mode 100644 index 0000000..26f32eb --- /dev/null +++ b/docs/examples/fetch.js @@ -0,0 +1,13 @@ +'use strict' + +const { fetch } = require('../../') + +async function main () { + const res = await fetch('http://localhost:3001/') + + const data = await res.text() + console.log('response received', res.status) + console.log('headers', res.headers) + console.log('data', data) +} +main() diff --git a/docs/examples/proxy-agent.js b/docs/examples/proxy-agent.js new file mode 100644 index 0000000..df41c61 --- /dev/null +++ b/docs/examples/proxy-agent.js @@ -0,0 +1,25 @@ +'use strict' + +const { request, setGlobalDispatcher, ProxyAgent } = require('../..') + +setGlobalDispatcher(new ProxyAgent('http://localhost:8000/')) + +async function main () { + const { + statusCode, + headers, + trailers, + body + // send the request via the http://localhost:8000/ HTTP proxy + } = await request('http://localhost:3000/undici') + + console.log('response received', statusCode) + console.log('headers', headers) + + for await (const data of body) { + console.log('data', data) + } + + console.log('trailers', trailers) +} +main() diff --git a/docs/examples/proxy/fetch.mjs b/docs/examples/proxy/fetch.mjs new file mode 100644 index 0000000..56e3772 --- /dev/null +++ b/docs/examples/proxy/fetch.mjs @@ -0,0 +1,35 @@ +import * as http from 'node:http' +import { once } from 'node:events' +import { createProxy } from 'proxy' +import { ProxyAgent } from '../../../index.js' + +const proxyServer = createProxy(http.createServer()) +const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('okay') +}) + +proxyServer.on('request', (req, res) => { + console.log(`Incoming request to ${req.url}`) +}) + +await once(proxyServer.listen(0), 'listening') +await once(server.listen(0), 'listening') + +const { port: proxyPort } = proxyServer.address() +const { port } = server.address() + +console.log(`Proxy listening on port ${proxyPort}`) +console.log(`Server listening on port ${port}`) +try { + // undici does a tunneling to the proxy server using CONNECT. + const agent = new ProxyAgent(`http://localhost:${proxyPort}`) + const response = await fetch(`http://localhost:${port}`, { + dispatcher: agent, + method: 'GET' + }) + const data = await response.text() + console.log('Response data:', data) +} catch (e) { + console.log(e) +} diff --git a/docs/examples/proxy/index.js b/docs/examples/proxy/index.js new file mode 100644 index 0000000..355b17d --- /dev/null +++ b/docs/examples/proxy/index.js @@ -0,0 +1,49 @@ +'use strict' + +const { Pool, Client } = require('../../../') +const http = require('node:http') +const proxy = require('./proxy') + +const pool = new Pool('http://localhost:4001', { + connections: 256, + pipelining: 1 +}) + +async function run () { + await Promise.all([ + new Promise(resolve => { + // Proxy + http.createServer((req, res) => { + proxy({ req, res, proxyName: 'example' }, pool).catch(err => { + if (res.headersSent) { + res.destroy(err) + } else { + for (const name of res.getHeaderNames()) { + res.removeHeader(name) + } + res.statusCode = err.statusCode || 500 + res.end() + } + }) + }).listen(4000, resolve) + }), + new Promise(resolve => { + // Upstream + http.createServer((req, res) => { + res.end('hello world') + }).listen(4001, resolve) + }) + ]) + + const client = new Client('http://localhost:4000') + const { body } = await client.request({ + method: 'GET', + path: '/' + }) + + for await (const chunk of body) { + console.log(String(chunk)) + } +} + +run() diff --git a/docs/examples/proxy/proxy.js b/docs/examples/proxy/proxy.js new file mode 100644 index 0000000..8826bc7 --- /dev/null +++ b/docs/examples/proxy/proxy.js @@ -0,0 +1,318 @@ +'use strict' + +const net = require('node:net') +const { pipeline } = require('node:stream') +const { STATUS_CODES } = require('node:http') + +module.exports = async function proxy (ctx, client) { + const { req, socket, proxyName } = ctx + + const headers = getHeaders({ + headers: req.rawHeaders, + httpVersion: req.httpVersion, + socket: req.socket, + proxyName + }) + + if (socket) { + const handler = new WSHandler(ctx) + client.dispatch({ + method: req.method, + path: req.url, + headers, + upgrade: 'Websocket' + }, handler) + return handler.promise + } else { + const handler = new HTTPHandler(ctx) + client.dispatch({ + method: req.method, + path: req.url, + headers, + body: req + }, handler) + return handler.promise + } +} + +class HTTPHandler { + constructor (ctx) { + const { req, res, proxyName } = ctx + + this.proxyName = proxyName + this.req = req + this.res = res + this.resume = null + this.abort = null + this.promise = new Promise((resolve, reject) => { + this.callback = err => err ? reject(err) : resolve() + }) + } + + onConnect (abort) { + if (this.req.aborted) { + abort() + } else { + this.abort = abort + this.res.on('close', abort) + } + } + + onHeaders (statusCode, headers, resume) { + if (statusCode < 200) { + return + } + + this.resume = resume + this.res.on('drain', resume) + this.res.writeHead(statusCode, getHeaders({ + headers, + proxyName: this.proxyName, + httpVersion: this.httpVersion + })) + } + + onData (chunk) { + return this.res.write(chunk) + } + + onComplete () { + this.res.off('close', this.abort) + this.res.off('drain', this.resume) + + this.res.end() + this.callback() + } + + onError (err) { + this.res.off('close', this.abort) + this.res.off('drain', this.resume) + + this.callback(err) + } +} + +class WSHandler { + constructor (ctx) { + const { req, socket, proxyName, head } = ctx + + setupSocket(socket) + + this.proxyName = proxyName + this.httpVersion = req.httpVersion + this.socket = socket + this.head = head + this.abort = null + this.promise = new Promise((resolve, reject) => { + this.callback = err => err ? reject(err) : resolve() + }) + } + + onConnect (abort) { + if (this.socket.destroyed) { + abort() + } else { + this.abort = abort + this.socket.on('close', abort) + } + } + + onUpgrade (statusCode, headers, socket) { + this.socket.off('close', this.abort) + + // TODO: Check statusCode? + + if (this.head && this.head.length) { + socket.unshift(this.head) + } + + setupSocket(socket) + + headers = getHeaders({ + headers, + proxyName: this.proxyName, + httpVersion: this.httpVersion + }) + + let head = '' + for (let n = 0; n < headers.length; n += 2) { + head += `\r\n${headers[n]}: ${headers[n + 1]}` + } + + this.socket.write(`HTTP/1.1 101 Switching Protocols\r\nconnection: upgrade\r\nupgrade: websocket${head}\r\n\r\n`) + + pipeline(socket, this.socket, socket, this.callback) + } + + onError (err) { + this.socket.off('close', this.abort) + + this.callback(err) + } +} + +// This expression matches hop-by-hop headers. +// These headers are meaningful only for a single transport-level connection, +// and must not be retransmitted by proxies or cached. +const HOP_EXPR = /^(te|host|upgrade|trailers|connection|keep-alive|http2-settings|transfer-encoding|proxy-connection|proxy-authenticate|proxy-authorization)$/i + +// Removes hop-by-hop and pseudo headers. +// Updates via and forwarded headers. +// Only hop-by-hop headers may be set using the Connection general header. +function getHeaders ({ + headers, + proxyName, + httpVersion, + socket +}) { + let via = '' + let forwarded = '' + let host = '' + let authority = '' + let connection = '' + + for (let n = 0; n < headers.length; n += 2) { + const key = headers[n] + const val = headers[n + 1] + + if (!via && key.length === 3 && key.toLowerCase() === 'via') { + via = val + } else if (!host && key.length === 4 && key.toLowerCase() === 'host') { + host = val + } else if (!forwarded && key.length === 9 && key.toLowerCase() === 'forwarded') { + forwarded = val + } else if (!connection && key.length === 10 && key.toLowerCase() === 'connection') { + connection = val + } else if (!authority && key.length === 10 && key === ':authority') { + authority = val + } + } + + let remove + if (connection && !HOP_EXPR.test(connection)) { + remove = connection.split(/,\s*/) + } + + const result = [] + for (let n = 0; n < headers.length; n += 2) { + const key = headers[n] + const val = headers[n + 1] + + if ( + key.charAt(0) !== ':' && + !HOP_EXPR.test(key) && + (!remove || !remove.includes(key)) + ) { + result.push(key, val) + } + } + + if (socket) { + result.push('forwarded', (forwarded ? forwarded + ', ' : '') + [ + `by=${printIp(socket.localAddress, socket.localPort)}`, + `for=${printIp(socket.remoteAddress, socket.remotePort)}`, + `proto=${socket.encrypted ? 'https' : 'http'}`, + `host=${printIp(authority || host || '')}` + ].join(';')) + } else if (forwarded) { + // The forwarded header should not be included in response. + throw new BadGateway() + } + + if (proxyName) { + if (via) { + if (via.split(',').some(name => name.endsWith(proxyName))) { + throw new LoopDetected() + } + via += ', ' + } + via += `${httpVersion} ${proxyName}` + } + + if (via) { + result.push('via', via) + } + + return result +} + +function setupSocket (socket) { + socket.setTimeout(0) + socket.setNoDelay(true) + socket.setKeepAlive(true, 0) +} + +function printIp (address, port) { + const isIPv6 = net.isIPv6(address) + let str = `${address}` + if (isIPv6) { + str = `[${str}]` + } + if (port) { + str = `${str}:${port}` + } + if (isIPv6 || port) { + str = `"${str}"` + } + return str +} + +class BadGateway extends Error { + constructor (message = STATUS_CODES[502]) { + super(message) + } + + toString () { + return `BadGatewayError: ${this.message}` + } + + get name () { + return 'BadGatewayError' + } + + get status () { + return 502 + } + + get statusCode () { + return 502 + } + + get expose () { + return false + } + + get headers () { + return undefined + } +} + +class LoopDetected extends Error { + constructor (message = STATUS_CODES[508]) { + super(message) + } + + toString () { + return `LoopDetectedError: ${this.message}` + } + + get name () { + return 'LoopDetectedError' + } + + get status () { + return 508 + } + + get statusCode () { + return 508 + } + + get expose () { + return false + } + + get headers () { + return undefined + } +} diff --git a/docs/examples/proxy/websocket.js b/docs/examples/proxy/websocket.js new file mode 100644 index 0000000..8cb9ef8 --- /dev/null +++ b/docs/examples/proxy/websocket.js @@ -0,0 +1,91 @@ +'use strict' + +const { Pool, Client } = require('../../../') +const http = require('node:http') +const proxy = require('./proxy') +const WebSocket = require('ws') + +const pool = new Pool('http://localhost:4001', { + connections: 256, + pipelining: 1 +}) + +function createWebSocketServer () { + const wss = new WebSocket.Server({ noServer: true }) + + wss.on('connection', ws => { + ws.on('message', message => { + console.log(`Received message: ${message}`) + ws.send('Received your message!') + }) + }) + + return wss +} + +async function run () { + await Promise.all([ + new Promise(resolve => { + // Proxy + http.createServer((req, res) => { + proxy({ req, res, proxyName: 'example' }, pool).catch(err => { + if (res.headersSent) { + res.destroy(err) + } else { + for (const name of res.getHeaderNames()) { + res.removeHeader(name) + } + res.statusCode = err.statusCode || 500 + res.end() + } + }) + }).listen(4000, resolve) + }), + new Promise(resolve => { + // Upstream + http.createServer((req, res) => { + res.end('hello world') + }).listen(4001, resolve) + }), + new Promise(resolve => { + // WebSocket server + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('WebSocket server is running!') + }) + + const wss = createWebSocketServer() + + server.on('upgrade', (request, socket, head) => { + wss.handleUpgrade(request, socket, head, ws => { + wss.emit('connection', ws, request) + }) + }) + + server.listen(4002, resolve) + }) + ]) + + const client = new Client('http://localhost:4000') + const { body } = await client.request({ + method: 'GET', + path: '/' + }) + + for await (const chunk of body) { + console.log(String(chunk)) + } + + // WebSocket client + const ws = new WebSocket('ws://localhost:4002') + ws.on('open', () => { + ws.send('Hello, WebSocket Server!') + }) + + ws.on('message', message => { + console.log(`WebSocket Server says: ${message}`) + ws.close() + }) +} + +run() diff --git a/docs/examples/request.js b/docs/examples/request.js new file mode 100644 index 0000000..f9aba75 --- /dev/null +++ b/docs/examples/request.js @@ -0,0 +1,94 @@ +'use strict' + +const { request } = require('../../') + +async function getRequest (port = 3001) { + // A simple GET request + const { + statusCode, + headers, + body + } = await request(`http://localhost:${port}/`) + + const data = await body.text() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', data) +} + +async function postJSONRequest (port = 3001) { + // Make a JSON POST request: + + const requestBody = { + hello: 'JSON POST Example body' + } + + const { + statusCode, + headers, + body + } = await request( + `http://localhost:${port}/json`, + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(requestBody) } + ) + + // .json() will fail if we did not receive a valid json body in response: + const decodedJson = await body.json() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', decodedJson) +} + +async function postFormRequest (port = 3001) { + // Make a URL-encoded form POST request: + const qs = require('node:querystring') + + const requestBody = { + hello: 'URL Encoded Example body' + } + + const { + statusCode, + headers, + body + } = await request( + `http://localhost:${port}/form`, + { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: qs.stringify(requestBody) } + ) + + const data = await body.text() + console.log('response received', statusCode) + console.log('headers', headers) + console.log('data', data) +} + +async function deleteRequest (port = 3001) { + // Make a DELETE request + const { + statusCode, + headers, + body + } = await request( + `http://localhost:${port}/something`, + { method: 'DELETE' } + ) + + console.log('response received', statusCode) + console.log('headers', headers) + // For a DELETE request we expect a 204 response with no body if successful, in which case getting the body content with .json() will fail + if (statusCode === 204) { + console.log('delete successful') + // always consume the body if there is one: + await body.dump() + } else { + const data = await body.text() + console.log('received unexpected data', data) + } +} + +module.exports = { + getRequest, + postJSONRequest, + postFormRequest, + deleteRequest +} diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..f6ebf25 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,107 @@ + + + + + Node.js Undici + + + + + + + +
+ + + + + + + + diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..491fcfc --- /dev/null +++ b/docs/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "name": "@undici/documentation", + "description": "Documentation site for the `undici` package.", + "scripts": { + "serve": "docsify serve ." + }, + "dependencies": { + "docsify-cli": "^4.4.4" + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..3c0cbc1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +'use strict' + +const neo = require('neostandard') + +module.exports = [ + ...neo({ + ignores: [ + 'lib/llhttp', + 'test/fixtures/wpt', + 'test/fixtures/cache-tests', + 'undici-fetch.js' + ], + noJsx: true, + ts: true + }), + { + rules: { + '@stylistic/comma-dangle': ['error', { + arrays: 'never', + objects: 'never', + imports: 'never', + exports: 'never', + functions: 'never' + }], + '@typescript-eslint/no-redeclare': 'off' + } + } +] diff --git a/index-fetch.js b/index-fetch.js new file mode 100644 index 0000000..01df32d --- /dev/null +++ b/index-fetch.js @@ -0,0 +1,32 @@ +'use strict' + +const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global') +const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent') +const fetchImpl = require('./lib/web/fetch').fetch + +module.exports.fetch = function fetch (resource, init = undefined) { + return fetchImpl(resource, init).catch((err) => { + if (err && typeof err === 'object') { + Error.captureStackTrace(err) + } + throw err + }) +} +module.exports.FormData = require('./lib/web/fetch/formdata').FormData +module.exports.Headers = require('./lib/web/fetch/headers').Headers +module.exports.Response = require('./lib/web/fetch/response').Response +module.exports.Request = require('./lib/web/fetch/request').Request + +const { CloseEvent, ErrorEvent, MessageEvent, createFastMessageEvent } = require('./lib/web/websocket/events') +module.exports.WebSocket = require('./lib/web/websocket/websocket').WebSocket +module.exports.CloseEvent = CloseEvent +module.exports.ErrorEvent = ErrorEvent +module.exports.MessageEvent = MessageEvent +module.exports.createFastMessageEvent = createFastMessageEvent + +module.exports.EventSource = require('./lib/web/eventsource/eventsource').EventSource + +// Expose the fetch implementation to be enabled in Node.js core via a flag +module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent +module.exports.getGlobalDispatcher = getGlobalDispatcher +module.exports.setGlobalDispatcher = setGlobalDispatcher diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..0883fc7 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,3 @@ +import Undici from './types/index' +export default Undici +export * from './types/index' diff --git a/index.js b/index.js new file mode 100644 index 0000000..f31e10e --- /dev/null +++ b/index.js @@ -0,0 +1,178 @@ +'use strict' + +const Client = require('./lib/dispatcher/client') +const Dispatcher = require('./lib/dispatcher/dispatcher') +const Pool = require('./lib/dispatcher/pool') +const BalancedPool = require('./lib/dispatcher/balanced-pool') +const Agent = require('./lib/dispatcher/agent') +const ProxyAgent = require('./lib/dispatcher/proxy-agent') +const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent') +const RetryAgent = require('./lib/dispatcher/retry-agent') +const errors = require('./lib/core/errors') +const util = require('./lib/core/util') +const { InvalidArgumentError } = errors +const api = require('./lib/api') +const buildConnector = require('./lib/core/connect') +const MockClient = require('./lib/mock/mock-client') +const MockAgent = require('./lib/mock/mock-agent') +const MockPool = require('./lib/mock/mock-pool') +const mockErrors = require('./lib/mock/mock-errors') +const RetryHandler = require('./lib/handler/retry-handler') +const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global') +const DecoratorHandler = require('./lib/handler/decorator-handler') +const RedirectHandler = require('./lib/handler/redirect-handler') + +Object.assign(Dispatcher.prototype, api) + +module.exports.Dispatcher = Dispatcher +module.exports.Client = Client +module.exports.Pool = Pool +module.exports.BalancedPool = BalancedPool +module.exports.Agent = Agent +module.exports.ProxyAgent = ProxyAgent +module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent +module.exports.RetryAgent = RetryAgent +module.exports.RetryHandler = RetryHandler + +module.exports.DecoratorHandler = DecoratorHandler +module.exports.RedirectHandler = RedirectHandler +module.exports.interceptors = { + redirect: require('./lib/interceptor/redirect'), + responseError: require('./lib/interceptor/response-error'), + retry: require('./lib/interceptor/retry'), + dump: require('./lib/interceptor/dump'), + dns: require('./lib/interceptor/dns'), + cache: require('./lib/interceptor/cache') +} + +module.exports.cacheStores = { + MemoryCacheStore: require('./lib/cache/memory-cache-store') +} + +const SqliteCacheStore = require('./lib/cache/sqlite-cache-store') +module.exports.cacheStores.SqliteCacheStore = SqliteCacheStore + +module.exports.buildConnector = buildConnector +module.exports.errors = errors +module.exports.util = { + parseHeaders: util.parseHeaders, + headerNameToString: util.headerNameToString +} + +function makeDispatcher (fn) { + return (url, opts, handler) => { + if (typeof opts === 'function') { + handler = opts + opts = null + } + + if (!url || (typeof url !== 'string' && typeof url !== 'object' && !(url instanceof URL))) { + throw new InvalidArgumentError('invalid url') + } + + if (opts != null && typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (opts && opts.path != null) { + if (typeof opts.path !== 'string') { + throw new InvalidArgumentError('invalid opts.path') + } + + let path = opts.path + if (!opts.path.startsWith('/')) { + path = `/${path}` + } + + url = new URL(util.parseOrigin(url).origin + path) + } else { + if (!opts) { + opts = typeof url === 'object' ? url : {} + } + + url = util.parseURL(url) + } + + const { agent, dispatcher = getGlobalDispatcher() } = opts + + if (agent) { + throw new InvalidArgumentError('unsupported opts.agent. Did you mean opts.client?') + } + + return fn.call(dispatcher, { + ...opts, + origin: url.origin, + path: url.search ? `${url.pathname}${url.search}` : url.pathname, + method: opts.method || (opts.body ? 'PUT' : 'GET') + }, handler) + } +} + +module.exports.setGlobalDispatcher = setGlobalDispatcher +module.exports.getGlobalDispatcher = getGlobalDispatcher + +const fetchImpl = require('./lib/web/fetch').fetch +module.exports.fetch = async function fetch (init, options = undefined) { + try { + return await fetchImpl(init, options) + } catch (err) { + if (err && typeof err === 'object') { + Error.captureStackTrace(err) + } + + throw err + } +} +module.exports.Headers = require('./lib/web/fetch/headers').Headers +module.exports.Response = require('./lib/web/fetch/response').Response +module.exports.Request = require('./lib/web/fetch/request').Request +module.exports.FormData = require('./lib/web/fetch/formdata').FormData + +const { setGlobalOrigin, getGlobalOrigin } = require('./lib/web/fetch/global') + +module.exports.setGlobalOrigin = setGlobalOrigin +module.exports.getGlobalOrigin = getGlobalOrigin + +const { CacheStorage } = require('./lib/web/cache/cachestorage') +const { kConstruct } = require('./lib/core/symbols') + +// Cache & CacheStorage are tightly coupled with fetch. Even if it may run +// in an older version of Node, it doesn't have any use without fetch. +module.exports.caches = new CacheStorage(kConstruct) + +const { deleteCookie, getCookies, getSetCookies, setCookie, parseCookie } = require('./lib/web/cookies') + +module.exports.deleteCookie = deleteCookie +module.exports.getCookies = getCookies +module.exports.getSetCookies = getSetCookies +module.exports.setCookie = setCookie +module.exports.parseCookie = parseCookie + +const { parseMIMEType, serializeAMimeType } = require('./lib/web/fetch/data-url') + +module.exports.parseMIMEType = parseMIMEType +module.exports.serializeAMimeType = serializeAMimeType + +const { CloseEvent, ErrorEvent, MessageEvent } = require('./lib/web/websocket/events') +module.exports.WebSocket = require('./lib/web/websocket/websocket').WebSocket +module.exports.CloseEvent = CloseEvent +module.exports.ErrorEvent = ErrorEvent +module.exports.MessageEvent = MessageEvent + +module.exports.WebSocketStream = require('./lib/web/websocket/stream/websocketstream').WebSocketStream +module.exports.WebSocketError = require('./lib/web/websocket/stream/websocketerror').WebSocketError + +module.exports.request = makeDispatcher(api.request) +module.exports.stream = makeDispatcher(api.stream) +module.exports.pipeline = makeDispatcher(api.pipeline) +module.exports.connect = makeDispatcher(api.connect) +module.exports.upgrade = makeDispatcher(api.upgrade) + +module.exports.MockClient = MockClient +module.exports.MockPool = MockPool +module.exports.MockAgent = MockAgent +module.exports.mockErrors = mockErrors + +const { EventSource } = require('./lib/web/eventsource/eventsource') + +module.exports.EventSource = EventSource diff --git a/lib/api/abort-signal.js b/lib/api/abort-signal.js new file mode 100644 index 0000000..608170b --- /dev/null +++ b/lib/api/abort-signal.js @@ -0,0 +1,59 @@ +'use strict' + +const { addAbortListener } = require('../core/util') +const { RequestAbortedError } = require('../core/errors') + +const kListener = Symbol('kListener') +const kSignal = Symbol('kSignal') + +function abort (self) { + if (self.abort) { + self.abort(self[kSignal]?.reason) + } else { + self.reason = self[kSignal]?.reason ?? new RequestAbortedError() + } + removeSignal(self) +} + +function addSignal (self, signal) { + self.reason = null + + self[kSignal] = null + self[kListener] = null + + if (!signal) { + return + } + + if (signal.aborted) { + abort(self) + return + } + + self[kSignal] = signal + self[kListener] = () => { + abort(self) + } + + addAbortListener(self[kSignal], self[kListener]) +} + +function removeSignal (self) { + if (!self[kSignal]) { + return + } + + if ('removeEventListener' in self[kSignal]) { + self[kSignal].removeEventListener('abort', self[kListener]) + } else { + self[kSignal].removeListener('abort', self[kListener]) + } + + self[kSignal] = null + self[kListener] = null +} + +module.exports = { + addSignal, + removeSignal +} diff --git a/lib/api/api-connect.js b/lib/api/api-connect.js new file mode 100644 index 0000000..c8b86dd --- /dev/null +++ b/lib/api/api-connect.js @@ -0,0 +1,110 @@ +'use strict' + +const assert = require('node:assert') +const { AsyncResource } = require('node:async_hooks') +const { InvalidArgumentError, SocketError } = require('../core/errors') +const util = require('../core/util') +const { addSignal, removeSignal } = require('./abort-signal') + +class ConnectHandler extends AsyncResource { + constructor (opts, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + const { signal, opaque, responseHeaders } = opts + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + super('UNDICI_CONNECT') + + this.opaque = opaque || null + this.responseHeaders = responseHeaders || null + this.callback = callback + this.abort = null + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (this.reason) { + abort(this.reason) + return + } + + assert(this.callback) + + this.abort = abort + this.context = context + } + + onHeaders () { + throw new SocketError('bad connect', null) + } + + onUpgrade (statusCode, rawHeaders, socket) { + const { callback, opaque, context } = this + + removeSignal(this) + + this.callback = null + + let headers = rawHeaders + // Indicates is an HTTP2Session + if (headers != null) { + headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + } + + this.runInAsyncScope(callback, null, null, { + statusCode, + headers, + socket, + opaque, + context + }) + } + + onError (err) { + const { callback, opaque } = this + + removeSignal(this) + + if (callback) { + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + } +} + +function connect (opts, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + connect.call(this, opts, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + const connectHandler = new ConnectHandler(opts, callback) + const connectOptions = { ...opts, method: 'CONNECT' } + + this.dispatch(connectOptions, connectHandler) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts?.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = connect diff --git a/lib/api/api-pipeline.js b/lib/api/api-pipeline.js new file mode 100644 index 0000000..77f3520 --- /dev/null +++ b/lib/api/api-pipeline.js @@ -0,0 +1,252 @@ +'use strict' + +const { + Readable, + Duplex, + PassThrough +} = require('node:stream') +const assert = require('node:assert') +const { AsyncResource } = require('node:async_hooks') +const { + InvalidArgumentError, + InvalidReturnValueError, + RequestAbortedError +} = require('../core/errors') +const util = require('../core/util') +const { addSignal, removeSignal } = require('./abort-signal') + +function noop () {} + +const kResume = Symbol('resume') + +class PipelineRequest extends Readable { + constructor () { + super({ autoDestroy: true }) + + this[kResume] = null + } + + _read () { + const { [kResume]: resume } = this + + if (resume) { + this[kResume] = null + resume() + } + } + + _destroy (err, callback) { + this._read() + + callback(err) + } +} + +class PipelineResponse extends Readable { + constructor (resume) { + super({ autoDestroy: true }) + this[kResume] = resume + } + + _read () { + this[kResume]() + } + + _destroy (err, callback) { + if (!err && !this._readableState.endEmitted) { + err = new RequestAbortedError() + } + + callback(err) + } +} + +class PipelineHandler extends AsyncResource { + constructor (opts, handler) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (typeof handler !== 'function') { + throw new InvalidArgumentError('invalid handler') + } + + const { signal, method, opaque, onInfo, responseHeaders } = opts + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + if (method === 'CONNECT') { + throw new InvalidArgumentError('invalid method') + } + + if (onInfo && typeof onInfo !== 'function') { + throw new InvalidArgumentError('invalid onInfo callback') + } + + super('UNDICI_PIPELINE') + + this.opaque = opaque || null + this.responseHeaders = responseHeaders || null + this.handler = handler + this.abort = null + this.context = null + this.onInfo = onInfo || null + + this.req = new PipelineRequest().on('error', noop) + + this.ret = new Duplex({ + readableObjectMode: opts.objectMode, + autoDestroy: true, + read: () => { + const { body } = this + + if (body?.resume) { + body.resume() + } + }, + write: (chunk, encoding, callback) => { + const { req } = this + + if (req.push(chunk, encoding) || req._readableState.destroyed) { + callback() + } else { + req[kResume] = callback + } + }, + destroy: (err, callback) => { + const { body, req, res, ret, abort } = this + + if (!err && !ret._readableState.endEmitted) { + err = new RequestAbortedError() + } + + if (abort && err) { + abort() + } + + util.destroy(body, err) + util.destroy(req, err) + util.destroy(res, err) + + removeSignal(this) + + callback(err) + } + }).on('prefinish', () => { + const { req } = this + + // Node < 15 does not call _final in same tick. + req.push(null) + }) + + this.res = null + + addSignal(this, signal) + } + + onConnect (abort, context) { + const { res } = this + + if (this.reason) { + abort(this.reason) + return + } + + assert(!res, 'pipeline cannot be retried') + + this.abort = abort + this.context = context + } + + onHeaders (statusCode, rawHeaders, resume) { + const { opaque, handler, context } = this + + if (statusCode < 200) { + if (this.onInfo) { + const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + this.onInfo({ statusCode, headers }) + } + return + } + + this.res = new PipelineResponse(resume) + + let body + try { + this.handler = null + const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + body = this.runInAsyncScope(handler, null, { + statusCode, + headers, + opaque, + body: this.res, + context + }) + } catch (err) { + this.res.on('error', noop) + throw err + } + + if (!body || typeof body.on !== 'function') { + throw new InvalidReturnValueError('expected Readable') + } + + body + .on('data', (chunk) => { + const { ret, body } = this + + if (!ret.push(chunk) && body.pause) { + body.pause() + } + }) + .on('error', (err) => { + const { ret } = this + + util.destroy(ret, err) + }) + .on('end', () => { + const { ret } = this + + ret.push(null) + }) + .on('close', () => { + const { ret } = this + + if (!ret._readableState.ended) { + util.destroy(ret, new RequestAbortedError()) + } + }) + + this.body = body + } + + onData (chunk) { + const { res } = this + return res.push(chunk) + } + + onComplete (trailers) { + const { res } = this + res.push(null) + } + + onError (err) { + const { ret } = this + this.handler = null + util.destroy(ret, err) + } +} + +function pipeline (opts, handler) { + try { + const pipelineHandler = new PipelineHandler(opts, handler) + this.dispatch({ ...opts, body: pipelineHandler.req }, pipelineHandler) + return pipelineHandler.ret + } catch (err) { + return new PassThrough().destroy(err) + } +} + +module.exports = pipeline diff --git a/lib/api/api-request.js b/lib/api/api-request.js new file mode 100644 index 0000000..9ae7ed6 --- /dev/null +++ b/lib/api/api-request.js @@ -0,0 +1,199 @@ +'use strict' + +const assert = require('node:assert') +const { AsyncResource } = require('node:async_hooks') +const { Readable } = require('./readable') +const { InvalidArgumentError, RequestAbortedError } = require('../core/errors') +const util = require('../core/util') + +function noop () {} + +class RequestHandler extends AsyncResource { + constructor (opts, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + const { signal, method, opaque, body, onInfo, responseHeaders, highWaterMark } = opts + + try { + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (highWaterMark && (typeof highWaterMark !== 'number' || highWaterMark < 0)) { + throw new InvalidArgumentError('invalid highWaterMark') + } + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + if (method === 'CONNECT') { + throw new InvalidArgumentError('invalid method') + } + + if (onInfo && typeof onInfo !== 'function') { + throw new InvalidArgumentError('invalid onInfo callback') + } + + super('UNDICI_REQUEST') + } catch (err) { + if (util.isStream(body)) { + util.destroy(body.on('error', noop), err) + } + throw err + } + + this.method = method + this.responseHeaders = responseHeaders || null + this.opaque = opaque || null + this.callback = callback + this.res = null + this.abort = null + this.body = body + this.trailers = {} + this.context = null + this.onInfo = onInfo || null + this.highWaterMark = highWaterMark + this.reason = null + this.removeAbortListener = null + + if (signal?.aborted) { + this.reason = signal.reason ?? new RequestAbortedError() + } else if (signal) { + this.removeAbortListener = util.addAbortListener(signal, () => { + this.reason = signal.reason ?? new RequestAbortedError() + if (this.res) { + util.destroy(this.res.on('error', noop), this.reason) + } else if (this.abort) { + this.abort(this.reason) + } + }) + } + } + + onConnect (abort, context) { + if (this.reason) { + abort(this.reason) + return + } + + assert(this.callback) + + this.abort = abort + this.context = context + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + const { callback, opaque, abort, context, responseHeaders, highWaterMark } = this + + const headers = responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + + if (statusCode < 200) { + if (this.onInfo) { + this.onInfo({ statusCode, headers }) + } + return + } + + const parsedHeaders = responseHeaders === 'raw' ? util.parseHeaders(rawHeaders) : headers + const contentType = parsedHeaders['content-type'] + const contentLength = parsedHeaders['content-length'] + const res = new Readable({ + resume, + abort, + contentType, + contentLength: this.method !== 'HEAD' && contentLength + ? Number(contentLength) + : null, + highWaterMark + }) + + if (this.removeAbortListener) { + res.on('close', this.removeAbortListener) + this.removeAbortListener = null + } + + this.callback = null + this.res = res + if (callback !== null) { + this.runInAsyncScope(callback, null, null, { + statusCode, + headers, + trailers: this.trailers, + opaque, + body: res, + context + }) + } + } + + onData (chunk) { + return this.res.push(chunk) + } + + onComplete (trailers) { + util.parseHeaders(trailers, this.trailers) + this.res.push(null) + } + + onError (err) { + const { res, callback, body, opaque } = this + + if (callback) { + // TODO: Does this need queueMicrotask? + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + + if (res) { + this.res = null + // Ensure all queued handlers are invoked before destroying res. + queueMicrotask(() => { + util.destroy(res.on('error', noop), err) + }) + } + + if (body) { + this.body = null + + if (util.isStream(body)) { + body.on('error', noop) + util.destroy(body, err) + } + } + + if (this.removeAbortListener) { + this.removeAbortListener() + this.removeAbortListener = null + } + } +} + +function request (opts, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + request.call(this, opts, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + const handler = new RequestHandler(opts, callback) + + this.dispatch(opts, handler) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts?.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = request +module.exports.RequestHandler = RequestHandler diff --git a/lib/api/api-stream.js b/lib/api/api-stream.js new file mode 100644 index 0000000..eb92733 --- /dev/null +++ b/lib/api/api-stream.js @@ -0,0 +1,209 @@ +'use strict' + +const assert = require('node:assert') +const { finished } = require('node:stream') +const { AsyncResource } = require('node:async_hooks') +const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors') +const util = require('../core/util') +const { addSignal, removeSignal } = require('./abort-signal') + +function noop () {} + +class StreamHandler extends AsyncResource { + constructor (opts, factory, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + const { signal, method, opaque, body, onInfo, responseHeaders } = opts + + try { + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (typeof factory !== 'function') { + throw new InvalidArgumentError('invalid factory') + } + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + if (method === 'CONNECT') { + throw new InvalidArgumentError('invalid method') + } + + if (onInfo && typeof onInfo !== 'function') { + throw new InvalidArgumentError('invalid onInfo callback') + } + + super('UNDICI_STREAM') + } catch (err) { + if (util.isStream(body)) { + util.destroy(body.on('error', noop), err) + } + throw err + } + + this.responseHeaders = responseHeaders || null + this.opaque = opaque || null + this.factory = factory + this.callback = callback + this.res = null + this.abort = null + this.context = null + this.trailers = null + this.body = body + this.onInfo = onInfo || null + + if (util.isStream(body)) { + body.on('error', (err) => { + this.onError(err) + }) + } + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (this.reason) { + abort(this.reason) + return + } + + assert(this.callback) + + this.abort = abort + this.context = context + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + const { factory, opaque, context, responseHeaders } = this + + const headers = responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + + if (statusCode < 200) { + if (this.onInfo) { + this.onInfo({ statusCode, headers }) + } + return + } + + this.factory = null + + if (factory === null) { + return + } + + const res = this.runInAsyncScope(factory, null, { + statusCode, + headers, + opaque, + context + }) + + if ( + !res || + typeof res.write !== 'function' || + typeof res.end !== 'function' || + typeof res.on !== 'function' + ) { + throw new InvalidReturnValueError('expected Writable') + } + + // TODO: Avoid finished. It registers an unnecessary amount of listeners. + finished(res, { readable: false }, (err) => { + const { callback, res, opaque, trailers, abort } = this + + this.res = null + if (err || !res.readable) { + util.destroy(res, err) + } + + this.callback = null + this.runInAsyncScope(callback, null, err || null, { opaque, trailers }) + + if (err) { + abort() + } + }) + + res.on('drain', resume) + + this.res = res + + const needDrain = res.writableNeedDrain !== undefined + ? res.writableNeedDrain + : res._writableState?.needDrain + + return needDrain !== true + } + + onData (chunk) { + const { res } = this + + return res ? res.write(chunk) : true + } + + onComplete (trailers) { + const { res } = this + + removeSignal(this) + + if (!res) { + return + } + + this.trailers = util.parseHeaders(trailers) + + res.end() + } + + onError (err) { + const { res, callback, opaque, body } = this + + removeSignal(this) + + this.factory = null + + if (res) { + this.res = null + util.destroy(res, err) + } else if (callback) { + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + + if (body) { + this.body = null + util.destroy(body, err) + } + } +} + +function stream (opts, factory, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + stream.call(this, opts, factory, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + const handler = new StreamHandler(opts, factory, callback) + + this.dispatch(opts, handler) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts?.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = stream diff --git a/lib/api/api-upgrade.js b/lib/api/api-upgrade.js new file mode 100644 index 0000000..f6efdc9 --- /dev/null +++ b/lib/api/api-upgrade.js @@ -0,0 +1,110 @@ +'use strict' + +const { InvalidArgumentError, SocketError } = require('../core/errors') +const { AsyncResource } = require('node:async_hooks') +const assert = require('node:assert') +const util = require('../core/util') +const { addSignal, removeSignal } = require('./abort-signal') + +class UpgradeHandler extends AsyncResource { + constructor (opts, callback) { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('invalid opts') + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + const { signal, opaque, responseHeaders } = opts + + if (signal && typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') { + throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget') + } + + super('UNDICI_UPGRADE') + + this.responseHeaders = responseHeaders || null + this.opaque = opaque || null + this.callback = callback + this.abort = null + this.context = null + + addSignal(this, signal) + } + + onConnect (abort, context) { + if (this.reason) { + abort(this.reason) + return + } + + assert(this.callback) + + this.abort = abort + this.context = null + } + + onHeaders () { + throw new SocketError('bad upgrade', null) + } + + onUpgrade (statusCode, rawHeaders, socket) { + assert(statusCode === 101) + + const { callback, opaque, context } = this + + removeSignal(this) + + this.callback = null + const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders) + this.runInAsyncScope(callback, null, null, { + headers, + socket, + opaque, + context + }) + } + + onError (err) { + const { callback, opaque } = this + + removeSignal(this) + + if (callback) { + this.callback = null + queueMicrotask(() => { + this.runInAsyncScope(callback, null, err, { opaque }) + }) + } + } +} + +function upgrade (opts, callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + upgrade.call(this, opts, (err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + try { + const upgradeHandler = new UpgradeHandler(opts, callback) + const upgradeOpts = { + ...opts, + method: opts.method || 'GET', + upgrade: opts.protocol || 'Websocket' + } + + this.dispatch(upgradeOpts, upgradeHandler) + } catch (err) { + if (typeof callback !== 'function') { + throw err + } + const opaque = opts?.opaque + queueMicrotask(() => callback(err, { opaque })) + } +} + +module.exports = upgrade diff --git a/lib/api/index.js b/lib/api/index.js new file mode 100644 index 0000000..8983a5e --- /dev/null +++ b/lib/api/index.js @@ -0,0 +1,7 @@ +'use strict' + +module.exports.request = require('./api-request') +module.exports.stream = require('./api-stream') +module.exports.pipeline = require('./api-pipeline') +module.exports.upgrade = require('./api-upgrade') +module.exports.connect = require('./api-connect') diff --git a/lib/api/readable.js b/lib/api/readable.js new file mode 100644 index 0000000..31a31ac --- /dev/null +++ b/lib/api/readable.js @@ -0,0 +1,558 @@ +// Ported from https://github.com/nodejs/undici/pull/907 + +'use strict' + +const assert = require('node:assert') +const { Readable } = require('node:stream') +const { RequestAbortedError, NotSupportedError, InvalidArgumentError, AbortError } = require('../core/errors') +const util = require('../core/util') +const { ReadableStreamFrom } = require('../core/util') + +const kConsume = Symbol('kConsume') +const kReading = Symbol('kReading') +const kBody = Symbol('kBody') +const kAbort = Symbol('kAbort') +const kContentType = Symbol('kContentType') +const kContentLength = Symbol('kContentLength') +const kUsed = Symbol('kUsed') +const kBytesRead = Symbol('kBytesRead') + +const noop = () => {} + +/** + * @class + * @extends {Readable} + * @see https://fetch.spec.whatwg.org/#body + */ +class BodyReadable extends Readable { + /** + * @param {object} opts + * @param {(this: Readable, size: number) => void} opts.resume + * @param {() => (void | null)} opts.abort + * @param {string} [opts.contentType = ''] + * @param {number} [opts.contentLength] + * @param {number} [opts.highWaterMark = 64 * 1024] + */ + constructor ({ + resume, + abort, + contentType = '', + contentLength, + highWaterMark = 64 * 1024 // Same as nodejs fs streams. + }) { + super({ + autoDestroy: true, + read: resume, + highWaterMark + }) + + this._readableState.dataEmitted = false + + this[kAbort] = abort + + /** + * @type {Consume | null} + */ + this[kConsume] = null + this[kBytesRead] = 0 + /** + * @type {ReadableStream|null} + */ + this[kBody] = null + this[kUsed] = false + this[kContentType] = contentType + this[kContentLength] = Number.isFinite(contentLength) ? contentLength : null + + // Is stream being consumed through Readable API? + // This is an optimization so that we avoid checking + // for 'data' and 'readable' listeners in the hot path + // inside push(). + this[kReading] = false + } + + /** + * @param {Error|null} err + * @param {(error:(Error|null)) => void} callback + * @returns {void} + */ + _destroy (err, callback) { + if (!err && !this._readableState.endEmitted) { + err = new RequestAbortedError() + } + + if (err) { + this[kAbort]() + } + + // Workaround for Node "bug". If the stream is destroyed in same + // tick as it is created, then a user who is waiting for a + // promise (i.e micro tick) for installing an 'error' listener will + // never get a chance and will always encounter an unhandled exception. + if (!this[kUsed]) { + setImmediate(() => { + callback(err) + }) + } else { + callback(err) + } + } + + /** + * @param {string} event + * @param {(...args: any[]) => void} listener + * @returns {this} + */ + on (event, listener) { + if (event === 'data' || event === 'readable') { + this[kReading] = true + this[kUsed] = true + } + return super.on(event, listener) + } + + /** + * @param {string} event + * @param {(...args: any[]) => void} listener + * @returns {this} + */ + addListener (event, listener) { + return this.on(event, listener) + } + + /** + * @param {string|symbol} event + * @param {(...args: any[]) => void} listener + * @returns {this} + */ + off (event, listener) { + const ret = super.off(event, listener) + if (event === 'data' || event === 'readable') { + this[kReading] = ( + this.listenerCount('data') > 0 || + this.listenerCount('readable') > 0 + ) + } + return ret + } + + /** + * @param {string|symbol} event + * @param {(...args: any[]) => void} listener + * @returns {this} + */ + removeListener (event, listener) { + return this.off(event, listener) + } + + /** + * @param {Buffer|null} chunk + * @returns {boolean} + */ + push (chunk) { + this[kBytesRead] += chunk ? chunk.length : 0 + + if (this[kConsume] && chunk !== null) { + consumePush(this[kConsume], chunk) + return this[kReading] ? super.push(chunk) : true + } + return super.push(chunk) + } + + /** + * Consumes and returns the body as a string. + * + * @see https://fetch.spec.whatwg.org/#dom-body-text + * @returns {Promise} + */ + text () { + return consume(this, 'text') + } + + /** + * Consumes and returns the body as a JavaScript Object. + * + * @see https://fetch.spec.whatwg.org/#dom-body-json + * @returns {Promise} + */ + json () { + return consume(this, 'json') + } + + /** + * Consumes and returns the body as a Blob + * + * @see https://fetch.spec.whatwg.org/#dom-body-blob + * @returns {Promise} + */ + blob () { + return consume(this, 'blob') + } + + /** + * Consumes and returns the body as an Uint8Array. + * + * @see https://fetch.spec.whatwg.org/#dom-body-bytes + * @returns {Promise} + */ + bytes () { + return consume(this, 'bytes') + } + + /** + * Consumes and returns the body as an ArrayBuffer. + * + * @see https://fetch.spec.whatwg.org/#dom-body-arraybuffer + * @returns {Promise} + */ + arrayBuffer () { + return consume(this, 'arrayBuffer') + } + + /** + * Not implemented + * + * @see https://fetch.spec.whatwg.org/#dom-body-formdata + * @throws {NotSupportedError} + */ + async formData () { + // TODO: Implement. + throw new NotSupportedError() + } + + /** + * Returns true if the body is not null and the body has been consumed. + * Otherwise, returns false. + * + * @see https://fetch.spec.whatwg.org/#dom-body-bodyused + * @readonly + * @returns {boolean} + */ + get bodyUsed () { + return util.isDisturbed(this) + } + + /** + * @see https://fetch.spec.whatwg.org/#dom-body-body + * @readonly + * @returns {ReadableStream} + */ + get body () { + if (!this[kBody]) { + this[kBody] = ReadableStreamFrom(this) + if (this[kConsume]) { + // TODO: Is this the best way to force a lock? + this[kBody].getReader() // Ensure stream is locked. + assert(this[kBody].locked) + } + } + return this[kBody] + } + + /** + * Dumps the response body by reading `limit` number of bytes. + * @param {object} opts + * @param {number} [opts.limit = 131072] Number of bytes to read. + * @param {AbortSignal} [opts.signal] An AbortSignal to cancel the dump. + * @returns {Promise} + */ + async dump (opts) { + const signal = opts?.signal + + if (signal != null && (typeof signal !== 'object' || !('aborted' in signal))) { + throw new InvalidArgumentError('signal must be an AbortSignal') + } + + const limit = opts?.limit && Number.isFinite(opts.limit) + ? opts.limit + : 128 * 1024 + + signal?.throwIfAborted() + + if (this._readableState.closeEmitted) { + return null + } + + return await new Promise((resolve, reject) => { + if ( + (this[kContentLength] && (this[kContentLength] > limit)) || + this[kBytesRead] > limit + ) { + this.destroy(new AbortError()) + } + + if (signal) { + const onAbort = () => { + this.destroy(signal.reason ?? new AbortError()) + } + signal.addEventListener('abort', onAbort) + this + .on('close', function () { + signal.removeEventListener('abort', onAbort) + if (signal.aborted) { + reject(signal.reason ?? new AbortError()) + } else { + resolve(null) + } + }) + } else { + this.on('close', resolve) + } + + this + .on('error', noop) + .on('data', () => { + if (this[kBytesRead] > limit) { + this.destroy() + } + }) + .resume() + }) + } + + /** + * @param {BufferEncoding} encoding + * @returns {this} + */ + setEncoding (encoding) { + if (Buffer.isEncoding(encoding)) { + this._readableState.encoding = encoding + } + return this + } +} + +/** + * @see https://streams.spec.whatwg.org/#readablestream-locked + * @param {BodyReadable} bodyReadable + * @returns {boolean} + */ +function isLocked (bodyReadable) { + // Consume is an implicit lock. + return bodyReadable[kBody]?.locked === true || bodyReadable[kConsume] !== null +} + +/** + * @see https://fetch.spec.whatwg.org/#body-unusable + * @param {BodyReadable} bodyReadable + * @returns {boolean} + */ +function isUnusable (bodyReadable) { + return util.isDisturbed(bodyReadable) || isLocked(bodyReadable) +} + +/** + * @typedef {object} Consume + * @property {string} type + * @property {BodyReadable} stream + * @property {((value?: any) => void)} resolve + * @property {((err: Error) => void)} reject + * @property {number} length + * @property {Buffer[]} body + */ + +/** + * @param {BodyReadable} stream + * @param {string} type + * @returns {Promise} + */ +function consume (stream, type) { + assert(!stream[kConsume]) + + return new Promise((resolve, reject) => { + if (isUnusable(stream)) { + const rState = stream._readableState + if (rState.destroyed && rState.closeEmitted === false) { + stream + .on('error', err => { + reject(err) + }) + .on('close', () => { + reject(new TypeError('unusable')) + }) + } else { + reject(rState.errored ?? new TypeError('unusable')) + } + } else { + queueMicrotask(() => { + stream[kConsume] = { + type, + stream, + resolve, + reject, + length: 0, + body: [] + } + + stream + .on('error', function (err) { + consumeFinish(this[kConsume], err) + }) + .on('close', function () { + if (this[kConsume].body !== null) { + consumeFinish(this[kConsume], new RequestAbortedError()) + } + }) + + consumeStart(stream[kConsume]) + }) + } + }) +} + +/** + * @param {Consume} consume + * @returns {void} + */ +function consumeStart (consume) { + if (consume.body === null) { + return + } + + const { _readableState: state } = consume.stream + + if (state.bufferIndex) { + const start = state.bufferIndex + const end = state.buffer.length + for (let n = start; n < end; n++) { + consumePush(consume, state.buffer[n]) + } + } else { + for (const chunk of state.buffer) { + consumePush(consume, chunk) + } + } + + if (state.endEmitted) { + consumeEnd(this[kConsume], this._readableState.encoding) + } else { + consume.stream.on('end', function () { + consumeEnd(this[kConsume], this._readableState.encoding) + }) + } + + consume.stream.resume() + + while (consume.stream.read() != null) { + // Loop + } +} + +/** + * @param {Buffer[]} chunks + * @param {number} length + * @param {BufferEncoding} encoding + * @returns {string} + */ +function chunksDecode (chunks, length, encoding) { + if (chunks.length === 0 || length === 0) { + return '' + } + const buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, length) + const bufferLength = buffer.length + + // Skip BOM. + const start = + bufferLength > 2 && + buffer[0] === 0xef && + buffer[1] === 0xbb && + buffer[2] === 0xbf + ? 3 + : 0 + if (!encoding || encoding === 'utf8' || encoding === 'utf-8') { + return buffer.utf8Slice(start, bufferLength) + } else { + return buffer.subarray(start, bufferLength).toString(encoding) + } +} + +/** + * @param {Buffer[]} chunks + * @param {number} length + * @returns {Uint8Array} + */ +function chunksConcat (chunks, length) { + if (chunks.length === 0 || length === 0) { + return new Uint8Array(0) + } + if (chunks.length === 1) { + // fast-path + return new Uint8Array(chunks[0]) + } + const buffer = new Uint8Array(Buffer.allocUnsafeSlow(length).buffer) + + let offset = 0 + for (let i = 0; i < chunks.length; ++i) { + const chunk = chunks[i] + buffer.set(chunk, offset) + offset += chunk.length + } + + return buffer +} + +/** + * @param {Consume} consume + * @param {BufferEncoding} encoding + * @returns {void} + */ +function consumeEnd (consume, encoding) { + const { type, body, resolve, stream, length } = consume + + try { + if (type === 'text') { + resolve(chunksDecode(body, length, encoding)) + } else if (type === 'json') { + resolve(JSON.parse(chunksDecode(body, length, encoding))) + } else if (type === 'arrayBuffer') { + resolve(chunksConcat(body, length).buffer) + } else if (type === 'blob') { + resolve(new Blob(body, { type: stream[kContentType] })) + } else if (type === 'bytes') { + resolve(chunksConcat(body, length)) + } + + consumeFinish(consume) + } catch (err) { + stream.destroy(err) + } +} + +/** + * @param {Consume} consume + * @param {Buffer} chunk + * @returns {void} + */ +function consumePush (consume, chunk) { + consume.length += chunk.length + consume.body.push(chunk) +} + +/** + * @param {Consume} consume + * @param {Error} [err] + * @returns {void} + */ +function consumeFinish (consume, err) { + if (consume.body === null) { + return + } + + if (err) { + consume.reject(err) + } else { + consume.resolve() + } + + // Reset the consume object to allow for garbage collection. + consume.type = null + consume.stream = null + consume.resolve = null + consume.reject = null + consume.length = 0 + consume.body = null +} + +module.exports = { + Readable: BodyReadable, + chunksDecode +} diff --git a/lib/api/util.js b/lib/api/util.js new file mode 100644 index 0000000..5512636 --- /dev/null +++ b/lib/api/util.js @@ -0,0 +1,95 @@ +'use strict' + +const assert = require('node:assert') +const { + ResponseStatusCodeError +} = require('../core/errors') + +const { chunksDecode } = require('./readable') +const CHUNK_LIMIT = 128 * 1024 + +async function getResolveErrorBodyCallback ({ callback, body, contentType, statusCode, statusMessage, headers }) { + assert(body) + + let chunks = [] + let length = 0 + + try { + for await (const chunk of body) { + chunks.push(chunk) + length += chunk.length + if (length > CHUNK_LIMIT) { + chunks = [] + length = 0 + break + } + } + } catch { + chunks = [] + length = 0 + // Do nothing.... + } + + const message = `Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}` + + if (statusCode === 204 || !contentType || !length) { + queueMicrotask(() => callback(new ResponseStatusCodeError(message, statusCode, headers))) + return + } + + const stackTraceLimit = Error.stackTraceLimit + Error.stackTraceLimit = 0 + let payload + + try { + if (isContentTypeApplicationJson(contentType)) { + payload = JSON.parse(chunksDecode(chunks, length)) + } else if (isContentTypeText(contentType)) { + payload = chunksDecode(chunks, length) + } + } catch { + // process in a callback to avoid throwing in the microtask queue + } finally { + Error.stackTraceLimit = stackTraceLimit + } + queueMicrotask(() => callback(new ResponseStatusCodeError(message, statusCode, headers, payload))) +} + +const isContentTypeApplicationJson = (contentType) => { + return ( + contentType.length > 15 && + contentType[11] === '/' && + contentType[0] === 'a' && + contentType[1] === 'p' && + contentType[2] === 'p' && + contentType[3] === 'l' && + contentType[4] === 'i' && + contentType[5] === 'c' && + contentType[6] === 'a' && + contentType[7] === 't' && + contentType[8] === 'i' && + contentType[9] === 'o' && + contentType[10] === 'n' && + contentType[12] === 'j' && + contentType[13] === 's' && + contentType[14] === 'o' && + contentType[15] === 'n' + ) +} + +const isContentTypeText = (contentType) => { + return ( + contentType.length > 4 && + contentType[4] === '/' && + contentType[0] === 't' && + contentType[1] === 'e' && + contentType[2] === 'x' && + contentType[3] === 't' + ) +} + +module.exports = { + getResolveErrorBodyCallback, + isContentTypeApplicationJson, + isContentTypeText +} diff --git a/lib/cache/memory-cache-store.js b/lib/cache/memory-cache-store.js new file mode 100644 index 0000000..dd5ac00 --- /dev/null +++ b/lib/cache/memory-cache-store.js @@ -0,0 +1,177 @@ +'use strict' + +const { Writable } = require('node:stream') +const { assertCacheKey, assertCacheValue } = require('../util/cache.js') + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheKey} CacheKey + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheValue} CacheValue + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore + * @typedef {import('../../types/cache-interceptor.d.ts').default.GetResult} GetResult + */ + +/** + * @implements {CacheStore} + */ +class MemoryCacheStore { + #maxCount = Infinity + #maxSize = Infinity + #maxEntrySize = Infinity + + #size = 0 + #count = 0 + #entries = new Map() + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts] + */ + constructor (opts) { + if (opts) { + if (typeof opts !== 'object') { + throw new TypeError('MemoryCacheStore options must be an object') + } + + if (opts.maxCount !== undefined) { + if ( + typeof opts.maxCount !== 'number' || + !Number.isInteger(opts.maxCount) || + opts.maxCount < 0 + ) { + throw new TypeError('MemoryCacheStore options.maxCount must be a non-negative integer') + } + this.#maxCount = opts.maxCount + } + + if (opts.maxSize !== undefined) { + if ( + typeof opts.maxSize !== 'number' || + !Number.isInteger(opts.maxSize) || + opts.maxSize < 0 + ) { + throw new TypeError('MemoryCacheStore options.maxSize must be a non-negative integer') + } + this.#maxSize = opts.maxSize + } + + if (opts.maxEntrySize !== undefined) { + if ( + typeof opts.maxEntrySize !== 'number' || + !Number.isInteger(opts.maxEntrySize) || + opts.maxEntrySize < 0 + ) { + throw new TypeError('MemoryCacheStore options.maxEntrySize must be a non-negative integer') + } + this.#maxEntrySize = opts.maxEntrySize + } + } + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req + * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} + */ + get (key) { + assertCacheKey(key) + + const topLevelKey = `${key.origin}:${key.path}` + + const now = Date.now() + const entry = this.#entries.get(topLevelKey)?.find((entry) => ( + entry.deleteAt > now && + entry.method === key.method && + (entry.vary == null || Object.keys(entry.vary).every(headerName => entry.vary[headerName] === key.headers?.[headerName])) + )) + + return entry == null + ? undefined + : { + statusMessage: entry.statusMessage, + statusCode: entry.statusCode, + headers: entry.headers, + body: entry.body, + vary: entry.vary ? entry.vary : undefined, + etag: entry.etag, + cacheControlDirectives: entry.cacheControlDirectives, + cachedAt: entry.cachedAt, + staleAt: entry.staleAt, + deleteAt: entry.deleteAt + } + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} val + * @returns {Writable | undefined} + */ + createWriteStream (key, val) { + assertCacheKey(key) + assertCacheValue(val) + + const topLevelKey = `${key.origin}:${key.path}` + + const store = this + const entry = { ...key, ...val, body: [], size: 0 } + + return new Writable({ + write (chunk, encoding, callback) { + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk, encoding) + } + + entry.size += chunk.byteLength + + if (entry.size >= store.#maxEntrySize) { + this.destroy() + } else { + entry.body.push(chunk) + } + + callback(null) + }, + final (callback) { + let entries = store.#entries.get(topLevelKey) + if (!entries) { + entries = [] + store.#entries.set(topLevelKey, entries) + } + entries.push(entry) + + store.#size += entry.size + store.#count += 1 + + if (store.#size > store.#maxSize || store.#count > store.#maxCount) { + for (const [key, entries] of store.#entries) { + for (const entry of entries.splice(0, entries.length / 2)) { + store.#size -= entry.size + store.#count -= 1 + } + if (entries.length === 0) { + store.#entries.delete(key) + } + } + } + + callback(null) + } + }) + } + + /** + * @param {CacheKey} key + */ + delete (key) { + if (typeof key !== 'object') { + throw new TypeError(`expected key to be object, got ${typeof key}`) + } + + const topLevelKey = `${key.origin}:${key.path}` + + for (const entry of this.#entries.get(topLevelKey) ?? []) { + this.#size -= entry.size + this.#count -= 1 + } + this.#entries.delete(topLevelKey) + } +} + +module.exports = MemoryCacheStore diff --git a/lib/cache/sqlite-cache-store.js b/lib/cache/sqlite-cache-store.js new file mode 100644 index 0000000..748016f --- /dev/null +++ b/lib/cache/sqlite-cache-store.js @@ -0,0 +1,458 @@ +'use strict' + +const { Writable } = require('stream') +const { assertCacheKey, assertCacheValue } = require('../util/cache.js') + +let DatabaseSync + +const VERSION = 3 + +// 2gb +const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000 + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore + * @implements {CacheStore} + * + * @typedef {{ + * id: Readonly, + * body?: Uint8Array + * statusCode: number + * statusMessage: string + * headers?: string + * vary?: string + * etag?: string + * cacheControlDirectives?: string + * cachedAt: number + * staleAt: number + * deleteAt: number + * }} SqliteStoreValue + */ +module.exports = class SqliteCacheStore { + #maxEntrySize = MAX_ENTRY_SIZE + #maxCount = Infinity + + /** + * @type {import('node:sqlite').DatabaseSync} + */ + #db + + /** + * @type {import('node:sqlite').StatementSync} + */ + #getValuesQuery + + /** + * @type {import('node:sqlite').StatementSync} + */ + #updateValueQuery + + /** + * @type {import('node:sqlite').StatementSync} + */ + #insertValueQuery + + /** + * @type {import('node:sqlite').StatementSync} + */ + #deleteExpiredValuesQuery + + /** + * @type {import('node:sqlite').StatementSync} + */ + #deleteByUrlQuery + + /** + * @type {import('node:sqlite').StatementSync} + */ + #countEntriesQuery + + /** + * @type {import('node:sqlite').StatementSync | null} + */ + #deleteOldValuesQuery + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts | undefined} opts + */ + constructor (opts) { + if (opts) { + if (typeof opts !== 'object') { + throw new TypeError('SqliteCacheStore options must be an object') + } + + if (opts.maxEntrySize !== undefined) { + if ( + typeof opts.maxEntrySize !== 'number' || + !Number.isInteger(opts.maxEntrySize) || + opts.maxEntrySize < 0 + ) { + throw new TypeError('SqliteCacheStore options.maxEntrySize must be a non-negative integer') + } + + if (opts.maxEntrySize > MAX_ENTRY_SIZE) { + throw new TypeError('SqliteCacheStore options.maxEntrySize must be less than 2gb') + } + + this.#maxEntrySize = opts.maxEntrySize + } + + if (opts.maxCount !== undefined) { + if ( + typeof opts.maxCount !== 'number' || + !Number.isInteger(opts.maxCount) || + opts.maxCount < 0 + ) { + throw new TypeError('SqliteCacheStore options.maxCount must be a non-negative integer') + } + this.#maxCount = opts.maxCount + } + } + + if (!DatabaseSync) { + DatabaseSync = require('node:sqlite').DatabaseSync + } + this.#db = new DatabaseSync(opts?.location ?? ':memory:') + + this.#db.exec(` + CREATE TABLE IF NOT EXISTS cacheInterceptorV${VERSION} ( + -- Data specific to us + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + method TEXT NOT NULL, + + -- Data returned to the interceptor + body BUF NULL, + deleteAt INTEGER NOT NULL, + statusCode INTEGER NOT NULL, + statusMessage TEXT NOT NULL, + headers TEXT NULL, + cacheControlDirectives TEXT NULL, + etag TEXT NULL, + vary TEXT NULL, + cachedAt INTEGER NOT NULL, + staleAt INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_url ON cacheInterceptorV${VERSION}(url); + CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_method ON cacheInterceptorV${VERSION}(method); + CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteAt ON cacheInterceptorV${VERSION}(deleteAt); + `) + + this.#getValuesQuery = this.#db.prepare(` + SELECT + id, + body, + deleteAt, + statusCode, + statusMessage, + headers, + etag, + cacheControlDirectives, + vary, + cachedAt, + staleAt + FROM cacheInterceptorV${VERSION} + WHERE + url = ? + AND method = ? + ORDER BY + deleteAt ASC + `) + + this.#updateValueQuery = this.#db.prepare(` + UPDATE cacheInterceptorV${VERSION} SET + body = ?, + deleteAt = ?, + statusCode = ?, + statusMessage = ?, + headers = ?, + etag = ?, + cacheControlDirectives = ?, + cachedAt = ?, + staleAt = ? + WHERE + id = ? + `) + + this.#insertValueQuery = this.#db.prepare(` + INSERT INTO cacheInterceptorV${VERSION} ( + url, + method, + body, + deleteAt, + statusCode, + statusMessage, + headers, + etag, + cacheControlDirectives, + vary, + cachedAt, + staleAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + this.#deleteByUrlQuery = this.#db.prepare( + `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?` + ) + + this.#countEntriesQuery = this.#db.prepare( + `SELECT COUNT(*) AS total FROM cacheInterceptorV${VERSION}` + ) + + this.#deleteExpiredValuesQuery = this.#db.prepare( + `DELETE FROM cacheInterceptorV${VERSION} WHERE deleteAt <= ?` + ) + + this.#deleteOldValuesQuery = this.#maxCount === Infinity + ? null + : this.#db.prepare(` + DELETE FROM cacheInterceptorV${VERSION} + WHERE id IN ( + SELECT + id + FROM cacheInterceptorV${VERSION} + ORDER BY cachedAt DESC + LIMIT ? + ) + `) + } + + close () { + this.#db.close() + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @returns {(import('../../types/cache-interceptor.d.ts').default.GetResult & { body?: Buffer }) | undefined} + */ + get (key) { + assertCacheKey(key) + + const value = this.#findValue(key) + return value + ? { + body: value.body ? Buffer.from(value.body.buffer) : undefined, + statusCode: value.statusCode, + statusMessage: value.statusMessage, + headers: value.headers ? JSON.parse(value.headers) : undefined, + etag: value.etag ? value.etag : undefined, + vary: value.vary ? JSON.parse(value.vary) : undefined, + cacheControlDirectives: value.cacheControlDirectives + ? JSON.parse(value.cacheControlDirectives) + : undefined, + cachedAt: value.cachedAt, + staleAt: value.staleAt, + deleteAt: value.deleteAt + } + : undefined + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: null | Buffer | Array}} value + */ + set (key, value) { + assertCacheKey(key) + + const url = this.#makeValueUrl(key) + const body = Array.isArray(value.body) ? Buffer.concat(value.body) : value.body + const size = body?.byteLength + + if (size && size > this.#maxEntrySize) { + return + } + + const existingValue = this.#findValue(key, true) + if (existingValue) { + // Updating an existing response, let's overwrite it + this.#updateValueQuery.run( + body, + value.deleteAt, + value.statusCode, + value.statusMessage, + value.headers ? JSON.stringify(value.headers) : null, + value.etag ? value.etag : null, + value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null, + value.cachedAt, + value.staleAt, + existingValue.id + ) + } else { + this.#prune() + // New response, let's insert it + this.#insertValueQuery.run( + url, + key.method, + body, + value.deleteAt, + value.statusCode, + value.statusMessage, + value.headers ? JSON.stringify(value.headers) : null, + value.etag ? value.etag : null, + value.cacheControlDirectives ? JSON.stringify(value.cacheControlDirectives) : null, + value.vary ? JSON.stringify(value.vary) : null, + value.cachedAt, + value.staleAt + ) + } + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} value + * @returns {Writable | undefined} + */ + createWriteStream (key, value) { + assertCacheKey(key) + assertCacheValue(value) + + let size = 0 + /** + * @type {Buffer[] | null} + */ + const body = [] + const store = this + + return new Writable({ + decodeStrings: true, + write (chunk, encoding, callback) { + size += chunk.byteLength + + if (size < store.#maxEntrySize) { + body.push(chunk) + } else { + this.destroy() + } + + callback() + }, + final (callback) { + store.set(key, { ...value, body }) + callback() + } + }) + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + */ + delete (key) { + if (typeof key !== 'object') { + throw new TypeError(`expected key to be object, got ${typeof key}`) + } + + this.#deleteByUrlQuery.run(this.#makeValueUrl(key)) + } + + #prune () { + if (this.size <= this.#maxCount) { + return 0 + } + + { + const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes + if (removed) { + return removed + } + } + + { + const removed = this.#deleteOldValuesQuery?.run(Math.max(Math.floor(this.#maxCount * 0.1), 1)).changes + if (removed) { + return removed + } + } + + return 0 + } + + /** + * Counts the number of rows in the cache + * @returns {Number} + */ + get size () { + const { total } = this.#countEntriesQuery.get() + return total + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @returns {string} + */ + #makeValueUrl (key) { + return `${key.origin}/${key.path}` + } + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} key + * @param {boolean} [canBeExpired=false] + * @returns {SqliteStoreValue | undefined} + */ + #findValue (key, canBeExpired = false) { + const url = this.#makeValueUrl(key) + const { headers, method } = key + + /** + * @type {SqliteStoreValue[]} + */ + const values = this.#getValuesQuery.all(url, method) + + if (values.length === 0) { + return undefined + } + + const now = Date.now() + for (const value of values) { + if (now >= value.deleteAt && !canBeExpired) { + return undefined + } + + let matches = true + + if (value.vary) { + if (!headers) { + return undefined + } + + const vary = JSON.parse(value.vary) + + for (const header in vary) { + if (!headerValueEquals(headers[header], vary[header])) { + matches = false + break + } + } + } + + if (matches) { + return value + } + } + + return undefined + } +} + +/** + * @param {string|string[]|null|undefined} lhs + * @param {string|string[]|null|undefined} rhs + * @returns {boolean} + */ +function headerValueEquals (lhs, rhs) { + if (Array.isArray(lhs) && Array.isArray(rhs)) { + if (lhs.length !== rhs.length) { + return false + } + + for (let i = 0; i < lhs.length; i++) { + if (rhs.includes(lhs[i])) { + return false + } + } + + return true + } + + return lhs === rhs +} diff --git a/lib/core/connect.js b/lib/core/connect.js new file mode 100644 index 0000000..8cd8abc --- /dev/null +++ b/lib/core/connect.js @@ -0,0 +1,240 @@ +'use strict' + +const net = require('node:net') +const assert = require('node:assert') +const util = require('./util') +const { InvalidArgumentError, ConnectTimeoutError } = require('./errors') +const timers = require('../util/timers') + +function noop () {} + +let tls // include tls conditionally since it is not always available + +// TODO: session re-use does not wait for the first +// connection to resolve the session and might therefore +// resolve the same servername multiple times even when +// re-use is enabled. + +let SessionCache +// FIXME: remove workaround when the Node bug is fixed +// https://github.com/nodejs/node/issues/49344#issuecomment-1741776308 +if (global.FinalizationRegistry && !(process.env.NODE_V8_COVERAGE || process.env.UNDICI_NO_FG)) { + SessionCache = class WeakSessionCache { + constructor (maxCachedSessions) { + this._maxCachedSessions = maxCachedSessions + this._sessionCache = new Map() + this._sessionRegistry = new global.FinalizationRegistry((key) => { + if (this._sessionCache.size < this._maxCachedSessions) { + return + } + + const ref = this._sessionCache.get(key) + if (ref !== undefined && ref.deref() === undefined) { + this._sessionCache.delete(key) + } + }) + } + + get (sessionKey) { + const ref = this._sessionCache.get(sessionKey) + return ref ? ref.deref() : null + } + + set (sessionKey, session) { + if (this._maxCachedSessions === 0) { + return + } + + this._sessionCache.set(sessionKey, new WeakRef(session)) + this._sessionRegistry.register(session, sessionKey) + } + } +} else { + SessionCache = class SimpleSessionCache { + constructor (maxCachedSessions) { + this._maxCachedSessions = maxCachedSessions + this._sessionCache = new Map() + } + + get (sessionKey) { + return this._sessionCache.get(sessionKey) + } + + set (sessionKey, session) { + if (this._maxCachedSessions === 0) { + return + } + + if (this._sessionCache.size >= this._maxCachedSessions) { + // remove the oldest session + const { value: oldestKey } = this._sessionCache.keys().next() + this._sessionCache.delete(oldestKey) + } + + this._sessionCache.set(sessionKey, session) + } + } +} + +function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) { + if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) { + throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero') + } + + const options = { path: socketPath, ...opts } + const sessionCache = new SessionCache(maxCachedSessions == null ? 100 : maxCachedSessions) + timeout = timeout == null ? 10e3 : timeout + allowH2 = allowH2 != null ? allowH2 : false + return function connect ({ hostname, host, protocol, port, servername, localAddress, httpSocket }, callback) { + let socket + if (protocol === 'https:') { + if (!tls) { + tls = require('node:tls') + } + servername = servername || options.servername || util.getServerName(host) || null + + const sessionKey = servername || hostname + assert(sessionKey) + + const session = customSession || sessionCache.get(sessionKey) || null + + port = port || 443 + + socket = tls.connect({ + highWaterMark: 16384, // TLS in node can't have bigger HWM anyway... + ...options, + servername, + session, + localAddress, + // TODO(HTTP/2): Add support for h2c + ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'], + socket: httpSocket, // upgrade socket connection + port, + host: hostname + }) + + socket + .on('session', function (session) { + // TODO (fix): Can a session become invalid once established? Don't think so? + sessionCache.set(sessionKey, session) + }) + } else { + assert(!httpSocket, 'httpSocket can only be sent on TLS update') + + port = port || 80 + + socket = net.connect({ + highWaterMark: 64 * 1024, // Same as nodejs fs streams. + ...options, + localAddress, + port, + host: hostname + }) + } + + // Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket + if (options.keepAlive == null || options.keepAlive) { + const keepAliveInitialDelay = options.keepAliveInitialDelay === undefined ? 60e3 : options.keepAliveInitialDelay + socket.setKeepAlive(true, keepAliveInitialDelay) + } + + const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port }) + + socket + .setNoDelay(true) + .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () { + queueMicrotask(clearConnectTimeout) + + if (callback) { + const cb = callback + callback = null + cb(null, this) + } + }) + .on('error', function (err) { + queueMicrotask(clearConnectTimeout) + + if (callback) { + const cb = callback + callback = null + cb(err) + } + }) + + return socket + } +} + +/** + * @param {WeakRef} socketWeakRef + * @param {object} opts + * @param {number} opts.timeout + * @param {string} opts.hostname + * @param {number} opts.port + * @returns {() => void} + */ +const setupConnectTimeout = process.platform === 'win32' + ? (socketWeakRef, opts) => { + if (!opts.timeout) { + return noop + } + + let s1 = null + let s2 = null + const fastTimer = timers.setFastTimeout(() => { + // setImmediate is added to make sure that we prioritize socket error events over timeouts + s1 = setImmediate(() => { + // Windows needs an extra setImmediate probably due to implementation differences in the socket logic + s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts)) + }) + }, opts.timeout) + return () => { + timers.clearFastTimeout(fastTimer) + clearImmediate(s1) + clearImmediate(s2) + } + } + : (socketWeakRef, opts) => { + if (!opts.timeout) { + return noop + } + + let s1 = null + const fastTimer = timers.setFastTimeout(() => { + // setImmediate is added to make sure that we prioritize socket error events over timeouts + s1 = setImmediate(() => { + onConnectTimeout(socketWeakRef.deref(), opts) + }) + }, opts.timeout) + return () => { + timers.clearFastTimeout(fastTimer) + clearImmediate(s1) + } + } + +/** + * @param {net.Socket} socket + * @param {object} opts + * @param {number} opts.timeout + * @param {string} opts.hostname + * @param {number} opts.port + */ +function onConnectTimeout (socket, opts) { + // The socket could be already garbage collected + if (socket == null) { + return + } + + let message = 'Connect Timeout Error' + if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) { + message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},` + } else { + message += ` (attempted address: ${opts.hostname}:${opts.port},` + } + + message += ` timeout: ${opts.timeout}ms)` + + util.destroy(socket, new ConnectTimeoutError(message)) +} + +module.exports = buildConnector diff --git a/lib/core/constants.js b/lib/core/constants.js new file mode 100644 index 0000000..088cf47 --- /dev/null +++ b/lib/core/constants.js @@ -0,0 +1,143 @@ +'use strict' + +/** + * @see https://developer.mozilla.org/docs/Web/HTTP/Headers + */ +const wellknownHeaderNames = /** @type {const} */ ([ + 'Accept', + 'Accept-Encoding', + 'Accept-Language', + 'Accept-Ranges', + 'Access-Control-Allow-Credentials', + 'Access-Control-Allow-Headers', + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Origin', + 'Access-Control-Expose-Headers', + 'Access-Control-Max-Age', + 'Access-Control-Request-Headers', + 'Access-Control-Request-Method', + 'Age', + 'Allow', + 'Alt-Svc', + 'Alt-Used', + 'Authorization', + 'Cache-Control', + 'Clear-Site-Data', + 'Connection', + 'Content-Disposition', + 'Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-Location', + 'Content-Range', + 'Content-Security-Policy', + 'Content-Security-Policy-Report-Only', + 'Content-Type', + 'Cookie', + 'Cross-Origin-Embedder-Policy', + 'Cross-Origin-Opener-Policy', + 'Cross-Origin-Resource-Policy', + 'Date', + 'Device-Memory', + 'Downlink', + 'ECT', + 'ETag', + 'Expect', + 'Expect-CT', + 'Expires', + 'Forwarded', + 'From', + 'Host', + 'If-Match', + 'If-Modified-Since', + 'If-None-Match', + 'If-Range', + 'If-Unmodified-Since', + 'Keep-Alive', + 'Last-Modified', + 'Link', + 'Location', + 'Max-Forwards', + 'Origin', + 'Permissions-Policy', + 'Pragma', + 'Proxy-Authenticate', + 'Proxy-Authorization', + 'RTT', + 'Range', + 'Referer', + 'Referrer-Policy', + 'Refresh', + 'Retry-After', + 'Sec-WebSocket-Accept', + 'Sec-WebSocket-Extensions', + 'Sec-WebSocket-Key', + 'Sec-WebSocket-Protocol', + 'Sec-WebSocket-Version', + 'Server', + 'Server-Timing', + 'Service-Worker-Allowed', + 'Service-Worker-Navigation-Preload', + 'Set-Cookie', + 'SourceMap', + 'Strict-Transport-Security', + 'Supports-Loading-Mode', + 'TE', + 'Timing-Allow-Origin', + 'Trailer', + 'Transfer-Encoding', + 'Upgrade', + 'Upgrade-Insecure-Requests', + 'User-Agent', + 'Vary', + 'Via', + 'WWW-Authenticate', + 'X-Content-Type-Options', + 'X-DNS-Prefetch-Control', + 'X-Frame-Options', + 'X-Permitted-Cross-Domain-Policies', + 'X-Powered-By', + 'X-Requested-With', + 'X-XSS-Protection' +]) + +/** @type {Record, string>} */ +const headerNameLowerCasedRecord = {} + +// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. +Object.setPrototypeOf(headerNameLowerCasedRecord, null) + +/** + * @type {Record, Buffer>} + */ +const wellknownHeaderNameBuffers = {} + +// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. +Object.setPrototypeOf(wellknownHeaderNameBuffers, null) + +/** + * @param {string} header Lowercased header + * @returns {Buffer} + */ +function getHeaderNameAsBuffer (header) { + let buffer = wellknownHeaderNameBuffers[header] + + if (buffer === undefined) { + buffer = Buffer.from(header) + } + + return buffer +} + +for (let i = 0; i < wellknownHeaderNames.length; ++i) { + const key = wellknownHeaderNames[i] + const lowerCasedKey = key.toLowerCase() + headerNameLowerCasedRecord[key] = headerNameLowerCasedRecord[lowerCasedKey] = + lowerCasedKey +} + +module.exports = { + wellknownHeaderNames, + headerNameLowerCasedRecord, + getHeaderNameAsBuffer +} diff --git a/lib/core/diagnostics.js b/lib/core/diagnostics.js new file mode 100644 index 0000000..44c168e --- /dev/null +++ b/lib/core/diagnostics.js @@ -0,0 +1,196 @@ +'use strict' + +const diagnosticsChannel = require('node:diagnostics_channel') +const util = require('node:util') + +const undiciDebugLog = util.debuglog('undici') +const fetchDebuglog = util.debuglog('fetch') +const websocketDebuglog = util.debuglog('websocket') + +const channels = { + // Client + beforeConnect: diagnosticsChannel.channel('undici:client:beforeConnect'), + connected: diagnosticsChannel.channel('undici:client:connected'), + connectError: diagnosticsChannel.channel('undici:client:connectError'), + sendHeaders: diagnosticsChannel.channel('undici:client:sendHeaders'), + // Request + create: diagnosticsChannel.channel('undici:request:create'), + bodySent: diagnosticsChannel.channel('undici:request:bodySent'), + headers: diagnosticsChannel.channel('undici:request:headers'), + trailers: diagnosticsChannel.channel('undici:request:trailers'), + error: diagnosticsChannel.channel('undici:request:error'), + // WebSocket + open: diagnosticsChannel.channel('undici:websocket:open'), + close: diagnosticsChannel.channel('undici:websocket:close'), + socketError: diagnosticsChannel.channel('undici:websocket:socket_error'), + ping: diagnosticsChannel.channel('undici:websocket:ping'), + pong: diagnosticsChannel.channel('undici:websocket:pong') +} + +let isTrackingClientEvents = false + +function trackClientEvents (debugLog = undiciDebugLog) { + if (isTrackingClientEvents) { + return + } + + isTrackingClientEvents = true + + diagnosticsChannel.subscribe('undici:client:beforeConnect', + evt => { + const { + connectParams: { version, protocol, port, host } + } = evt + debugLog( + 'connecting to %s%s using %s%s', + host, + port ? `:${port}` : '', + protocol, + version + ) + }) + + diagnosticsChannel.subscribe('undici:client:connected', + evt => { + const { + connectParams: { version, protocol, port, host } + } = evt + debugLog( + 'connected to %s%s using %s%s', + host, + port ? `:${port}` : '', + protocol, + version + ) + }) + + diagnosticsChannel.subscribe('undici:client:connectError', + evt => { + const { + connectParams: { version, protocol, port, host }, + error + } = evt + debugLog( + 'connection to %s%s using %s%s errored - %s', + host, + port ? `:${port}` : '', + protocol, + version, + error.message + ) + }) + + diagnosticsChannel.subscribe('undici:client:sendHeaders', + evt => { + const { + request: { method, path, origin } + } = evt + debugLog('sending request to %s %s/%s', method, origin, path) + }) +} + +let isTrackingRequestEvents = false + +function trackRequestEvents (debugLog = undiciDebugLog) { + if (isTrackingRequestEvents) { + return + } + + isTrackingRequestEvents = true + + diagnosticsChannel.subscribe('undici:request:headers', + evt => { + const { + request: { method, path, origin }, + response: { statusCode } + } = evt + debugLog( + 'received response to %s %s/%s - HTTP %d', + method, + origin, + path, + statusCode + ) + }) + + diagnosticsChannel.subscribe('undici:request:trailers', + evt => { + const { + request: { method, path, origin } + } = evt + debugLog('trailers received from %s %s/%s', method, origin, path) + }) + + diagnosticsChannel.subscribe('undici:request:error', + evt => { + const { + request: { method, path, origin }, + error + } = evt + debugLog( + 'request to %s %s/%s errored - %s', + method, + origin, + path, + error.message + ) + }) +} + +let isTrackingWebSocketEvents = false + +function trackWebSocketEvents (debugLog = websocketDebuglog) { + if (isTrackingWebSocketEvents) { + return + } + + isTrackingWebSocketEvents = true + + diagnosticsChannel.subscribe('undici:websocket:open', + evt => { + const { + address: { address, port } + } = evt + debugLog('connection opened %s%s', address, port ? `:${port}` : '') + }) + + diagnosticsChannel.subscribe('undici:websocket:close', + evt => { + const { websocket, code, reason } = evt + debugLog( + 'closed connection to %s - %s %s', + websocket.url, + code, + reason + ) + }) + + diagnosticsChannel.subscribe('undici:websocket:socket_error', + err => { + debugLog('connection errored - %s', err.message) + }) + + diagnosticsChannel.subscribe('undici:websocket:ping', + evt => { + debugLog('ping received') + }) + + diagnosticsChannel.subscribe('undici:websocket:pong', + evt => { + debugLog('pong received') + }) +} + +if (undiciDebugLog.enabled || fetchDebuglog.enabled) { + trackClientEvents(fetchDebuglog.enabled ? fetchDebuglog : undiciDebugLog) + trackRequestEvents(fetchDebuglog.enabled ? fetchDebuglog : undiciDebugLog) +} + +if (websocketDebuglog.enabled) { + trackClientEvents(undiciDebugLog.enabled ? undiciDebugLog : websocketDebuglog) + trackWebSocketEvents(websocketDebuglog) +} + +module.exports = { + channels +} diff --git a/lib/core/errors.js b/lib/core/errors.js new file mode 100644 index 0000000..b2b3f32 --- /dev/null +++ b/lib/core/errors.js @@ -0,0 +1,244 @@ +'use strict' + +class UndiciError extends Error { + constructor (message, options) { + super(message, options) + this.name = 'UndiciError' + this.code = 'UND_ERR' + } +} + +class ConnectTimeoutError extends UndiciError { + constructor (message) { + super(message) + this.name = 'ConnectTimeoutError' + this.message = message || 'Connect Timeout Error' + this.code = 'UND_ERR_CONNECT_TIMEOUT' + } +} + +class HeadersTimeoutError extends UndiciError { + constructor (message) { + super(message) + this.name = 'HeadersTimeoutError' + this.message = message || 'Headers Timeout Error' + this.code = 'UND_ERR_HEADERS_TIMEOUT' + } +} + +class HeadersOverflowError extends UndiciError { + constructor (message) { + super(message) + this.name = 'HeadersOverflowError' + this.message = message || 'Headers Overflow Error' + this.code = 'UND_ERR_HEADERS_OVERFLOW' + } +} + +class BodyTimeoutError extends UndiciError { + constructor (message) { + super(message) + this.name = 'BodyTimeoutError' + this.message = message || 'Body Timeout Error' + this.code = 'UND_ERR_BODY_TIMEOUT' + } +} + +class ResponseStatusCodeError extends UndiciError { + constructor (message, statusCode, headers, body) { + super(message) + this.name = 'ResponseStatusCodeError' + this.message = message || 'Response Status Code Error' + this.code = 'UND_ERR_RESPONSE_STATUS_CODE' + this.body = body + this.status = statusCode + this.statusCode = statusCode + this.headers = headers + } +} + +class InvalidArgumentError extends UndiciError { + constructor (message) { + super(message) + this.name = 'InvalidArgumentError' + this.message = message || 'Invalid Argument Error' + this.code = 'UND_ERR_INVALID_ARG' + } +} + +class InvalidReturnValueError extends UndiciError { + constructor (message) { + super(message) + this.name = 'InvalidReturnValueError' + this.message = message || 'Invalid Return Value Error' + this.code = 'UND_ERR_INVALID_RETURN_VALUE' + } +} + +class AbortError extends UndiciError { + constructor (message) { + super(message) + this.name = 'AbortError' + this.message = message || 'The operation was aborted' + } +} + +class RequestAbortedError extends AbortError { + constructor (message) { + super(message) + this.name = 'AbortError' + this.message = message || 'Request aborted' + this.code = 'UND_ERR_ABORTED' + } +} + +class InformationalError extends UndiciError { + constructor (message) { + super(message) + this.name = 'InformationalError' + this.message = message || 'Request information' + this.code = 'UND_ERR_INFO' + } +} + +class RequestContentLengthMismatchError extends UndiciError { + constructor (message) { + super(message) + this.name = 'RequestContentLengthMismatchError' + this.message = message || 'Request body length does not match content-length header' + this.code = 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH' + } +} + +class ResponseContentLengthMismatchError extends UndiciError { + constructor (message) { + super(message) + this.name = 'ResponseContentLengthMismatchError' + this.message = message || 'Response body length does not match content-length header' + this.code = 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH' + } +} + +class ClientDestroyedError extends UndiciError { + constructor (message) { + super(message) + this.name = 'ClientDestroyedError' + this.message = message || 'The client is destroyed' + this.code = 'UND_ERR_DESTROYED' + } +} + +class ClientClosedError extends UndiciError { + constructor (message) { + super(message) + this.name = 'ClientClosedError' + this.message = message || 'The client is closed' + this.code = 'UND_ERR_CLOSED' + } +} + +class SocketError extends UndiciError { + constructor (message, socket) { + super(message) + this.name = 'SocketError' + this.message = message || 'Socket error' + this.code = 'UND_ERR_SOCKET' + this.socket = socket + } +} + +class NotSupportedError extends UndiciError { + constructor (message) { + super(message) + this.name = 'NotSupportedError' + this.message = message || 'Not supported error' + this.code = 'UND_ERR_NOT_SUPPORTED' + } +} + +class BalancedPoolMissingUpstreamError extends UndiciError { + constructor (message) { + super(message) + this.name = 'MissingUpstreamError' + this.message = message || 'No upstream has been added to the BalancedPool' + this.code = 'UND_ERR_BPL_MISSING_UPSTREAM' + } +} + +class HTTPParserError extends Error { + constructor (message, code, data) { + super(message) + this.name = 'HTTPParserError' + this.code = code ? `HPE_${code}` : undefined + this.data = data ? data.toString() : undefined + } +} + +class ResponseExceededMaxSizeError extends UndiciError { + constructor (message) { + super(message) + this.name = 'ResponseExceededMaxSizeError' + this.message = message || 'Response content exceeded max size' + this.code = 'UND_ERR_RES_EXCEEDED_MAX_SIZE' + } +} + +class RequestRetryError extends UndiciError { + constructor (message, code, { headers, data }) { + super(message) + this.name = 'RequestRetryError' + this.message = message || 'Request retry error' + this.code = 'UND_ERR_REQ_RETRY' + this.statusCode = code + this.data = data + this.headers = headers + } +} + +class ResponseError extends UndiciError { + constructor (message, code, { headers, body }) { + super(message) + this.name = 'ResponseError' + this.message = message || 'Response error' + this.code = 'UND_ERR_RESPONSE' + this.statusCode = code + this.body = body + this.headers = headers + } +} + +class SecureProxyConnectionError extends UndiciError { + constructor (cause, message, options = {}) { + super(message, { cause, ...options }) + this.name = 'SecureProxyConnectionError' + this.message = message || 'Secure Proxy Connection failed' + this.code = 'UND_ERR_PRX_TLS' + this.cause = cause + } +} + +module.exports = { + AbortError, + HTTPParserError, + UndiciError, + HeadersTimeoutError, + HeadersOverflowError, + BodyTimeoutError, + RequestContentLengthMismatchError, + ConnectTimeoutError, + ResponseStatusCodeError, + InvalidArgumentError, + InvalidReturnValueError, + RequestAbortedError, + ClientDestroyedError, + ClientClosedError, + InformationalError, + SocketError, + NotSupportedError, + ResponseContentLengthMismatchError, + BalancedPoolMissingUpstreamError, + ResponseExceededMaxSizeError, + RequestRetryError, + ResponseError, + SecureProxyConnectionError +} diff --git a/lib/core/request.js b/lib/core/request.js new file mode 100644 index 0000000..a8680b5 --- /dev/null +++ b/lib/core/request.js @@ -0,0 +1,397 @@ +'use strict' + +const { + InvalidArgumentError, + NotSupportedError +} = require('./errors') +const assert = require('node:assert') +const { + isValidHTTPToken, + isValidHeaderValue, + isStream, + destroy, + isBuffer, + isFormDataLike, + isIterable, + isBlobLike, + serializePathWithQuery, + assertRequestHandler, + getServerName, + normalizedMethodRecords +} = require('./util') +const { channels } = require('./diagnostics.js') +const { headerNameLowerCasedRecord } = require('./constants') + +// Verifies that a given path is valid does not contain control chars \x00 to \x20 +const invalidPathRegex = /[^\u0021-\u00ff]/ + +const kHandler = Symbol('handler') + +class Request { + constructor (origin, { + path, + method, + body, + headers, + query, + idempotent, + blocking, + upgrade, + headersTimeout, + bodyTimeout, + reset, + expectContinue, + servername, + throwOnError + }, handler) { + if (typeof path !== 'string') { + throw new InvalidArgumentError('path must be a string') + } else if ( + path[0] !== '/' && + !(path.startsWith('http://') || path.startsWith('https://')) && + method !== 'CONNECT' + ) { + throw new InvalidArgumentError('path must be an absolute URL or start with a slash') + } else if (invalidPathRegex.test(path)) { + throw new InvalidArgumentError('invalid request path') + } + + if (typeof method !== 'string') { + throw new InvalidArgumentError('method must be a string') + } else if (normalizedMethodRecords[method] === undefined && !isValidHTTPToken(method)) { + throw new InvalidArgumentError('invalid request method') + } + + if (upgrade && typeof upgrade !== 'string') { + throw new InvalidArgumentError('upgrade must be a string') + } + + if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) { + throw new InvalidArgumentError('invalid headersTimeout') + } + + if (bodyTimeout != null && (!Number.isFinite(bodyTimeout) || bodyTimeout < 0)) { + throw new InvalidArgumentError('invalid bodyTimeout') + } + + if (reset != null && typeof reset !== 'boolean') { + throw new InvalidArgumentError('invalid reset') + } + + if (expectContinue != null && typeof expectContinue !== 'boolean') { + throw new InvalidArgumentError('invalid expectContinue') + } + + if (throwOnError != null) { + throw new InvalidArgumentError('invalid throwOnError') + } + + this.headersTimeout = headersTimeout + + this.bodyTimeout = bodyTimeout + + this.method = method + + this.abort = null + + if (body == null) { + this.body = null + } else if (isStream(body)) { + this.body = body + + const rState = this.body._readableState + if (!rState || !rState.autoDestroy) { + this.endHandler = function autoDestroy () { + destroy(this) + } + this.body.on('end', this.endHandler) + } + + this.errorHandler = err => { + if (this.abort) { + this.abort(err) + } else { + this.error = err + } + } + this.body.on('error', this.errorHandler) + } else if (isBuffer(body)) { + this.body = body.byteLength ? body : null + } else if (ArrayBuffer.isView(body)) { + this.body = body.buffer.byteLength ? Buffer.from(body.buffer, body.byteOffset, body.byteLength) : null + } else if (body instanceof ArrayBuffer) { + this.body = body.byteLength ? Buffer.from(body) : null + } else if (typeof body === 'string') { + this.body = body.length ? Buffer.from(body) : null + } else if (isFormDataLike(body) || isIterable(body) || isBlobLike(body)) { + this.body = body + } else { + throw new InvalidArgumentError('body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable') + } + + this.completed = false + this.aborted = false + + this.upgrade = upgrade || null + + this.path = query ? serializePathWithQuery(path, query) : path + + this.origin = origin + + this.idempotent = idempotent == null + ? method === 'HEAD' || method === 'GET' + : idempotent + + this.blocking = blocking ?? this.method !== 'HEAD' + + this.reset = reset == null ? null : reset + + this.host = null + + this.contentLength = null + + this.contentType = null + + this.headers = [] + + // Only for H2 + this.expectContinue = expectContinue != null ? expectContinue : false + + if (Array.isArray(headers)) { + if (headers.length % 2 !== 0) { + throw new InvalidArgumentError('headers array must be even') + } + for (let i = 0; i < headers.length; i += 2) { + processHeader(this, headers[i], headers[i + 1]) + } + } else if (headers && typeof headers === 'object') { + if (headers[Symbol.iterator]) { + for (const header of headers) { + if (!Array.isArray(header) || header.length !== 2) { + throw new InvalidArgumentError('headers must be in key-value pair format') + } + processHeader(this, header[0], header[1]) + } + } else { + const keys = Object.keys(headers) + for (let i = 0; i < keys.length; ++i) { + processHeader(this, keys[i], headers[keys[i]]) + } + } + } else if (headers != null) { + throw new InvalidArgumentError('headers must be an object or an array') + } + + assertRequestHandler(handler, method, upgrade) + + this.servername = servername || getServerName(this.host) || null + + this[kHandler] = handler + + if (channels.create.hasSubscribers) { + channels.create.publish({ request: this }) + } + } + + onBodySent (chunk) { + if (this[kHandler].onBodySent) { + try { + return this[kHandler].onBodySent(chunk) + } catch (err) { + this.abort(err) + } + } + } + + onRequestSent () { + if (channels.bodySent.hasSubscribers) { + channels.bodySent.publish({ request: this }) + } + + if (this[kHandler].onRequestSent) { + try { + return this[kHandler].onRequestSent() + } catch (err) { + this.abort(err) + } + } + } + + onConnect (abort) { + assert(!this.aborted) + assert(!this.completed) + + if (this.error) { + abort(this.error) + } else { + this.abort = abort + return this[kHandler].onConnect(abort) + } + } + + onResponseStarted () { + return this[kHandler].onResponseStarted?.() + } + + onHeaders (statusCode, headers, resume, statusText) { + assert(!this.aborted) + assert(!this.completed) + + if (channels.headers.hasSubscribers) { + channels.headers.publish({ request: this, response: { statusCode, headers, statusText } }) + } + + try { + return this[kHandler].onHeaders(statusCode, headers, resume, statusText) + } catch (err) { + this.abort(err) + } + } + + onData (chunk) { + assert(!this.aborted) + assert(!this.completed) + + try { + return this[kHandler].onData(chunk) + } catch (err) { + this.abort(err) + return false + } + } + + onUpgrade (statusCode, headers, socket) { + assert(!this.aborted) + assert(!this.completed) + + return this[kHandler].onUpgrade(statusCode, headers, socket) + } + + onComplete (trailers) { + this.onFinally() + + assert(!this.aborted) + assert(!this.completed) + + this.completed = true + if (channels.trailers.hasSubscribers) { + channels.trailers.publish({ request: this, trailers }) + } + + try { + return this[kHandler].onComplete(trailers) + } catch (err) { + // TODO (fix): This might be a bad idea? + this.onError(err) + } + } + + onError (error) { + this.onFinally() + + if (channels.error.hasSubscribers) { + channels.error.publish({ request: this, error }) + } + + if (this.aborted) { + return + } + this.aborted = true + + return this[kHandler].onError(error) + } + + onFinally () { + if (this.errorHandler) { + this.body.off('error', this.errorHandler) + this.errorHandler = null + } + + if (this.endHandler) { + this.body.off('end', this.endHandler) + this.endHandler = null + } + } + + addHeader (key, value) { + processHeader(this, key, value) + return this + } +} + +function processHeader (request, key, val) { + if (val && (typeof val === 'object' && !Array.isArray(val))) { + throw new InvalidArgumentError(`invalid ${key} header`) + } else if (val === undefined) { + return + } + + let headerName = headerNameLowerCasedRecord[key] + + if (headerName === undefined) { + headerName = key.toLowerCase() + if (headerNameLowerCasedRecord[headerName] === undefined && !isValidHTTPToken(headerName)) { + throw new InvalidArgumentError('invalid header key') + } + } + + if (Array.isArray(val)) { + const arr = [] + for (let i = 0; i < val.length; i++) { + if (typeof val[i] === 'string') { + if (!isValidHeaderValue(val[i])) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + arr.push(val[i]) + } else if (val[i] === null) { + arr.push('') + } else if (typeof val[i] === 'object') { + throw new InvalidArgumentError(`invalid ${key} header`) + } else { + arr.push(`${val[i]}`) + } + } + val = arr + } else if (typeof val === 'string') { + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + } else if (val === null) { + val = '' + } else { + val = `${val}` + } + + if (request.host === null && headerName === 'host') { + if (typeof val !== 'string') { + throw new InvalidArgumentError('invalid host header') + } + // Consumed by Client + request.host = val + } else if (request.contentLength === null && headerName === 'content-length') { + request.contentLength = parseInt(val, 10) + if (!Number.isFinite(request.contentLength)) { + throw new InvalidArgumentError('invalid content-length header') + } + } else if (request.contentType === null && headerName === 'content-type') { + request.contentType = val + request.headers.push(key, val) + } else if (headerName === 'transfer-encoding' || headerName === 'keep-alive' || headerName === 'upgrade') { + throw new InvalidArgumentError(`invalid ${headerName} header`) + } else if (headerName === 'connection') { + const value = typeof val === 'string' ? val.toLowerCase() : null + if (value !== 'close' && value !== 'keep-alive') { + throw new InvalidArgumentError('invalid connection header') + } + + if (value === 'close') { + request.reset = true + } + } else if (headerName === 'expect') { + throw new NotSupportedError('expect header not supported') + } else { + request.headers.push(key, val) + } +} + +module.exports = Request diff --git a/lib/core/symbols.js b/lib/core/symbols.js new file mode 100644 index 0000000..f3b563a --- /dev/null +++ b/lib/core/symbols.js @@ -0,0 +1,68 @@ +'use strict' + +module.exports = { + kClose: Symbol('close'), + kDestroy: Symbol('destroy'), + kDispatch: Symbol('dispatch'), + kUrl: Symbol('url'), + kWriting: Symbol('writing'), + kResuming: Symbol('resuming'), + kQueue: Symbol('queue'), + kConnect: Symbol('connect'), + kConnecting: Symbol('connecting'), + kKeepAliveDefaultTimeout: Symbol('default keep alive timeout'), + kKeepAliveMaxTimeout: Symbol('max keep alive timeout'), + kKeepAliveTimeoutThreshold: Symbol('keep alive timeout threshold'), + kKeepAliveTimeoutValue: Symbol('keep alive timeout'), + kKeepAlive: Symbol('keep alive'), + kHeadersTimeout: Symbol('headers timeout'), + kBodyTimeout: Symbol('body timeout'), + kServerName: Symbol('server name'), + kLocalAddress: Symbol('local address'), + kHost: Symbol('host'), + kNoRef: Symbol('no ref'), + kBodyUsed: Symbol('used'), + kBody: Symbol('abstracted request body'), + kRunning: Symbol('running'), + kBlocking: Symbol('blocking'), + kPending: Symbol('pending'), + kSize: Symbol('size'), + kBusy: Symbol('busy'), + kQueued: Symbol('queued'), + kFree: Symbol('free'), + kConnected: Symbol('connected'), + kClosed: Symbol('closed'), + kNeedDrain: Symbol('need drain'), + kReset: Symbol('reset'), + kDestroyed: Symbol.for('nodejs.stream.destroyed'), + kResume: Symbol('resume'), + kOnError: Symbol('on error'), + kMaxHeadersSize: Symbol('max headers size'), + kRunningIdx: Symbol('running index'), + kPendingIdx: Symbol('pending index'), + kError: Symbol('error'), + kClients: Symbol('clients'), + kClient: Symbol('client'), + kParser: Symbol('parser'), + kOnDestroyed: Symbol('destroy callbacks'), + kPipelining: Symbol('pipelining'), + kSocket: Symbol('socket'), + kHostHeader: Symbol('host header'), + kConnector: Symbol('connector'), + kStrictContentLength: Symbol('strict content length'), + kMaxRedirections: Symbol('maxRedirections'), + kMaxRequests: Symbol('maxRequestsPerClient'), + kProxy: Symbol('proxy agent options'), + kCounter: Symbol('socket request counter'), + kMaxResponseSize: Symbol('max response size'), + kHTTP2Session: Symbol('http2Session'), + kHTTP2SessionState: Symbol('http2Session state'), + kRetryHandlerDefaultRetry: Symbol('retry agent default retry'), + kConstruct: Symbol('constructable'), + kListeners: Symbol('listeners'), + kHTTPContext: Symbol('http context'), + kMaxConcurrentStreams: Symbol('max concurrent streams'), + kNoProxyAgent: Symbol('no proxy agent'), + kHttpProxyAgent: Symbol('http proxy agent'), + kHttpsProxyAgent: Symbol('https proxy agent') +} diff --git a/lib/core/tree.js b/lib/core/tree.js new file mode 100644 index 0000000..e7b960c --- /dev/null +++ b/lib/core/tree.js @@ -0,0 +1,160 @@ +'use strict' + +const { + wellknownHeaderNames, + headerNameLowerCasedRecord +} = require('./constants') + +class TstNode { + /** @type {any} */ + value = null + /** @type {null | TstNode} */ + left = null + /** @type {null | TstNode} */ + middle = null + /** @type {null | TstNode} */ + right = null + /** @type {number} */ + code + /** + * @param {string} key + * @param {any} value + * @param {number} index + */ + constructor (key, value, index) { + if (index === undefined || index >= key.length) { + throw new TypeError('Unreachable') + } + const code = this.code = key.charCodeAt(index) + // check code is ascii string + if (code > 0x7F) { + throw new TypeError('key must be ascii string') + } + if (key.length !== ++index) { + this.middle = new TstNode(key, value, index) + } else { + this.value = value + } + } + + /** + * @param {string} key + * @param {any} value + * @returns {void} + */ + add (key, value) { + const length = key.length + if (length === 0) { + throw new TypeError('Unreachable') + } + let index = 0 + /** + * @type {TstNode} + */ + let node = this + while (true) { + const code = key.charCodeAt(index) + // check code is ascii string + if (code > 0x7F) { + throw new TypeError('key must be ascii string') + } + if (node.code === code) { + if (length === ++index) { + node.value = value + break + } else if (node.middle !== null) { + node = node.middle + } else { + node.middle = new TstNode(key, value, index) + break + } + } else if (node.code < code) { + if (node.left !== null) { + node = node.left + } else { + node.left = new TstNode(key, value, index) + break + } + } else if (node.right !== null) { + node = node.right + } else { + node.right = new TstNode(key, value, index) + break + } + } + } + + /** + * @param {Uint8Array} key + * @return {TstNode | null} + */ + search (key) { + const keylength = key.length + let index = 0 + /** + * @type {TstNode|null} + */ + let node = this + while (node !== null && index < keylength) { + let code = key[index] + // A-Z + // First check if it is bigger than 0x5a. + // Lowercase letters have higher char codes than uppercase ones. + // Also we assume that headers will mostly contain lowercase characters. + if (code <= 0x5a && code >= 0x41) { + // Lowercase for uppercase. + code |= 32 + } + while (node !== null) { + if (code === node.code) { + if (keylength === ++index) { + // Returns Node since it is the last key. + return node + } + node = node.middle + break + } + node = node.code < code ? node.left : node.right + } + } + return null + } +} + +class TernarySearchTree { + /** @type {TstNode | null} */ + node = null + + /** + * @param {string} key + * @param {any} value + * @returns {void} + * */ + insert (key, value) { + if (this.node === null) { + this.node = new TstNode(key, value, 0) + } else { + this.node.add(key, value) + } + } + + /** + * @param {Uint8Array} key + * @returns {any} + */ + lookup (key) { + return this.node?.search(key)?.value ?? null + } +} + +const tree = new TernarySearchTree() + +for (let i = 0; i < wellknownHeaderNames.length; ++i) { + const key = headerNameLowerCasedRecord[wellknownHeaderNames[i]] + tree.insert(key, key) +} + +module.exports = { + TernarySearchTree, + tree +} diff --git a/lib/core/util.js b/lib/core/util.js new file mode 100644 index 0000000..71071af --- /dev/null +++ b/lib/core/util.js @@ -0,0 +1,912 @@ +'use strict' + +const assert = require('node:assert') +const { kDestroyed, kBodyUsed, kListeners, kBody } = require('./symbols') +const { IncomingMessage } = require('node:http') +const stream = require('node:stream') +const net = require('node:net') +const { Blob } = require('node:buffer') +const nodeUtil = require('node:util') +const { stringify } = require('node:querystring') +const { EventEmitter: EE } = require('node:events') +const { InvalidArgumentError } = require('./errors') +const { headerNameLowerCasedRecord } = require('./constants') +const { tree } = require('./tree') + +const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(v => Number(v)) + +class BodyAsyncIterable { + constructor (body) { + this[kBody] = body + this[kBodyUsed] = false + } + + async * [Symbol.asyncIterator] () { + assert(!this[kBodyUsed], 'disturbed') + this[kBodyUsed] = true + yield * this[kBody] + } +} + +/** + * @param {*} body + * @returns {*} + */ +function wrapRequestBody (body) { + if (isStream(body)) { + // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp + // so that it can be dispatched again? + // TODO (fix): Do we need 100-expect support to provide a way to do this properly? + if (bodyLength(body) === 0) { + body + .on('data', function () { + assert(false) + }) + } + + if (typeof body.readableDidRead !== 'boolean') { + body[kBodyUsed] = false + EE.prototype.on.call(body, 'data', function () { + this[kBodyUsed] = true + }) + } + + return body + } else if (body && typeof body.pipeTo === 'function') { + // TODO (fix): We can't access ReadableStream internal state + // to determine whether or not it has been disturbed. This is just + // a workaround. + return new BodyAsyncIterable(body) + } else if ( + body && + typeof body !== 'string' && + !ArrayBuffer.isView(body) && + isIterable(body) + ) { + // TODO: Should we allow re-using iterable if !this.opts.idempotent + // or through some other flag? + return new BodyAsyncIterable(body) + } else { + return body + } +} + +/** + * @param {*} obj + * @returns {obj is import('node:stream').Stream} + */ +function isStream (obj) { + return obj && typeof obj === 'object' && typeof obj.pipe === 'function' && typeof obj.on === 'function' +} + +/** + * @param {*} object + * @returns {object is Blob} + * based on https://github.com/node-fetch/fetch-blob/blob/8ab587d34080de94140b54f07168451e7d0b655e/index.js#L229-L241 (MIT License) + */ +function isBlobLike (object) { + if (object === null) { + return false + } else if (object instanceof Blob) { + return true + } else if (typeof object !== 'object') { + return false + } else { + const sTag = object[Symbol.toStringTag] + + return (sTag === 'Blob' || sTag === 'File') && ( + ('stream' in object && typeof object.stream === 'function') || + ('arrayBuffer' in object && typeof object.arrayBuffer === 'function') + ) + } +} + +/** + * @param {string} url The URL to add the query params to + * @param {import('node:querystring').ParsedUrlQueryInput} queryParams The object to serialize into a URL query string + * @returns {string} The URL with the query params added + */ +function serializePathWithQuery (url, queryParams) { + if (url.includes('?') || url.includes('#')) { + throw new Error('Query params cannot be passed when url already contains "?" or "#".') + } + + const stringified = stringify(queryParams) + + if (stringified) { + url += '?' + stringified + } + + return url +} + +/** + * @param {number|string|undefined} port + * @returns {boolean} + */ +function isValidPort (port) { + const value = parseInt(port, 10) + return ( + value === Number(port) && + value >= 0 && + value <= 65535 + ) +} + +/** + * Check if the value is a valid http or https prefixed string. + * + * @param {string} value + * @returns {boolean} + */ +function isHttpOrHttpsPrefixed (value) { + return ( + value != null && + value[0] === 'h' && + value[1] === 't' && + value[2] === 't' && + value[3] === 'p' && + ( + value[4] === ':' || + ( + value[4] === 's' && + value[5] === ':' + ) + ) + ) +} + +/** + * @param {string|URL|Record} url + * @returns {URL} + */ +function parseURL (url) { + if (typeof url === 'string') { + /** + * @type {URL} + */ + url = new URL(url) + + if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) { + throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + return url + } + + if (!url || typeof url !== 'object') { + throw new InvalidArgumentError('Invalid URL: The URL argument must be a non-null object.') + } + + if (!(url instanceof URL)) { + if (url.port != null && url.port !== '' && isValidPort(url.port) === false) { + throw new InvalidArgumentError('Invalid URL: port must be a valid integer or a string representation of an integer.') + } + + if (url.path != null && typeof url.path !== 'string') { + throw new InvalidArgumentError('Invalid URL path: the path must be a string or null/undefined.') + } + + if (url.pathname != null && typeof url.pathname !== 'string') { + throw new InvalidArgumentError('Invalid URL pathname: the pathname must be a string or null/undefined.') + } + + if (url.hostname != null && typeof url.hostname !== 'string') { + throw new InvalidArgumentError('Invalid URL hostname: the hostname must be a string or null/undefined.') + } + + if (url.origin != null && typeof url.origin !== 'string') { + throw new InvalidArgumentError('Invalid URL origin: the origin must be a string or null/undefined.') + } + + if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) { + throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + const port = url.port != null + ? url.port + : (url.protocol === 'https:' ? 443 : 80) + let origin = url.origin != null + ? url.origin + : `${url.protocol || ''}//${url.hostname || ''}:${port}` + let path = url.path != null + ? url.path + : `${url.pathname || ''}${url.search || ''}` + + if (origin[origin.length - 1] === '/') { + origin = origin.slice(0, origin.length - 1) + } + + if (path && path[0] !== '/') { + path = `/${path}` + } + // new URL(path, origin) is unsafe when `path` contains an absolute URL + // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL: + // If first parameter is a relative URL, second param is required, and will be used as the base URL. + // If first parameter is an absolute URL, a given second param will be ignored. + return new URL(`${origin}${path}`) + } + + if (!isHttpOrHttpsPrefixed(url.origin || url.protocol)) { + throw new InvalidArgumentError('Invalid URL protocol: the URL must start with `http:` or `https:`.') + } + + return url +} + +/** + * @param {string|URL|Record} url + * @returns {URL} + */ +function parseOrigin (url) { + url = parseURL(url) + + if (url.pathname !== '/' || url.search || url.hash) { + throw new InvalidArgumentError('invalid url') + } + + return url +} + +/** + * @param {string} host + * @returns {string} + */ +function getHostname (host) { + if (host[0] === '[') { + const idx = host.indexOf(']') + + assert(idx !== -1) + return host.substring(1, idx) + } + + const idx = host.indexOf(':') + if (idx === -1) return host + + return host.substring(0, idx) +} + +/** + * IP addresses are not valid server names per RFC6066 + * Currently, the only server names supported are DNS hostnames + * @param {string|null} host + * @returns {string|null} + */ +function getServerName (host) { + if (!host) { + return null + } + + assert(typeof host === 'string') + + const servername = getHostname(host) + if (net.isIP(servername)) { + return '' + } + + return servername +} + +/** + * @function + * @template T + * @param {T} obj + * @returns {T} + */ +function deepClone (obj) { + return JSON.parse(JSON.stringify(obj)) +} + +/** + * @param {*} obj + * @returns {obj is AsyncIterable} + */ +function isAsyncIterable (obj) { + return !!(obj != null && typeof obj[Symbol.asyncIterator] === 'function') +} + +/** + * @param {*} obj + * @returns {obj is Iterable} + */ +function isIterable (obj) { + return !!(obj != null && (typeof obj[Symbol.iterator] === 'function' || typeof obj[Symbol.asyncIterator] === 'function')) +} + +/** + * @param {Blob|Buffer|import ('stream').Stream} body + * @returns {number|null} + */ +function bodyLength (body) { + if (body == null) { + return 0 + } else if (isStream(body)) { + const state = body._readableState + return state && state.objectMode === false && state.ended === true && Number.isFinite(state.length) + ? state.length + : null + } else if (isBlobLike(body)) { + return body.size != null ? body.size : null + } else if (isBuffer(body)) { + return body.byteLength + } + + return null +} + +/** + * @param {import ('stream').Stream} body + * @returns {boolean} + */ +function isDestroyed (body) { + return body && !!(body.destroyed || body[kDestroyed] || (stream.isDestroyed?.(body))) +} + +/** + * @param {import ('stream').Stream} stream + * @param {Error} [err] + * @returns {void} + */ +function destroy (stream, err) { + if (stream == null || !isStream(stream) || isDestroyed(stream)) { + return + } + + if (typeof stream.destroy === 'function') { + if (Object.getPrototypeOf(stream).constructor === IncomingMessage) { + // See: https://github.com/nodejs/node/pull/38505/files + stream.socket = null + } + + stream.destroy(err) + } else if (err) { + queueMicrotask(() => { + stream.emit('error', err) + }) + } + + if (stream.destroyed !== true) { + stream[kDestroyed] = true + } +} + +const KEEPALIVE_TIMEOUT_EXPR = /timeout=(\d+)/ +/** + * @param {string} val + * @returns {number | null} + */ +function parseKeepAliveTimeout (val) { + const m = val.match(KEEPALIVE_TIMEOUT_EXPR) + return m ? parseInt(m[1], 10) * 1000 : null +} + +/** + * Retrieves a header name and returns its lowercase value. + * @param {string | Buffer} value Header name + * @returns {string} + */ +function headerNameToString (value) { + return typeof value === 'string' + ? headerNameLowerCasedRecord[value] ?? value.toLowerCase() + : tree.lookup(value) ?? value.toString('latin1').toLowerCase() +} + +/** + * Receive the buffer as a string and return its lowercase value. + * @param {Buffer} value Header name + * @returns {string} + */ +function bufferToLowerCasedHeaderName (value) { + return tree.lookup(value) ?? value.toString('latin1').toLowerCase() +} + +/** + * @param {(Buffer | string)[]} headers + * @param {Record} [obj] + * @returns {Record} + */ +function parseHeaders (headers, obj) { + if (obj === undefined) obj = {} + + for (let i = 0; i < headers.length; i += 2) { + const key = headerNameToString(headers[i]) + let val = obj[key] + + if (val) { + if (typeof val === 'string') { + val = [val] + obj[key] = val + } + val.push(headers[i + 1].toString('utf8')) + } else { + const headersValue = headers[i + 1] + if (typeof headersValue === 'string') { + obj[key] = headersValue + } else { + obj[key] = Array.isArray(headersValue) ? headersValue.map(x => x.toString('utf8')) : headersValue.toString('utf8') + } + } + } + + // See https://github.com/nodejs/node/pull/46528 + if ('content-length' in obj && 'content-disposition' in obj) { + obj['content-disposition'] = Buffer.from(obj['content-disposition']).toString('latin1') + } + + return obj +} + +/** + * @param {Buffer[]} headers + * @returns {string[]} + */ +function parseRawHeaders (headers) { + const headersLength = headers.length + /** + * @type {string[]} + */ + const ret = new Array(headersLength) + + let hasContentLength = false + let contentDispositionIdx = -1 + let key + let val + let kLen = 0 + + for (let n = 0; n < headersLength; n += 2) { + key = headers[n] + val = headers[n + 1] + + typeof key !== 'string' && (key = key.toString()) + typeof val !== 'string' && (val = val.toString('utf8')) + + kLen = key.length + if (kLen === 14 && key[7] === '-' && (key === 'content-length' || key.toLowerCase() === 'content-length')) { + hasContentLength = true + } else if (kLen === 19 && key[7] === '-' && (key === 'content-disposition' || key.toLowerCase() === 'content-disposition')) { + contentDispositionIdx = n + 1 + } + ret[n] = key + ret[n + 1] = val + } + + // See https://github.com/nodejs/node/pull/46528 + if (hasContentLength && contentDispositionIdx !== -1) { + ret[contentDispositionIdx] = Buffer.from(ret[contentDispositionIdx]).toString('latin1') + } + + return ret +} + +/** + * @param {string[]} headers + * @param {Buffer[]} headers + */ +function encodeRawHeaders (headers) { + if (!Array.isArray(headers)) { + throw new TypeError('expected headers to be an array') + } + return headers.map(x => Buffer.from(x)) +} + +/** + * @param {*} buffer + * @returns {buffer is Buffer} + */ +function isBuffer (buffer) { + // See, https://github.com/mcollina/undici/pull/319 + return buffer instanceof Uint8Array || Buffer.isBuffer(buffer) +} + +/** + * Asserts that the handler object is a request handler. + * + * @param {object} handler + * @param {string} method + * @param {string} [upgrade] + * @returns {asserts handler is import('../api/api-request').RequestHandler} + */ +function assertRequestHandler (handler, method, upgrade) { + if (!handler || typeof handler !== 'object') { + throw new InvalidArgumentError('handler must be an object') + } + + if (typeof handler.onRequestStart === 'function') { + // TODO (fix): More checks... + return + } + + if (typeof handler.onConnect !== 'function') { + throw new InvalidArgumentError('invalid onConnect method') + } + + if (typeof handler.onError !== 'function') { + throw new InvalidArgumentError('invalid onError method') + } + + if (typeof handler.onBodySent !== 'function' && handler.onBodySent !== undefined) { + throw new InvalidArgumentError('invalid onBodySent method') + } + + if (upgrade || method === 'CONNECT') { + if (typeof handler.onUpgrade !== 'function') { + throw new InvalidArgumentError('invalid onUpgrade method') + } + } else { + if (typeof handler.onHeaders !== 'function') { + throw new InvalidArgumentError('invalid onHeaders method') + } + + if (typeof handler.onData !== 'function') { + throw new InvalidArgumentError('invalid onData method') + } + + if (typeof handler.onComplete !== 'function') { + throw new InvalidArgumentError('invalid onComplete method') + } + } +} + +/** + * A body is disturbed if it has been read from and it cannot be re-used without + * losing state or data. + * @param {import('node:stream').Readable} body + * @returns {boolean} + */ +function isDisturbed (body) { + // TODO (fix): Why is body[kBodyUsed] needed? + return !!(body && (stream.isDisturbed(body) || body[kBodyUsed])) +} + +/** + * @typedef {object} SocketInfo + * @property {string} [localAddress] + * @property {number} [localPort] + * @property {string} [remoteAddress] + * @property {number} [remotePort] + * @property {string} [remoteFamily] + * @property {number} [timeout] + * @property {number} bytesWritten + * @property {number} bytesRead + */ + +/** + * @param {import('net').Socket} socket + * @returns {SocketInfo} + */ +function getSocketInfo (socket) { + return { + localAddress: socket.localAddress, + localPort: socket.localPort, + remoteAddress: socket.remoteAddress, + remotePort: socket.remotePort, + remoteFamily: socket.remoteFamily, + timeout: socket.timeout, + bytesWritten: socket.bytesWritten, + bytesRead: socket.bytesRead + } +} + +/** + * @param {Iterable} iterable + * @returns {ReadableStream} + */ +function ReadableStreamFrom (iterable) { + // We cannot use ReadableStream.from here because it does not return a byte stream. + + let iterator + return new ReadableStream( + { + async start () { + iterator = iterable[Symbol.asyncIterator]() + }, + pull (controller) { + async function pull () { + const { done, value } = await iterator.next() + if (done) { + queueMicrotask(() => { + controller.close() + controller.byobRequest?.respond(0) + }) + } else { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value) + if (buf.byteLength) { + controller.enqueue(new Uint8Array(buf)) + } else { + return await pull() + } + } + } + + return pull() + }, + async cancel () { + await iterator.return() + }, + type: 'bytes' + } + ) +} + +/** + * The object should be a FormData instance and contains all the required + * methods. + * @param {*} object + * @returns {object is FormData} + */ +function isFormDataLike (object) { + return ( + object && + typeof object === 'object' && + typeof object.append === 'function' && + typeof object.delete === 'function' && + typeof object.get === 'function' && + typeof object.getAll === 'function' && + typeof object.has === 'function' && + typeof object.set === 'function' && + object[Symbol.toStringTag] === 'FormData' + ) +} + +function addAbortListener (signal, listener) { + if ('addEventListener' in signal) { + signal.addEventListener('abort', listener, { once: true }) + return () => signal.removeEventListener('abort', listener) + } + signal.once('abort', listener) + return () => signal.removeListener('abort', listener) +} + +/** + * @function + * @param {string} value + * @returns {string} + */ +const toUSVString = (() => { + if (typeof String.prototype.toWellFormed === 'function') { + /** + * @param {string} value + * @returns {string} + */ + return (value) => `${value}`.toWellFormed() + } else { + /** + * @param {string} value + * @returns {string} + */ + return nodeUtil.toUSVString + } +})() + +/** + * @param {*} value + * @returns {boolean} + */ +// TODO: move this to webidl +const isUSVString = (() => { + if (typeof String.prototype.isWellFormed === 'function') { + /** + * @param {*} value + * @returns {boolean} + */ + return (value) => `${value}`.isWellFormed() + } else { + /** + * @param {*} value + * @returns {boolean} + */ + return (value) => toUSVString(value) === `${value}` + } +})() + +/** + * @see https://tools.ietf.org/html/rfc7230#section-3.2.6 + * @param {number} c + * @returns {boolean} + */ +function isTokenCharCode (c) { + switch (c) { + case 0x22: + case 0x28: + case 0x29: + case 0x2c: + case 0x2f: + case 0x3a: + case 0x3b: + case 0x3c: + case 0x3d: + case 0x3e: + case 0x3f: + case 0x40: + case 0x5b: + case 0x5c: + case 0x5d: + case 0x7b: + case 0x7d: + // DQUOTE and "(),/:;<=>?@[\]{}" + return false + default: + // VCHAR %x21-7E + return c >= 0x21 && c <= 0x7e + } +} + +/** + * @param {string} characters + * @returns {boolean} + */ +function isValidHTTPToken (characters) { + if (characters.length === 0) { + return false + } + for (let i = 0; i < characters.length; ++i) { + if (!isTokenCharCode(characters.charCodeAt(i))) { + return false + } + } + return true +} + +// headerCharRegex have been lifted from +// https://github.com/nodejs/node/blob/main/lib/_http_common.js + +/** + * Matches if val contains an invalid field-vchar + * field-value = *( field-content / obs-fold ) + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + */ +const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/ + +/** + * @param {string} characters + * @returns {boolean} + */ +function isValidHeaderValue (characters) { + return !headerCharRegex.test(characters) +} + +const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+)?$/ + +/** + * @typedef {object} RangeHeader + * @property {number} start + * @property {number | null} end + * @property {number | null} size + */ + +/** + * Parse accordingly to RFC 9110 + * @see https://www.rfc-editor.org/rfc/rfc9110#field.content-range + * @param {string} [range] + * @returns {RangeHeader|null} + */ +function parseRangeHeader (range) { + if (range == null || range === '') return { start: 0, end: null, size: null } + + const m = range ? range.match(rangeHeaderRegex) : null + return m + ? { + start: parseInt(m[1]), + end: m[2] ? parseInt(m[2]) : null, + size: m[3] ? parseInt(m[3]) : null + } + : null +} + +/** + * @template {import("events").EventEmitter} T + * @param {T} obj + * @param {string} name + * @param {(...args: any[]) => void} listener + * @returns {T} + */ +function addListener (obj, name, listener) { + const listeners = (obj[kListeners] ??= []) + listeners.push([name, listener]) + obj.on(name, listener) + return obj +} + +/** + * @template {import("events").EventEmitter} T + * @param {T} obj + * @returns {T} + */ +function removeAllListeners (obj) { + if (obj[kListeners] != null) { + for (const [name, listener] of obj[kListeners]) { + obj.removeListener(name, listener) + } + obj[kListeners] = null + } + return obj +} + +/** + * @param {import ('../dispatcher/client')} client + * @param {import ('../core/request')} request + * @param {Error} err + */ +function errorRequest (client, request, err) { + try { + request.onError(err) + assert(request.aborted) + } catch (err) { + client.emit('error', err) + } +} + +const kEnumerableProperty = Object.create(null) +kEnumerableProperty.enumerable = true + +const normalizedMethodRecordsBase = { + delete: 'DELETE', + DELETE: 'DELETE', + get: 'GET', + GET: 'GET', + head: 'HEAD', + HEAD: 'HEAD', + options: 'OPTIONS', + OPTIONS: 'OPTIONS', + post: 'POST', + POST: 'POST', + put: 'PUT', + PUT: 'PUT' +} + +const normalizedMethodRecords = { + ...normalizedMethodRecordsBase, + patch: 'patch', + PATCH: 'PATCH' +} + +// Note: object prototypes should not be able to be referenced. e.g. `Object#hasOwnProperty`. +Object.setPrototypeOf(normalizedMethodRecordsBase, null) +Object.setPrototypeOf(normalizedMethodRecords, null) + +module.exports = { + kEnumerableProperty, + isDisturbed, + toUSVString, + isUSVString, + isBlobLike, + parseOrigin, + parseURL, + getServerName, + isStream, + isIterable, + isAsyncIterable, + isDestroyed, + headerNameToString, + bufferToLowerCasedHeaderName, + addListener, + removeAllListeners, + errorRequest, + parseRawHeaders, + encodeRawHeaders, + parseHeaders, + parseKeepAliveTimeout, + destroy, + bodyLength, + deepClone, + ReadableStreamFrom, + isBuffer, + assertRequestHandler, + getSocketInfo, + isFormDataLike, + serializePathWithQuery, + addAbortListener, + isValidHTTPToken, + isValidHeaderValue, + isTokenCharCode, + parseRangeHeader, + normalizedMethodRecordsBase, + normalizedMethodRecords, + isValidPort, + isHttpOrHttpsPrefixed, + nodeMajor, + nodeMinor, + safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']), + wrapRequestBody +} diff --git a/lib/dispatcher/agent.js b/lib/dispatcher/agent.js new file mode 100644 index 0000000..46fc157 --- /dev/null +++ b/lib/dispatcher/agent.js @@ -0,0 +1,115 @@ +'use strict' + +const { InvalidArgumentError } = require('../core/errors') +const { kClients, kRunning, kClose, kDestroy, kDispatch } = require('../core/symbols') +const DispatcherBase = require('./dispatcher-base') +const Pool = require('./pool') +const Client = require('./client') +const util = require('../core/util') + +const kOnConnect = Symbol('onConnect') +const kOnDisconnect = Symbol('onDisconnect') +const kOnConnectionError = Symbol('onConnectionError') +const kOnDrain = Symbol('onDrain') +const kFactory = Symbol('factory') +const kOptions = Symbol('options') + +function defaultFactory (origin, opts) { + return opts && opts.connections === 1 + ? new Client(origin, opts) + : new Pool(origin, opts) +} + +class Agent extends DispatcherBase { + constructor ({ factory = defaultFactory, connect, ...options } = {}) { + if (typeof factory !== 'function') { + throw new InvalidArgumentError('factory must be a function.') + } + + if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') { + throw new InvalidArgumentError('connect must be a function or an object') + } + + super() + + if (connect && typeof connect !== 'function') { + connect = { ...connect } + } + + this[kOptions] = { ...util.deepClone(options), connect } + this[kFactory] = factory + this[kClients] = new Map() + + this[kOnDrain] = (origin, targets) => { + this.emit('drain', origin, [this, ...targets]) + } + + this[kOnConnect] = (origin, targets) => { + this.emit('connect', origin, [this, ...targets]) + } + + this[kOnDisconnect] = (origin, targets, err) => { + this.emit('disconnect', origin, [this, ...targets], err) + } + + this[kOnConnectionError] = (origin, targets, err) => { + this.emit('connectionError', origin, [this, ...targets], err) + } + } + + get [kRunning] () { + let ret = 0 + for (const client of this[kClients].values()) { + ret += client[kRunning] + } + return ret + } + + [kDispatch] (opts, handler) { + let key + if (opts.origin && (typeof opts.origin === 'string' || opts.origin instanceof URL)) { + key = String(opts.origin) + } else { + throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.') + } + + let dispatcher = this[kClients].get(key) + + if (!dispatcher) { + dispatcher = this[kFactory](opts.origin, this[kOptions]) + .on('drain', this[kOnDrain]) + .on('connect', this[kOnConnect]) + .on('disconnect', this[kOnDisconnect]) + .on('connectionError', this[kOnConnectionError]) + + // This introduces a tiny memory leak, as dispatchers are never removed from the map. + // TODO(mcollina): remove te timer when the client/pool do not have any more + // active connections. + this[kClients].set(key, dispatcher) + } + + return dispatcher.dispatch(opts, handler) + } + + async [kClose] () { + const closePromises = [] + for (const client of this[kClients].values()) { + closePromises.push(client.close()) + } + this[kClients].clear() + + await Promise.all(closePromises) + } + + async [kDestroy] (err) { + const destroyPromises = [] + for (const client of this[kClients].values()) { + destroyPromises.push(client.destroy(err)) + } + this[kClients].clear() + + await Promise.all(destroyPromises) + } +} + +module.exports = Agent diff --git a/lib/dispatcher/balanced-pool.js b/lib/dispatcher/balanced-pool.js new file mode 100644 index 0000000..5bbec0e --- /dev/null +++ b/lib/dispatcher/balanced-pool.js @@ -0,0 +1,206 @@ +'use strict' + +const { + BalancedPoolMissingUpstreamError, + InvalidArgumentError +} = require('../core/errors') +const { + PoolBase, + kClients, + kNeedDrain, + kAddClient, + kRemoveClient, + kGetDispatcher +} = require('./pool-base') +const Pool = require('./pool') +const { kUrl } = require('../core/symbols') +const { parseOrigin } = require('../core/util') +const kFactory = Symbol('factory') + +const kOptions = Symbol('options') +const kGreatestCommonDivisor = Symbol('kGreatestCommonDivisor') +const kCurrentWeight = Symbol('kCurrentWeight') +const kIndex = Symbol('kIndex') +const kWeight = Symbol('kWeight') +const kMaxWeightPerServer = Symbol('kMaxWeightPerServer') +const kErrorPenalty = Symbol('kErrorPenalty') + +/** + * Calculate the greatest common divisor of two numbers by + * using the Euclidean algorithm. + * + * @param {number} a + * @param {number} b + * @returns {number} + */ +function getGreatestCommonDivisor (a, b) { + if (a === 0) return b + + while (b !== 0) { + const t = b + b = a % b + a = t + } + return a +} + +function defaultFactory (origin, opts) { + return new Pool(origin, opts) +} + +class BalancedPool extends PoolBase { + constructor (upstreams = [], { factory = defaultFactory, ...opts } = {}) { + if (typeof factory !== 'function') { + throw new InvalidArgumentError('factory must be a function.') + } + + super() + + this[kOptions] = opts + this[kIndex] = -1 + this[kCurrentWeight] = 0 + + this[kMaxWeightPerServer] = this[kOptions].maxWeightPerServer || 100 + this[kErrorPenalty] = this[kOptions].errorPenalty || 15 + + if (!Array.isArray(upstreams)) { + upstreams = [upstreams] + } + + this[kFactory] = factory + + for (const upstream of upstreams) { + this.addUpstream(upstream) + } + this._updateBalancedPoolStats() + } + + addUpstream (upstream) { + const upstreamOrigin = parseOrigin(upstream).origin + + if (this[kClients].find((pool) => ( + pool[kUrl].origin === upstreamOrigin && + pool.closed !== true && + pool.destroyed !== true + ))) { + return this + } + const pool = this[kFactory](upstreamOrigin, Object.assign({}, this[kOptions])) + + this[kAddClient](pool) + pool.on('connect', () => { + pool[kWeight] = Math.min(this[kMaxWeightPerServer], pool[kWeight] + this[kErrorPenalty]) + }) + + pool.on('connectionError', () => { + pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty]) + this._updateBalancedPoolStats() + }) + + pool.on('disconnect', (...args) => { + const err = args[2] + if (err && err.code === 'UND_ERR_SOCKET') { + // decrease the weight of the pool. + pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty]) + this._updateBalancedPoolStats() + } + }) + + for (const client of this[kClients]) { + client[kWeight] = this[kMaxWeightPerServer] + } + + this._updateBalancedPoolStats() + + return this + } + + _updateBalancedPoolStats () { + let result = 0 + for (let i = 0; i < this[kClients].length; i++) { + result = getGreatestCommonDivisor(this[kClients][i][kWeight], result) + } + + this[kGreatestCommonDivisor] = result + } + + removeUpstream (upstream) { + const upstreamOrigin = parseOrigin(upstream).origin + + const pool = this[kClients].find((pool) => ( + pool[kUrl].origin === upstreamOrigin && + pool.closed !== true && + pool.destroyed !== true + )) + + if (pool) { + this[kRemoveClient](pool) + } + + return this + } + + get upstreams () { + return this[kClients] + .filter(dispatcher => dispatcher.closed !== true && dispatcher.destroyed !== true) + .map((p) => p[kUrl].origin) + } + + [kGetDispatcher] () { + // We validate that pools is greater than 0, + // otherwise we would have to wait until an upstream + // is added, which might never happen. + if (this[kClients].length === 0) { + throw new BalancedPoolMissingUpstreamError() + } + + const dispatcher = this[kClients].find(dispatcher => ( + !dispatcher[kNeedDrain] && + dispatcher.closed !== true && + dispatcher.destroyed !== true + )) + + if (!dispatcher) { + return + } + + const allClientsBusy = this[kClients].map(pool => pool[kNeedDrain]).reduce((a, b) => a && b, true) + + if (allClientsBusy) { + return + } + + let counter = 0 + + let maxWeightIndex = this[kClients].findIndex(pool => !pool[kNeedDrain]) + + while (counter++ < this[kClients].length) { + this[kIndex] = (this[kIndex] + 1) % this[kClients].length + const pool = this[kClients][this[kIndex]] + + // find pool index with the largest weight + if (pool[kWeight] > this[kClients][maxWeightIndex][kWeight] && !pool[kNeedDrain]) { + maxWeightIndex = this[kIndex] + } + + // decrease the current weight every `this[kClients].length`. + if (this[kIndex] === 0) { + // Set the current weight to the next lower weight. + this[kCurrentWeight] = this[kCurrentWeight] - this[kGreatestCommonDivisor] + + if (this[kCurrentWeight] <= 0) { + this[kCurrentWeight] = this[kMaxWeightPerServer] + } + } + if (pool[kWeight] >= this[kCurrentWeight] && (!pool[kNeedDrain])) { + return pool + } + } + + this[kCurrentWeight] = this[kClients][maxWeightIndex][kWeight] + this[kIndex] = maxWeightIndex + return this[kClients][maxWeightIndex] + } +} + +module.exports = BalancedPool diff --git a/lib/dispatcher/client-h1.js b/lib/dispatcher/client-h1.js new file mode 100644 index 0000000..37fe94e --- /dev/null +++ b/lib/dispatcher/client-h1.js @@ -0,0 +1,1615 @@ +'use strict' + +/* global WebAssembly */ + +const assert = require('node:assert') +const util = require('../core/util.js') +const { channels } = require('../core/diagnostics.js') +const timers = require('../util/timers.js') +const { + RequestContentLengthMismatchError, + ResponseContentLengthMismatchError, + RequestAbortedError, + HeadersTimeoutError, + HeadersOverflowError, + SocketError, + InformationalError, + BodyTimeoutError, + HTTPParserError, + ResponseExceededMaxSizeError +} = require('../core/errors.js') +const { + kUrl, + kReset, + kClient, + kParser, + kBlocking, + kRunning, + kPending, + kSize, + kWriting, + kQueue, + kNoRef, + kKeepAliveDefaultTimeout, + kHostHeader, + kPendingIdx, + kRunningIdx, + kError, + kPipelining, + kSocket, + kKeepAliveTimeoutValue, + kMaxHeadersSize, + kKeepAliveMaxTimeout, + kKeepAliveTimeoutThreshold, + kHeadersTimeout, + kBodyTimeout, + kStrictContentLength, + kMaxRequests, + kCounter, + kMaxResponseSize, + kOnError, + kResume, + kHTTPContext, + kClosed +} = require('../core/symbols.js') + +const constants = require('../llhttp/constants.js') +const EMPTY_BUF = Buffer.alloc(0) +const FastBuffer = Buffer[Symbol.species] +const removeAllListeners = util.removeAllListeners + +let extractBody + +async function lazyllhttp () { + const llhttpWasmData = process.env.JEST_WORKER_ID ? require('../llhttp/llhttp-wasm.js') : undefined + + let mod + try { + mod = await WebAssembly.compile(require('../llhttp/llhttp_simd-wasm.js')) + } catch (e) { + /* istanbul ignore next */ + + // We could check if the error was caused by the simd option not + // being enabled, but the occurring of this other error + // * https://github.com/emscripten-core/emscripten/issues/11495 + // got me to remove that check to avoid breaking Node 12. + mod = await WebAssembly.compile(llhttpWasmData || require('../llhttp/llhttp-wasm.js')) + } + + return await WebAssembly.instantiate(mod, { + env: { + /** + * @param {number} p + * @param {number} at + * @param {number} len + * @returns {number} + */ + wasm_on_url: (p, at, len) => { + /* istanbul ignore next */ + return 0 + }, + /** + * @param {number} p + * @param {number} at + * @param {number} len + * @returns {number} + */ + wasm_on_status: (p, at, len) => { + assert(currentParser.ptr === p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onStatus(new FastBuffer(currentBufferRef.buffer, start, len)) + }, + /** + * @param {number} p + * @returns {number} + */ + wasm_on_message_begin: (p) => { + assert(currentParser.ptr === p) + return currentParser.onMessageBegin() + }, + /** + * @param {number} p + * @param {number} at + * @param {number} len + * @returns {number} + */ + wasm_on_header_field: (p, at, len) => { + assert(currentParser.ptr === p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onHeaderField(new FastBuffer(currentBufferRef.buffer, start, len)) + }, + /** + * @param {number} p + * @param {number} at + * @param {number} len + * @returns {number} + */ + wasm_on_header_value: (p, at, len) => { + assert(currentParser.ptr === p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onHeaderValue(new FastBuffer(currentBufferRef.buffer, start, len)) + }, + /** + * @param {number} p + * @param {number} statusCode + * @param {0|1} upgrade + * @param {0|1} shouldKeepAlive + * @returns {number} + */ + wasm_on_headers_complete: (p, statusCode, upgrade, shouldKeepAlive) => { + assert(currentParser.ptr === p) + return currentParser.onHeadersComplete(statusCode, upgrade === 1, shouldKeepAlive === 1) + }, + /** + * @param {number} p + * @param {number} at + * @param {number} len + * @returns {number} + */ + wasm_on_body: (p, at, len) => { + assert(currentParser.ptr === p) + const start = at - currentBufferPtr + currentBufferRef.byteOffset + return currentParser.onBody(new FastBuffer(currentBufferRef.buffer, start, len)) + }, + /** + * @param {number} p + * @returns {number} + */ + wasm_on_message_complete: (p) => { + assert(currentParser.ptr === p) + return currentParser.onMessageComplete() + } + + } + }) +} + +let llhttpInstance = null +/** + * @type {Promise|null} + */ +let llhttpPromise = lazyllhttp() +llhttpPromise.catch() + +/** + * @type {Parser|null} + */ +let currentParser = null +let currentBufferRef = null +/** + * @type {number} + */ +let currentBufferSize = 0 +let currentBufferPtr = null + +const USE_NATIVE_TIMER = 0 +const USE_FAST_TIMER = 1 + +// Use fast timers for headers and body to take eventual event loop +// latency into account. +const TIMEOUT_HEADERS = 2 | USE_FAST_TIMER +const TIMEOUT_BODY = 4 | USE_FAST_TIMER + +// Use native timers to ignore event loop latency for keep-alive +// handling. +const TIMEOUT_KEEP_ALIVE = 8 | USE_NATIVE_TIMER + +class Parser { + /** + * @param {import('./client.js')} client + * @param {import('net').Socket} socket + * @param {*} llhttp + */ + constructor (client, socket, { exports }) { + this.llhttp = exports + this.ptr = this.llhttp.llhttp_alloc(constants.TYPE.RESPONSE) + this.client = client + /** + * @type {import('net').Socket} + */ + this.socket = socket + this.timeout = null + this.timeoutValue = null + this.timeoutType = null + this.statusCode = 0 + this.statusText = '' + this.upgrade = false + this.headers = [] + this.headersSize = 0 + this.headersMaxSize = client[kMaxHeadersSize] + this.shouldKeepAlive = false + this.paused = false + this.resume = this.resume.bind(this) + + this.bytesRead = 0 + + this.keepAlive = '' + this.contentLength = '' + this.connection = '' + this.maxResponseSize = client[kMaxResponseSize] + } + + setTimeout (delay, type) { + // If the existing timer and the new timer are of different timer type + // (fast or native) or have different delay, we need to clear the existing + // timer and set a new one. + if ( + delay !== this.timeoutValue || + (type & USE_FAST_TIMER) ^ (this.timeoutType & USE_FAST_TIMER) + ) { + // If a timeout is already set, clear it with clearTimeout of the fast + // timer implementation, as it can clear fast and native timers. + if (this.timeout) { + timers.clearTimeout(this.timeout) + this.timeout = null + } + + if (delay) { + if (type & USE_FAST_TIMER) { + this.timeout = timers.setFastTimeout(onParserTimeout, delay, new WeakRef(this)) + } else { + this.timeout = setTimeout(onParserTimeout, delay, new WeakRef(this)) + this.timeout.unref() + } + } + + this.timeoutValue = delay + } else if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + this.timeoutType = type + } + + resume () { + if (this.socket.destroyed || !this.paused) { + return + } + + assert(this.ptr != null) + assert(currentParser === null) + + this.llhttp.llhttp_resume(this.ptr) + + assert(this.timeoutType === TIMEOUT_BODY) + if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + this.paused = false + this.execute(this.socket.read() || EMPTY_BUF) // Flush parser. + this.readMore() + } + + readMore () { + while (!this.paused && this.ptr) { + const chunk = this.socket.read() + if (chunk === null) { + break + } + this.execute(chunk) + } + } + + /** + * @param {Buffer} chunk + */ + execute (chunk) { + assert(currentParser === null) + assert(this.ptr != null) + assert(!this.paused) + + const { socket, llhttp } = this + + // Allocate a new buffer if the current buffer is too small. + if (chunk.length > currentBufferSize) { + if (currentBufferPtr) { + llhttp.free(currentBufferPtr) + } + // Allocate a buffer that is a multiple of 4096 bytes. + currentBufferSize = Math.ceil(chunk.length / 4096) * 4096 + currentBufferPtr = llhttp.malloc(currentBufferSize) + } + + new Uint8Array(llhttp.memory.buffer, currentBufferPtr, currentBufferSize).set(chunk) + + // Call `execute` on the wasm parser. + // We pass the `llhttp_parser` pointer address, the pointer address of buffer view data, + // and finally the length of bytes to parse. + // The return value is an error code or `constants.ERROR.OK`. + try { + let ret + + try { + currentBufferRef = chunk + currentParser = this + ret = llhttp.llhttp_execute(this.ptr, currentBufferPtr, chunk.length) + /* eslint-disable-next-line no-useless-catch */ + } catch (err) { + /* istanbul ignore next: difficult to make a test case for */ + throw err + } finally { + currentParser = null + currentBufferRef = null + } + + if (ret !== constants.ERROR.OK) { + const data = chunk.subarray(llhttp.llhttp_get_error_pos(this.ptr) - currentBufferPtr) + + if (ret === constants.ERROR.PAUSED_UPGRADE) { + this.onUpgrade(data) + } else if (ret === constants.ERROR.PAUSED) { + this.paused = true + socket.unshift(data) + } else { + const ptr = llhttp.llhttp_get_error_reason(this.ptr) + let message = '' + /* istanbul ignore else: difficult to make a test case for */ + if (ptr) { + const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0) + message = + 'Response does not match the HTTP/1.1 protocol (' + + Buffer.from(llhttp.memory.buffer, ptr, len).toString() + + ')' + } + throw new HTTPParserError(message, constants.ERROR[ret], data) + } + } + } catch (err) { + util.destroy(socket, err) + } + } + + destroy () { + assert(currentParser === null) + assert(this.ptr != null) + + this.llhttp.llhttp_free(this.ptr) + this.ptr = null + + this.timeout && timers.clearTimeout(this.timeout) + this.timeout = null + this.timeoutValue = null + this.timeoutType = null + + this.paused = false + } + + /** + * @param {Buffer} buf + * @returns {0} + */ + onStatus (buf) { + this.statusText = buf.toString() + return 0 + } + + /** + * @returns {0|-1} + */ + onMessageBegin () { + const { socket, client } = this + + /* istanbul ignore next: difficult to make a test case for */ + if (socket.destroyed) { + return -1 + } + + const request = client[kQueue][client[kRunningIdx]] + if (!request) { + return -1 + } + request.onResponseStarted() + + return 0 + } + + /** + * @param {Buffer} buf + * @returns {number} + */ + onHeaderField (buf) { + const len = this.headers.length + + if ((len & 1) === 0) { + this.headers.push(buf) + } else { + this.headers[len - 1] = Buffer.concat([this.headers[len - 1], buf]) + } + + this.trackHeader(buf.length) + + return 0 + } + + /** + * @param {Buffer} buf + * @returns {number} + */ + onHeaderValue (buf) { + let len = this.headers.length + + if ((len & 1) === 1) { + this.headers.push(buf) + len += 1 + } else { + this.headers[len - 1] = Buffer.concat([this.headers[len - 1], buf]) + } + + const key = this.headers[len - 2] + if (key.length === 10) { + const headerName = util.bufferToLowerCasedHeaderName(key) + if (headerName === 'keep-alive') { + this.keepAlive += buf.toString() + } else if (headerName === 'connection') { + this.connection += buf.toString() + } + } else if (key.length === 14 && util.bufferToLowerCasedHeaderName(key) === 'content-length') { + this.contentLength += buf.toString() + } + + this.trackHeader(buf.length) + + return 0 + } + + /** + * @param {number} len + */ + trackHeader (len) { + this.headersSize += len + if (this.headersSize >= this.headersMaxSize) { + util.destroy(this.socket, new HeadersOverflowError()) + } + } + + /** + * @param {Buffer} head + */ + onUpgrade (head) { + const { upgrade, client, socket, headers, statusCode } = this + + assert(upgrade) + assert(client[kSocket] === socket) + assert(!socket.destroyed) + assert(!this.paused) + assert((headers.length & 1) === 0) + + const request = client[kQueue][client[kRunningIdx]] + assert(request) + assert(request.upgrade || request.method === 'CONNECT') + + this.statusCode = 0 + this.statusText = '' + this.shouldKeepAlive = false + + this.headers = [] + this.headersSize = 0 + + socket.unshift(head) + + socket[kParser].destroy() + socket[kParser] = null + + socket[kClient] = null + socket[kError] = null + + removeAllListeners(socket) + + client[kSocket] = null + client[kHTTPContext] = null // TODO (fix): This is hacky... + client[kQueue][client[kRunningIdx]++] = null + client.emit('disconnect', client[kUrl], [client], new InformationalError('upgrade')) + + try { + request.onUpgrade(statusCode, headers, socket) + } catch (err) { + util.destroy(socket, err) + } + + client[kResume]() + } + + /** + * @param {number} statusCode + * @param {boolean} upgrade + * @param {boolean} shouldKeepAlive + * @returns {number} + */ + onHeadersComplete (statusCode, upgrade, shouldKeepAlive) { + const { client, socket, headers, statusText } = this + + /* istanbul ignore next: difficult to make a test case for */ + if (socket.destroyed) { + return -1 + } + + const request = client[kQueue][client[kRunningIdx]] + + /* istanbul ignore next: difficult to make a test case for */ + if (!request) { + return -1 + } + + assert(!this.upgrade) + assert(this.statusCode < 200) + + if (statusCode === 100) { + util.destroy(socket, new SocketError('bad response', util.getSocketInfo(socket))) + return -1 + } + + /* this can only happen if server is misbehaving */ + if (upgrade && !request.upgrade) { + util.destroy(socket, new SocketError('bad upgrade', util.getSocketInfo(socket))) + return -1 + } + + assert(this.timeoutType === TIMEOUT_HEADERS) + + this.statusCode = statusCode + this.shouldKeepAlive = ( + shouldKeepAlive || + // Override llhttp value which does not allow keepAlive for HEAD. + (request.method === 'HEAD' && !socket[kReset] && this.connection.toLowerCase() === 'keep-alive') + ) + + if (this.statusCode >= 200) { + const bodyTimeout = request.bodyTimeout != null + ? request.bodyTimeout + : client[kBodyTimeout] + this.setTimeout(bodyTimeout, TIMEOUT_BODY) + } else if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + if (request.method === 'CONNECT') { + assert(client[kRunning] === 1) + this.upgrade = true + return 2 + } + + if (upgrade) { + assert(client[kRunning] === 1) + this.upgrade = true + return 2 + } + + assert((this.headers.length & 1) === 0) + this.headers = [] + this.headersSize = 0 + + if (this.shouldKeepAlive && client[kPipelining]) { + const keepAliveTimeout = this.keepAlive ? util.parseKeepAliveTimeout(this.keepAlive) : null + + if (keepAliveTimeout != null) { + const timeout = Math.min( + keepAliveTimeout - client[kKeepAliveTimeoutThreshold], + client[kKeepAliveMaxTimeout] + ) + if (timeout <= 0) { + socket[kReset] = true + } else { + client[kKeepAliveTimeoutValue] = timeout + } + } else { + client[kKeepAliveTimeoutValue] = client[kKeepAliveDefaultTimeout] + } + } else { + // Stop more requests from being dispatched. + socket[kReset] = true + } + + const pause = request.onHeaders(statusCode, headers, this.resume, statusText) === false + + if (request.aborted) { + return -1 + } + + if (request.method === 'HEAD') { + return 1 + } + + if (statusCode < 200) { + return 1 + } + + if (socket[kBlocking]) { + socket[kBlocking] = false + client[kResume]() + } + + return pause ? constants.ERROR.PAUSED : 0 + } + + /** + * @param {Buffer} buf + * @returns {number} + */ + onBody (buf) { + const { client, socket, statusCode, maxResponseSize } = this + + if (socket.destroyed) { + return -1 + } + + const request = client[kQueue][client[kRunningIdx]] + assert(request) + + assert(this.timeoutType === TIMEOUT_BODY) + if (this.timeout) { + // istanbul ignore else: only for jest + if (this.timeout.refresh) { + this.timeout.refresh() + } + } + + assert(statusCode >= 200) + + if (maxResponseSize > -1 && this.bytesRead + buf.length > maxResponseSize) { + util.destroy(socket, new ResponseExceededMaxSizeError()) + return -1 + } + + this.bytesRead += buf.length + + if (request.onData(buf) === false) { + return constants.ERROR.PAUSED + } + + return 0 + } + + /** + * @returns {number} + */ + onMessageComplete () { + const { client, socket, statusCode, upgrade, headers, contentLength, bytesRead, shouldKeepAlive } = this + + if (socket.destroyed && (!statusCode || shouldKeepAlive)) { + return -1 + } + + if (upgrade) { + return 0 + } + + assert(statusCode >= 100) + assert((this.headers.length & 1) === 0) + + const request = client[kQueue][client[kRunningIdx]] + assert(request) + + this.statusCode = 0 + this.statusText = '' + this.bytesRead = 0 + this.contentLength = '' + this.keepAlive = '' + this.connection = '' + + this.headers = [] + this.headersSize = 0 + + if (statusCode < 200) { + return 0 + } + + /* istanbul ignore next: should be handled by llhttp? */ + if (request.method !== 'HEAD' && contentLength && bytesRead !== parseInt(contentLength, 10)) { + util.destroy(socket, new ResponseContentLengthMismatchError()) + return -1 + } + + request.onComplete(headers) + + client[kQueue][client[kRunningIdx]++] = null + + if (socket[kWriting]) { + assert(client[kRunning] === 0) + // Response completed before request. + util.destroy(socket, new InformationalError('reset')) + return constants.ERROR.PAUSED + } else if (!shouldKeepAlive) { + util.destroy(socket, new InformationalError('reset')) + return constants.ERROR.PAUSED + } else if (socket[kReset] && client[kRunning] === 0) { + // Destroy socket once all requests have completed. + // The request at the tail of the pipeline is the one + // that requested reset and no further requests should + // have been queued since then. + util.destroy(socket, new InformationalError('reset')) + return constants.ERROR.PAUSED + } else if (client[kPipelining] == null || client[kPipelining] === 1) { + // We must wait a full event loop cycle to reuse this socket to make sure + // that non-spec compliant servers are not closing the connection even if they + // said they won't. + setImmediate(() => client[kResume]()) + } else { + client[kResume]() + } + + return 0 + } +} + +function onParserTimeout (parser) { + const { socket, timeoutType, client, paused } = parser.deref() + + /* istanbul ignore else */ + if (timeoutType === TIMEOUT_HEADERS) { + if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) { + assert(!paused, 'cannot be paused while waiting for headers') + util.destroy(socket, new HeadersTimeoutError()) + } + } else if (timeoutType === TIMEOUT_BODY) { + if (!paused) { + util.destroy(socket, new BodyTimeoutError()) + } + } else if (timeoutType === TIMEOUT_KEEP_ALIVE) { + assert(client[kRunning] === 0 && client[kKeepAliveTimeoutValue]) + util.destroy(socket, new InformationalError('socket idle timeout')) + } +} + +/** + * @param {import ('./client.js')} client + * @param {import('net').Socket} socket + * @returns + */ +async function connectH1 (client, socket) { + client[kSocket] = socket + + if (!llhttpInstance) { + const noop = () => {} + socket.on('error', noop) + llhttpInstance = await llhttpPromise + llhttpPromise = null + socket.off('error', noop) + } + + if (socket.errored) { + throw socket.errored + } + + if (socket.destroyed) { + throw new SocketError('destroyed') + } + + socket[kNoRef] = false + socket[kWriting] = false + socket[kReset] = false + socket[kBlocking] = false + socket[kParser] = new Parser(client, socket, llhttpInstance) + + util.addListener(socket, 'error', onHttpSocketError) + util.addListener(socket, 'readable', onHttpSocketReadable) + util.addListener(socket, 'end', onHttpSocketEnd) + util.addListener(socket, 'close', onHttpSocketClose) + + socket[kClosed] = false + socket.on('close', onSocketClose) + + return { + version: 'h1', + defaultPipelining: 1, + write (request) { + return writeH1(client, request) + }, + resume () { + resumeH1(client) + }, + /** + * @param {Error|undefined} err + * @param {() => void} callback + */ + destroy (err, callback) { + if (socket[kClosed]) { + queueMicrotask(callback) + } else { + socket.on('close', callback) + socket.destroy(err) + } + }, + /** + * @returns {boolean} + */ + get destroyed () { + return socket.destroyed + }, + /** + * @param {import('../core/request.js')} request + * @returns {boolean} + */ + busy (request) { + if (socket[kWriting] || socket[kReset] || socket[kBlocking]) { + return true + } + + if (request) { + if (client[kRunning] > 0 && !request.idempotent) { + // Non-idempotent request cannot be retried. + // Ensure that no other requests are inflight and + // could cause failure. + return true + } + + if (client[kRunning] > 0 && (request.upgrade || request.method === 'CONNECT')) { + // Don't dispatch an upgrade until all preceding requests have completed. + // A misbehaving server might upgrade the connection before all pipelined + // request has completed. + return true + } + + if (client[kRunning] > 0 && util.bodyLength(request.body) !== 0 && + (util.isStream(request.body) || util.isAsyncIterable(request.body) || util.isFormDataLike(request.body))) { + // Request with stream or iterator body can error while other requests + // are inflight and indirectly error those as well. + // Ensure this doesn't happen by waiting for inflight + // to complete before dispatching. + + // Request with stream or iterator body cannot be retried. + // Ensure that no other requests are inflight and + // could cause failure. + return true + } + } + + return false + } + } +} + +function onHttpSocketError (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + + const parser = this[kParser] + + // On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded + // to the user. + if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) { + // We treat all incoming data so for as a valid response. + parser.onMessageComplete() + return + } + + this[kError] = err + + this[kClient][kOnError](err) +} + +function onHttpSocketReadable () { + this[kParser]?.readMore() +} + +function onHttpSocketEnd () { + const parser = this[kParser] + + if (parser.statusCode && !parser.shouldKeepAlive) { + // We treat all incoming data so far as a valid response. + parser.onMessageComplete() + return + } + + util.destroy(this, new SocketError('other side closed', util.getSocketInfo(this))) +} + +function onHttpSocketClose () { + const parser = this[kParser] + + if (parser) { + if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) { + // We treat all incoming data so far as a valid response. + parser.onMessageComplete() + } + + this[kParser].destroy() + this[kParser] = null + } + + const err = this[kError] || new SocketError('closed', util.getSocketInfo(this)) + + const client = this[kClient] + + client[kSocket] = null + client[kHTTPContext] = null // TODO (fix): This is hacky... + + if (client.destroyed) { + assert(client[kPending] === 0) + + // Fail entire queue. + const requests = client[kQueue].splice(client[kRunningIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + util.errorRequest(client, request, err) + } + } else if (client[kRunning] > 0 && err.code !== 'UND_ERR_INFO') { + // Fail head of pipeline. + const request = client[kQueue][client[kRunningIdx]] + client[kQueue][client[kRunningIdx]++] = null + + util.errorRequest(client, request, err) + } + + client[kPendingIdx] = client[kRunningIdx] + + assert(client[kRunning] === 0) + + client.emit('disconnect', client[kUrl], [client], err) + + client[kResume]() +} + +function onSocketClose () { + this[kClosed] = true +} + +/** + * @param {import('./client.js')} client + */ +function resumeH1 (client) { + const socket = client[kSocket] + + if (socket && !socket.destroyed) { + if (client[kSize] === 0) { + if (!socket[kNoRef] && socket.unref) { + socket.unref() + socket[kNoRef] = true + } + } else if (socket[kNoRef] && socket.ref) { + socket.ref() + socket[kNoRef] = false + } + + if (client[kSize] === 0) { + if (socket[kParser].timeoutType !== TIMEOUT_KEEP_ALIVE) { + socket[kParser].setTimeout(client[kKeepAliveTimeoutValue], TIMEOUT_KEEP_ALIVE) + } + } else if (client[kRunning] > 0 && socket[kParser].statusCode < 200) { + if (socket[kParser].timeoutType !== TIMEOUT_HEADERS) { + const request = client[kQueue][client[kRunningIdx]] + const headersTimeout = request.headersTimeout != null + ? request.headersTimeout + : client[kHeadersTimeout] + socket[kParser].setTimeout(headersTimeout, TIMEOUT_HEADERS) + } + } + } +} + +// https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2 +function shouldSendContentLength (method) { + return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && method !== 'TRACE' && method !== 'CONNECT' +} + +/** + * @param {import('./client.js')} client + * @param {import('../core/request.js')} request + * @returns + */ +function writeH1 (client, request) { + const { method, path, host, upgrade, blocking, reset } = request + + let { body, headers, contentLength } = request + + // https://tools.ietf.org/html/rfc7231#section-4.3.1 + // https://tools.ietf.org/html/rfc7231#section-4.3.2 + // https://tools.ietf.org/html/rfc7231#section-4.3.5 + + // Sending a payload body on a request that does not + // expect it can cause undefined behavior on some + // servers and corrupt connection state. Do not + // re-use the connection for further requests. + + const expectsPayload = ( + method === 'PUT' || + method === 'POST' || + method === 'PATCH' || + method === 'QUERY' || + method === 'PROPFIND' || + method === 'PROPPATCH' + ) + + if (util.isFormDataLike(body)) { + if (!extractBody) { + extractBody = require('../web/fetch/body.js').extractBody + } + + const [bodyStream, contentType] = extractBody(body) + if (request.contentType == null) { + headers.push('content-type', contentType) + } + body = bodyStream.stream + contentLength = bodyStream.length + } else if (util.isBlobLike(body) && request.contentType == null && body.type) { + headers.push('content-type', body.type) + } + + if (body && typeof body.read === 'function') { + // Try to read EOF in order to get length. + body.read(0) + } + + const bodyLength = util.bodyLength(body) + + contentLength = bodyLength ?? contentLength + + if (contentLength === null) { + contentLength = request.contentLength + } + + if (contentLength === 0 && !expectsPayload) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD NOT send a Content-Length header field when + // the request message does not contain a payload body and the method + // semantics do not anticipate such a body. + + contentLength = null + } + + // https://github.com/nodejs/undici/issues/2046 + // A user agent may send a Content-Length header with 0 value, this should be allowed. + if (shouldSendContentLength(method) && contentLength > 0 && request.contentLength !== null && request.contentLength !== contentLength) { + if (client[kStrictContentLength]) { + util.errorRequest(client, request, new RequestContentLengthMismatchError()) + return false + } + + process.emitWarning(new RequestContentLengthMismatchError()) + } + + const socket = client[kSocket] + + /** + * @param {Error} [err] + * @returns {void} + */ + const abort = (err) => { + if (request.aborted || request.completed) { + return + } + + util.errorRequest(client, request, err || new RequestAbortedError()) + + util.destroy(body) + util.destroy(socket, new InformationalError('aborted')) + } + + try { + request.onConnect(abort) + } catch (err) { + util.errorRequest(client, request, err) + } + + if (request.aborted) { + return false + } + + if (method === 'HEAD') { + // https://github.com/mcollina/undici/issues/258 + // Close after a HEAD request to interop with misbehaving servers + // that may send a body in the response. + + socket[kReset] = true + } + + if (upgrade || method === 'CONNECT') { + // On CONNECT or upgrade, block pipeline from dispatching further + // requests on this connection. + + socket[kReset] = true + } + + if (reset != null) { + socket[kReset] = reset + } + + if (client[kMaxRequests] && socket[kCounter]++ >= client[kMaxRequests]) { + socket[kReset] = true + } + + if (blocking) { + socket[kBlocking] = true + } + + let header = `${method} ${path} HTTP/1.1\r\n` + + if (typeof host === 'string') { + header += `host: ${host}\r\n` + } else { + header += client[kHostHeader] + } + + if (upgrade) { + header += `connection: upgrade\r\nupgrade: ${upgrade}\r\n` + } else if (client[kPipelining] && !socket[kReset]) { + header += 'connection: keep-alive\r\n' + } else { + header += 'connection: close\r\n' + } + + if (Array.isArray(headers)) { + for (let n = 0; n < headers.length; n += 2) { + const key = headers[n + 0] + const val = headers[n + 1] + + if (Array.isArray(val)) { + for (let i = 0; i < val.length; i++) { + header += `${key}: ${val[i]}\r\n` + } + } else { + header += `${key}: ${val}\r\n` + } + } + } + + if (channels.sendHeaders.hasSubscribers) { + channels.sendHeaders.publish({ request, headers: header, socket }) + } + + /* istanbul ignore else: assertion */ + if (!body || bodyLength === 0) { + writeBuffer(abort, null, client, request, socket, contentLength, header, expectsPayload) + } else if (util.isBuffer(body)) { + writeBuffer(abort, body, client, request, socket, contentLength, header, expectsPayload) + } else if (util.isBlobLike(body)) { + if (typeof body.stream === 'function') { + writeIterable(abort, body.stream(), client, request, socket, contentLength, header, expectsPayload) + } else { + writeBlob(abort, body, client, request, socket, contentLength, header, expectsPayload) + } + } else if (util.isStream(body)) { + writeStream(abort, body, client, request, socket, contentLength, header, expectsPayload) + } else if (util.isIterable(body)) { + writeIterable(abort, body, client, request, socket, contentLength, header, expectsPayload) + } else { + assert(false) + } + + return true +} + +/** + * @param {AbortCallback} abort + * @param {import('stream').Stream} body + * @param {import('./client.js')} client + * @param {import('../core/request.js')} request + * @param {import('net').Socket} socket + * @param {number} contentLength + * @param {string} header + * @param {boolean} expectsPayload + */ +function writeStream (abort, body, client, request, socket, contentLength, header, expectsPayload) { + assert(contentLength !== 0 || client[kRunning] === 0, 'stream body cannot be pipelined') + + let finished = false + + const writer = new AsyncWriter({ abort, socket, request, contentLength, client, expectsPayload, header }) + + /** + * @param {Buffer} chunk + * @returns {void} + */ + const onData = function (chunk) { + if (finished) { + return + } + + try { + if (!writer.write(chunk) && this.pause) { + this.pause() + } + } catch (err) { + util.destroy(this, err) + } + } + + /** + * @returns {void} + */ + const onDrain = function () { + if (finished) { + return + } + + if (body.resume) { + body.resume() + } + } + + /** + * @returns {void} + */ + const onClose = function () { + // 'close' might be emitted *before* 'error' for + // broken streams. Wait a tick to avoid this case. + queueMicrotask(() => { + // It's only safe to remove 'error' listener after + // 'close'. + body.removeListener('error', onFinished) + }) + + if (!finished) { + const err = new RequestAbortedError() + queueMicrotask(() => onFinished(err)) + } + } + + /** + * @param {Error} [err] + * @returns + */ + const onFinished = function (err) { + if (finished) { + return + } + + finished = true + + assert(socket.destroyed || (socket[kWriting] && client[kRunning] <= 1)) + + socket + .off('drain', onDrain) + .off('error', onFinished) + + body + .removeListener('data', onData) + .removeListener('end', onFinished) + .removeListener('close', onClose) + + if (!err) { + try { + writer.end() + } catch (er) { + err = er + } + } + + writer.destroy(err) + + if (err && (err.code !== 'UND_ERR_INFO' || err.message !== 'reset')) { + util.destroy(body, err) + } else { + util.destroy(body) + } + } + + body + .on('data', onData) + .on('end', onFinished) + .on('error', onFinished) + .on('close', onClose) + + if (body.resume) { + body.resume() + } + + socket + .on('drain', onDrain) + .on('error', onFinished) + + if (body.errorEmitted ?? body.errored) { + setImmediate(() => onFinished(body.errored)) + } else if (body.endEmitted ?? body.readableEnded) { + setImmediate(() => onFinished(null)) + } + + if (body.closeEmitted ?? body.closed) { + setImmediate(onClose) + } +} + +/** + * @typedef AbortCallback + * @type {Function} + * @param {Error} [err] + * @returns {void} + */ + +/** + * @param {AbortCallback} abort + * @param {Uint8Array|null} body + * @param {import('./client.js')} client + * @param {import('../core/request.js')} request + * @param {import('net').Socket} socket + * @param {number} contentLength + * @param {string} header + * @param {boolean} expectsPayload + * @returns {void} + */ +function writeBuffer (abort, body, client, request, socket, contentLength, header, expectsPayload) { + try { + if (!body) { + if (contentLength === 0) { + socket.write(`${header}content-length: 0\r\n\r\n`, 'latin1') + } else { + assert(contentLength === null, 'no body must not have content length') + socket.write(`${header}\r\n`, 'latin1') + } + } else if (util.isBuffer(body)) { + assert(contentLength === body.byteLength, 'buffer body must have content length') + + socket.cork() + socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'latin1') + socket.write(body) + socket.uncork() + request.onBodySent(body) + + if (!expectsPayload && request.reset !== false) { + socket[kReset] = true + } + } + request.onRequestSent() + + client[kResume]() + } catch (err) { + abort(err) + } +} + +/** + * @param {AbortCallback} abort + * @param {Blob} body + * @param {import('./client.js')} client + * @param {import('../core/request.js')} request + * @param {import('net').Socket} socket + * @param {number} contentLength + * @param {string} header + * @param {boolean} expectsPayload + * @returns {Promise} + */ +async function writeBlob (abort, body, client, request, socket, contentLength, header, expectsPayload) { + assert(contentLength === body.size, 'blob body must have content length') + + try { + if (contentLength != null && contentLength !== body.size) { + throw new RequestContentLengthMismatchError() + } + + const buffer = Buffer.from(await body.arrayBuffer()) + + socket.cork() + socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'latin1') + socket.write(buffer) + socket.uncork() + + request.onBodySent(buffer) + request.onRequestSent() + + if (!expectsPayload && request.reset !== false) { + socket[kReset] = true + } + + client[kResume]() + } catch (err) { + abort(err) + } +} + +/** + * @param {AbortCallback} abort + * @param {Iterable} body + * @param {import('./client.js')} client + * @param {import('../core/request.js')} request + * @param {import('net').Socket} socket + * @param {number} contentLength + * @param {string} header + * @param {boolean} expectsPayload + * @returns {Promise} + */ +async function writeIterable (abort, body, client, request, socket, contentLength, header, expectsPayload) { + assert(contentLength !== 0 || client[kRunning] === 0, 'iterator body cannot be pipelined') + + let callback = null + function onDrain () { + if (callback) { + const cb = callback + callback = null + cb() + } + } + + const waitForDrain = () => new Promise((resolve, reject) => { + assert(callback === null) + + if (socket[kError]) { + reject(socket[kError]) + } else { + callback = resolve + } + }) + + socket + .on('close', onDrain) + .on('drain', onDrain) + + const writer = new AsyncWriter({ abort, socket, request, contentLength, client, expectsPayload, header }) + try { + // It's up to the user to somehow abort the async iterable. + for await (const chunk of body) { + if (socket[kError]) { + throw socket[kError] + } + + if (!writer.write(chunk)) { + await waitForDrain() + } + } + + writer.end() + } catch (err) { + writer.destroy(err) + } finally { + socket + .off('close', onDrain) + .off('drain', onDrain) + } +} + +class AsyncWriter { + /** + * + * @param {object} arg + * @param {AbortCallback} arg.abort + * @param {import('net').Socket} arg.socket + * @param {import('../core/request.js')} arg.request + * @param {number} arg.contentLength + * @param {import('./client.js')} arg.client + * @param {boolean} arg.expectsPayload + * @param {string} arg.header + */ + constructor ({ abort, socket, request, contentLength, client, expectsPayload, header }) { + this.socket = socket + this.request = request + this.contentLength = contentLength + this.client = client + this.bytesWritten = 0 + this.expectsPayload = expectsPayload + this.header = header + this.abort = abort + + socket[kWriting] = true + } + + /** + * @param {Buffer} chunk + * @returns + */ + write (chunk) { + const { socket, request, contentLength, client, bytesWritten, expectsPayload, header } = this + + if (socket[kError]) { + throw socket[kError] + } + + if (socket.destroyed) { + return false + } + + const len = Buffer.byteLength(chunk) + if (!len) { + return true + } + + // We should defer writing chunks. + if (contentLength !== null && bytesWritten + len > contentLength) { + if (client[kStrictContentLength]) { + throw new RequestContentLengthMismatchError() + } + + process.emitWarning(new RequestContentLengthMismatchError()) + } + + socket.cork() + + if (bytesWritten === 0) { + if (!expectsPayload && request.reset !== false) { + socket[kReset] = true + } + + if (contentLength === null) { + socket.write(`${header}transfer-encoding: chunked\r\n`, 'latin1') + } else { + socket.write(`${header}content-length: ${contentLength}\r\n\r\n`, 'latin1') + } + } + + if (contentLength === null) { + socket.write(`\r\n${len.toString(16)}\r\n`, 'latin1') + } + + this.bytesWritten += len + + const ret = socket.write(chunk) + + socket.uncork() + + request.onBodySent(chunk) + + if (!ret) { + if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) { + // istanbul ignore else: only for jest + if (socket[kParser].timeout.refresh) { + socket[kParser].timeout.refresh() + } + } + } + + return ret + } + + /** + * @returns {void} + */ + end () { + const { socket, contentLength, client, bytesWritten, expectsPayload, header, request } = this + request.onRequestSent() + + socket[kWriting] = false + + if (socket[kError]) { + throw socket[kError] + } + + if (socket.destroyed) { + return + } + + if (bytesWritten === 0) { + if (expectsPayload) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD send a Content-Length in a request message when + // no Transfer-Encoding is sent and the request method defines a meaning + // for an enclosed payload body. + + socket.write(`${header}content-length: 0\r\n\r\n`, 'latin1') + } else { + socket.write(`${header}\r\n`, 'latin1') + } + } else if (contentLength === null) { + socket.write('\r\n0\r\n\r\n', 'latin1') + } + + if (contentLength !== null && bytesWritten !== contentLength) { + if (client[kStrictContentLength]) { + throw new RequestContentLengthMismatchError() + } else { + process.emitWarning(new RequestContentLengthMismatchError()) + } + } + + if (socket[kParser].timeout && socket[kParser].timeoutType === TIMEOUT_HEADERS) { + // istanbul ignore else: only for jest + if (socket[kParser].timeout.refresh) { + socket[kParser].timeout.refresh() + } + } + + client[kResume]() + } + + /** + * @param {Error} [err] + * @returns {void} + */ + destroy (err) { + const { socket, client, abort } = this + + socket[kWriting] = false + + if (err) { + assert(client[kRunning] <= 1, 'pipeline should only contain this request') + abort(err) + } + } +} + +module.exports = connectH1 diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js new file mode 100644 index 0000000..d34c7ca --- /dev/null +++ b/lib/dispatcher/client-h2.js @@ -0,0 +1,795 @@ +'use strict' + +const assert = require('node:assert') +const { pipeline } = require('node:stream') +const util = require('../core/util.js') +const { + RequestContentLengthMismatchError, + RequestAbortedError, + SocketError, + InformationalError +} = require('../core/errors.js') +const { + kUrl, + kReset, + kClient, + kRunning, + kPending, + kQueue, + kPendingIdx, + kRunningIdx, + kError, + kSocket, + kStrictContentLength, + kOnError, + kMaxConcurrentStreams, + kHTTP2Session, + kResume, + kSize, + kHTTPContext, + kClosed, + kBodyTimeout +} = require('../core/symbols.js') +const { channels } = require('../core/diagnostics.js') + +const kOpenStreams = Symbol('open streams') + +let extractBody + +/** @type {import('http2')} */ +let http2 +try { + http2 = require('node:http2') +} catch { + // @ts-ignore + http2 = { constants: {} } +} + +const { + constants: { + HTTP2_HEADER_AUTHORITY, + HTTP2_HEADER_METHOD, + HTTP2_HEADER_PATH, + HTTP2_HEADER_SCHEME, + HTTP2_HEADER_CONTENT_LENGTH, + HTTP2_HEADER_EXPECT, + HTTP2_HEADER_STATUS + } +} = http2 + +function parseH2Headers (headers) { + const result = [] + + for (const [name, value] of Object.entries(headers)) { + // h2 may concat the header value by array + // e.g. Set-Cookie + if (Array.isArray(value)) { + for (const subvalue of value) { + // we need to provide each header value of header name + // because the headers handler expect name-value pair + result.push(Buffer.from(name), Buffer.from(subvalue)) + } + } else { + result.push(Buffer.from(name), Buffer.from(value)) + } + } + + return result +} + +async function connectH2 (client, socket) { + client[kSocket] = socket + + const session = http2.connect(client[kUrl], { + createConnection: () => socket, + peerMaxConcurrentStreams: client[kMaxConcurrentStreams], + settings: { + // TODO(metcoder95): add support for PUSH + enablePush: false + } + }) + + session[kOpenStreams] = 0 + session[kClient] = client + session[kSocket] = socket + session[kHTTP2Session] = null + + util.addListener(session, 'error', onHttp2SessionError) + util.addListener(session, 'frameError', onHttp2FrameError) + util.addListener(session, 'end', onHttp2SessionEnd) + util.addListener(session, 'goaway', onHttp2SessionGoAway) + util.addListener(session, 'close', onHttp2SessionClose) + + session.unref() + + client[kHTTP2Session] = session + socket[kHTTP2Session] = session + + util.addListener(socket, 'error', onHttp2SocketError) + util.addListener(socket, 'end', onHttp2SocketEnd) + util.addListener(socket, 'close', onHttp2SocketClose) + + socket[kClosed] = false + socket.on('close', onSocketClose) + + return { + version: 'h2', + defaultPipelining: Infinity, + write (request) { + return writeH2(client, request) + }, + resume () { + resumeH2(client) + }, + destroy (err, callback) { + if (socket[kClosed]) { + queueMicrotask(callback) + } else { + socket.destroy(err).on('close', callback) + } + }, + get destroyed () { + return socket.destroyed + }, + busy () { + return false + } + } +} + +function resumeH2 (client) { + const socket = client[kSocket] + + if (socket?.destroyed === false) { + if (client[kSize] === 0 || client[kMaxConcurrentStreams] === 0) { + socket.unref() + client[kHTTP2Session].unref() + } else { + socket.ref() + client[kHTTP2Session].ref() + } + } +} + +function onHttp2SessionError (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + + this[kSocket][kError] = err + this[kClient][kOnError](err) +} + +function onHttp2FrameError (type, code, id) { + if (id === 0) { + const err = new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`) + this[kSocket][kError] = err + this[kClient][kOnError](err) + } +} + +function onHttp2SessionEnd () { + const err = new SocketError('other side closed', util.getSocketInfo(this[kSocket])) + this.destroy(err) + util.destroy(this[kSocket], err) +} + +/** + * This is the root cause of #3011 + * We need to handle GOAWAY frames properly, and trigger the session close + * along with the socket right away + * + * @this {import('http2').ClientHttp2Session} + * @param {number} errorCode + */ +function onHttp2SessionGoAway (errorCode) { + // TODO(mcollina): Verify if GOAWAY implements the spec correctly: + // https://datatracker.ietf.org/doc/html/rfc7540#section-6.8 + // Specifically, we do not verify the "valid" stream id. + + const err = this[kError] || new SocketError(`HTTP/2: "GOAWAY" frame received with code ${errorCode}`, util.getSocketInfo(this[kSocket])) + const client = this[kClient] + + client[kSocket] = null + client[kHTTPContext] = null + + // this is an HTTP2 session + this.close() + this[kHTTP2Session] = null + + util.destroy(this[kSocket], err) + + // Fail head of pipeline. + if (client[kRunningIdx] < client[kQueue].length) { + const request = client[kQueue][client[kRunningIdx]] + client[kQueue][client[kRunningIdx]++] = null + util.errorRequest(client, request, err) + client[kPendingIdx] = client[kRunningIdx] + } + + assert(client[kRunning] === 0) + + client.emit('disconnect', client[kUrl], [client], err) + + client[kResume]() +} + +function onHttp2SessionClose () { + const { [kClient]: client } = this + const { [kSocket]: socket } = client + + const err = this[kSocket][kError] || this[kError] || new SocketError('closed', util.getSocketInfo(socket)) + + client[kSocket] = null + client[kHTTPContext] = null + + if (client.destroyed) { + assert(client[kPending] === 0) + + // Fail entire queue. + const requests = client[kQueue].splice(client[kRunningIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + util.errorRequest(client, request, err) + } + } +} + +function onHttp2SocketClose () { + const err = this[kError] || new SocketError('closed', util.getSocketInfo(this)) + + const client = this[kHTTP2Session][kClient] + + client[kSocket] = null + client[kHTTPContext] = null + + if (this[kHTTP2Session] !== null) { + this[kHTTP2Session].destroy(err) + } + + client[kPendingIdx] = client[kRunningIdx] + + assert(client[kRunning] === 0) + + client.emit('disconnect', client[kUrl], [client], err) + + client[kResume]() +} + +function onHttp2SocketError (err) { + assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') + + this[kError] = err + + this[kClient][kOnError](err) +} + +function onHttp2SocketEnd () { + util.destroy(this, new SocketError('other side closed', util.getSocketInfo(this))) +} + +function onSocketClose () { + this[kClosed] = true +} + +// https://www.rfc-editor.org/rfc/rfc7230#section-3.3.2 +function shouldSendContentLength (method) { + return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && method !== 'TRACE' && method !== 'CONNECT' +} + +function writeH2 (client, request) { + const requestTimeout = request.bodyTimeout ?? client[kBodyTimeout] + const session = client[kHTTP2Session] + const { method, path, host, upgrade, expectContinue, signal, headers: reqHeaders } = request + let { body } = request + + if (upgrade) { + util.errorRequest(client, request, new Error('Upgrade not supported for H2')) + return false + } + + const headers = {} + for (let n = 0; n < reqHeaders.length; n += 2) { + const key = reqHeaders[n + 0] + const val = reqHeaders[n + 1] + + if (Array.isArray(val)) { + for (let i = 0; i < val.length; i++) { + if (headers[key]) { + headers[key] += `,${val[i]}` + } else { + headers[key] = val[i] + } + } + } else { + headers[key] = val + } + } + + /** @type {import('node:http2').ClientHttp2Stream} */ + let stream = null + + const { hostname, port } = client[kUrl] + + headers[HTTP2_HEADER_AUTHORITY] = host || `${hostname}${port ? `:${port}` : ''}` + headers[HTTP2_HEADER_METHOD] = method + + const abort = (err) => { + if (request.aborted || request.completed) { + return + } + + err = err || new RequestAbortedError() + + util.errorRequest(client, request, err) + + if (stream != null) { + // Some chunks might still come after abort, + // let's ignore them + stream.removeAllListeners('data') + + // On Abort, we close the stream to send RST_STREAM frame + stream.close() + + // We move the running index to the next request + client[kOnError](err) + client[kResume]() + } + + // We do not destroy the socket as we can continue using the session + // the stream gets destroyed and the session remains to create new streams + util.destroy(body, err) + } + + try { + // We are already connected, streams are pending. + // We can call on connect, and wait for abort + request.onConnect(abort) + } catch (err) { + util.errorRequest(client, request, err) + } + + if (request.aborted) { + return false + } + + if (method === 'CONNECT') { + session.ref() + // We are already connected, streams are pending, first request + // will create a new stream. We trigger a request to create the stream and wait until + // `ready` event is triggered + // We disabled endStream to allow the user to write to the stream + stream = session.request(headers, { endStream: false, signal }) + + if (!stream.pending) { + request.onUpgrade(null, null, stream) + ++session[kOpenStreams] + client[kQueue][client[kRunningIdx]++] = null + } else { + stream.once('ready', () => { + request.onUpgrade(null, null, stream) + ++session[kOpenStreams] + client[kQueue][client[kRunningIdx]++] = null + }) + } + + stream.once('close', () => { + session[kOpenStreams] -= 1 + if (session[kOpenStreams] === 0) session.unref() + }) + stream.setTimeout(requestTimeout) + + return true + } + + // https://tools.ietf.org/html/rfc7540#section-8.3 + // :path and :scheme headers must be omitted when sending CONNECT + + headers[HTTP2_HEADER_PATH] = path + headers[HTTP2_HEADER_SCHEME] = 'https' + + // https://tools.ietf.org/html/rfc7231#section-4.3.1 + // https://tools.ietf.org/html/rfc7231#section-4.3.2 + // https://tools.ietf.org/html/rfc7231#section-4.3.5 + + // Sending a payload body on a request that does not + // expect it can cause undefined behavior on some + // servers and corrupt connection state. Do not + // re-use the connection for further requests. + + const expectsPayload = ( + method === 'PUT' || + method === 'POST' || + method === 'PATCH' + ) + + if (body && typeof body.read === 'function') { + // Try to read EOF in order to get length. + body.read(0) + } + + let contentLength = util.bodyLength(body) + + if (util.isFormDataLike(body)) { + extractBody ??= require('../web/fetch/body.js').extractBody + + const [bodyStream, contentType] = extractBody(body) + headers['content-type'] = contentType + + body = bodyStream.stream + contentLength = bodyStream.length + } + + if (contentLength == null) { + contentLength = request.contentLength + } + + if (contentLength === 0 || !expectsPayload) { + // https://tools.ietf.org/html/rfc7230#section-3.3.2 + // A user agent SHOULD NOT send a Content-Length header field when + // the request message does not contain a payload body and the method + // semantics do not anticipate such a body. + + contentLength = null + } + + // https://github.com/nodejs/undici/issues/2046 + // A user agent may send a Content-Length header with 0 value, this should be allowed. + if (shouldSendContentLength(method) && contentLength > 0 && request.contentLength != null && request.contentLength !== contentLength) { + if (client[kStrictContentLength]) { + util.errorRequest(client, request, new RequestContentLengthMismatchError()) + return false + } + + process.emitWarning(new RequestContentLengthMismatchError()) + } + + if (contentLength != null) { + assert(body, 'no body must not have content length') + headers[HTTP2_HEADER_CONTENT_LENGTH] = `${contentLength}` + } + + session.ref() + + if (channels.sendHeaders.hasSubscribers) { + let header = '' + for (const key in headers) { + header += `${key}: ${headers[key]}\r\n` + } + channels.sendHeaders.publish({ request, headers: header, socket: session[kSocket] }) + } + + // TODO(metcoder95): add support for sending trailers + const shouldEndStream = method === 'GET' || method === 'HEAD' || body === null + if (expectContinue) { + headers[HTTP2_HEADER_EXPECT] = '100-continue' + stream = session.request(headers, { endStream: shouldEndStream, signal }) + + stream.once('continue', writeBodyH2) + } else { + stream = session.request(headers, { + endStream: shouldEndStream, + signal + }) + + writeBodyH2() + } + + // Increment counter as we have new streams open + ++session[kOpenStreams] + stream.setTimeout(requestTimeout) + + stream.once('response', headers => { + const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers + request.onResponseStarted() + + // Due to the stream nature, it is possible we face a race condition + // where the stream has been assigned, but the request has been aborted + // the request remains in-flight and headers hasn't been received yet + // for those scenarios, best effort is to destroy the stream immediately + // as there's no value to keep it open. + if (request.aborted) { + stream.removeAllListeners('data') + return + } + + if (request.onHeaders(Number(statusCode), parseH2Headers(realHeaders), stream.resume.bind(stream), '') === false) { + stream.pause() + } + }) + + stream.on('data', (chunk) => { + if (request.onData(chunk) === false) { + stream.pause() + } + }) + + stream.once('end', (err) => { + stream.removeAllListeners('data') + // When state is null, it means we haven't consumed body and the stream still do not have + // a state. + // Present specially when using pipeline or stream + if (stream.state?.state == null || stream.state.state < 6) { + // Do not complete the request if it was aborted + // Not prone to happen for as safety net to avoid race conditions with 'trailers' + if (!request.aborted && !request.completed) { + request.onComplete({}) + } + + client[kQueue][client[kRunningIdx]++] = null + client[kResume]() + } else { + // Stream is closed or half-closed-remote (6), decrement counter and cleanup + // It does not have sense to continue working with the stream as we do not + // have yet RST_STREAM support on client-side + --session[kOpenStreams] + if (session[kOpenStreams] === 0) { + session.unref() + } + + abort(err ?? new InformationalError('HTTP/2: stream half-closed (remote)')) + client[kQueue][client[kRunningIdx]++] = null + client[kPendingIdx] = client[kRunningIdx] + client[kResume]() + } + }) + + stream.once('close', () => { + stream.removeAllListeners('data') + session[kOpenStreams] -= 1 + if (session[kOpenStreams] === 0) { + session.unref() + } + }) + + stream.once('error', function (err) { + stream.removeAllListeners('data') + abort(err) + }) + + stream.once('frameError', (type, code) => { + stream.removeAllListeners('data') + abort(new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`)) + }) + + stream.on('aborted', () => { + stream.removeAllListeners('data') + }) + + stream.on('timeout', () => { + const err = new InformationalError(`HTTP/2: "stream timeout after ${requestTimeout}"`) + stream.removeAllListeners('data') + session[kOpenStreams] -= 1 + + if (session[kOpenStreams] === 0) { + session.unref() + } + + abort(err) + }) + + stream.once('trailers', trailers => { + if (request.aborted || request.completed) { + return + } + + request.onComplete(trailers) + }) + + return true + + function writeBodyH2 () { + /* istanbul ignore else: assertion */ + if (!body || contentLength === 0) { + writeBuffer( + abort, + stream, + null, + client, + request, + client[kSocket], + contentLength, + expectsPayload + ) + } else if (util.isBuffer(body)) { + writeBuffer( + abort, + stream, + body, + client, + request, + client[kSocket], + contentLength, + expectsPayload + ) + } else if (util.isBlobLike(body)) { + if (typeof body.stream === 'function') { + writeIterable( + abort, + stream, + body.stream(), + client, + request, + client[kSocket], + contentLength, + expectsPayload + ) + } else { + writeBlob( + abort, + stream, + body, + client, + request, + client[kSocket], + contentLength, + expectsPayload + ) + } + } else if (util.isStream(body)) { + writeStream( + abort, + client[kSocket], + expectsPayload, + stream, + body, + client, + request, + contentLength + ) + } else if (util.isIterable(body)) { + writeIterable( + abort, + stream, + body, + client, + request, + client[kSocket], + contentLength, + expectsPayload + ) + } else { + assert(false) + } + } +} + +function writeBuffer (abort, h2stream, body, client, request, socket, contentLength, expectsPayload) { + try { + if (body != null && util.isBuffer(body)) { + assert(contentLength === body.byteLength, 'buffer body must have content length') + h2stream.cork() + h2stream.write(body) + h2stream.uncork() + h2stream.end() + + request.onBodySent(body) + } + + if (!expectsPayload) { + socket[kReset] = true + } + + request.onRequestSent() + client[kResume]() + } catch (error) { + abort(error) + } +} + +function writeStream (abort, socket, expectsPayload, h2stream, body, client, request, contentLength) { + assert(contentLength !== 0 || client[kRunning] === 0, 'stream body cannot be pipelined') + + // For HTTP/2, is enough to pipe the stream + const pipe = pipeline( + body, + h2stream, + (err) => { + if (err) { + util.destroy(pipe, err) + abort(err) + } else { + util.removeAllListeners(pipe) + request.onRequestSent() + + if (!expectsPayload) { + socket[kReset] = true + } + + client[kResume]() + } + } + ) + + util.addListener(pipe, 'data', onPipeData) + + function onPipeData (chunk) { + request.onBodySent(chunk) + } +} + +async function writeBlob (abort, h2stream, body, client, request, socket, contentLength, expectsPayload) { + assert(contentLength === body.size, 'blob body must have content length') + + try { + if (contentLength != null && contentLength !== body.size) { + throw new RequestContentLengthMismatchError() + } + + const buffer = Buffer.from(await body.arrayBuffer()) + + h2stream.cork() + h2stream.write(buffer) + h2stream.uncork() + h2stream.end() + + request.onBodySent(buffer) + request.onRequestSent() + + if (!expectsPayload) { + socket[kReset] = true + } + + client[kResume]() + } catch (err) { + abort(err) + } +} + +async function writeIterable (abort, h2stream, body, client, request, socket, contentLength, expectsPayload) { + assert(contentLength !== 0 || client[kRunning] === 0, 'iterator body cannot be pipelined') + + let callback = null + function onDrain () { + if (callback) { + const cb = callback + callback = null + cb() + } + } + + const waitForDrain = () => new Promise((resolve, reject) => { + assert(callback === null) + + if (socket[kError]) { + reject(socket[kError]) + } else { + callback = resolve + } + }) + + h2stream + .on('close', onDrain) + .on('drain', onDrain) + + try { + // It's up to the user to somehow abort the async iterable. + for await (const chunk of body) { + if (socket[kError]) { + throw socket[kError] + } + + const res = h2stream.write(chunk) + request.onBodySent(chunk) + if (!res) { + await waitForDrain() + } + } + + h2stream.end() + + request.onRequestSent() + + if (!expectsPayload) { + socket[kReset] = true + } + + client[kResume]() + } catch (err) { + abort(err) + } finally { + h2stream + .off('close', onDrain) + .off('drain', onDrain) + } +} + +module.exports = connectH2 diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js new file mode 100644 index 0000000..3939b89 --- /dev/null +++ b/lib/dispatcher/client.js @@ -0,0 +1,609 @@ +'use strict' + +const assert = require('node:assert') +const net = require('node:net') +const http = require('node:http') +const util = require('../core/util.js') +const { channels } = require('../core/diagnostics.js') +const Request = require('../core/request.js') +const DispatcherBase = require('./dispatcher-base') +const { + InvalidArgumentError, + InformationalError, + ClientDestroyedError +} = require('../core/errors.js') +const buildConnector = require('../core/connect.js') +const { + kUrl, + kServerName, + kClient, + kBusy, + kConnect, + kResuming, + kRunning, + kPending, + kSize, + kQueue, + kConnected, + kConnecting, + kNeedDrain, + kKeepAliveDefaultTimeout, + kHostHeader, + kPendingIdx, + kRunningIdx, + kError, + kPipelining, + kKeepAliveTimeoutValue, + kMaxHeadersSize, + kKeepAliveMaxTimeout, + kKeepAliveTimeoutThreshold, + kHeadersTimeout, + kBodyTimeout, + kStrictContentLength, + kConnector, + kMaxRequests, + kCounter, + kClose, + kDestroy, + kDispatch, + kLocalAddress, + kMaxResponseSize, + kOnError, + kHTTPContext, + kMaxConcurrentStreams, + kResume +} = require('../core/symbols.js') +const connectH1 = require('./client-h1.js') +const connectH2 = require('./client-h2.js') + +const kClosedResolve = Symbol('kClosedResolve') + +const getDefaultNodeMaxHeaderSize = http && + http.maxHeaderSize && + Number.isInteger(http.maxHeaderSize) && + http.maxHeaderSize > 0 + ? () => http.maxHeaderSize + : () => { throw new InvalidArgumentError('http module not available or http.maxHeaderSize invalid') } + +const noop = () => {} + +function getPipelining (client) { + return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1 +} + +/** + * @type {import('../../types/client.js').default} + */ +class Client extends DispatcherBase { + /** + * + * @param {string|URL} url + * @param {import('../../types/client.js').Client.Options} options + */ + constructor (url, { + maxHeaderSize, + headersTimeout, + socketTimeout, + requestTimeout, + connectTimeout, + bodyTimeout, + idleTimeout, + keepAlive, + keepAliveTimeout, + maxKeepAliveTimeout, + keepAliveMaxTimeout, + keepAliveTimeoutThreshold, + socketPath, + pipelining, + tls, + strictContentLength, + maxCachedSessions, + connect, + maxRequestsPerClient, + localAddress, + maxResponseSize, + autoSelectFamily, + autoSelectFamilyAttemptTimeout, + // h2 + maxConcurrentStreams, + allowH2 + } = {}) { + if (keepAlive !== undefined) { + throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') + } + + if (socketTimeout !== undefined) { + throw new InvalidArgumentError('unsupported socketTimeout, use headersTimeout & bodyTimeout instead') + } + + if (requestTimeout !== undefined) { + throw new InvalidArgumentError('unsupported requestTimeout, use headersTimeout & bodyTimeout instead') + } + + if (idleTimeout !== undefined) { + throw new InvalidArgumentError('unsupported idleTimeout, use keepAliveTimeout instead') + } + + if (maxKeepAliveTimeout !== undefined) { + throw new InvalidArgumentError('unsupported maxKeepAliveTimeout, use keepAliveMaxTimeout instead') + } + + if (maxHeaderSize != null) { + if (!Number.isInteger(maxHeaderSize) || maxHeaderSize < 1) { + throw new InvalidArgumentError('invalid maxHeaderSize') + } + } else { + // If maxHeaderSize is not provided, use the default value from the http module + // or if that is not available, throw an error. + maxHeaderSize = getDefaultNodeMaxHeaderSize() + } + + if (socketPath != null && typeof socketPath !== 'string') { + throw new InvalidArgumentError('invalid socketPath') + } + + if (connectTimeout != null && (!Number.isFinite(connectTimeout) || connectTimeout < 0)) { + throw new InvalidArgumentError('invalid connectTimeout') + } + + if (keepAliveTimeout != null && (!Number.isFinite(keepAliveTimeout) || keepAliveTimeout <= 0)) { + throw new InvalidArgumentError('invalid keepAliveTimeout') + } + + if (keepAliveMaxTimeout != null && (!Number.isFinite(keepAliveMaxTimeout) || keepAliveMaxTimeout <= 0)) { + throw new InvalidArgumentError('invalid keepAliveMaxTimeout') + } + + if (keepAliveTimeoutThreshold != null && !Number.isFinite(keepAliveTimeoutThreshold)) { + throw new InvalidArgumentError('invalid keepAliveTimeoutThreshold') + } + + if (headersTimeout != null && (!Number.isInteger(headersTimeout) || headersTimeout < 0)) { + throw new InvalidArgumentError('headersTimeout must be a positive integer or zero') + } + + if (bodyTimeout != null && (!Number.isInteger(bodyTimeout) || bodyTimeout < 0)) { + throw new InvalidArgumentError('bodyTimeout must be a positive integer or zero') + } + + if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') { + throw new InvalidArgumentError('connect must be a function or an object') + } + + if (maxRequestsPerClient != null && (!Number.isInteger(maxRequestsPerClient) || maxRequestsPerClient < 0)) { + throw new InvalidArgumentError('maxRequestsPerClient must be a positive number') + } + + if (localAddress != null && (typeof localAddress !== 'string' || net.isIP(localAddress) === 0)) { + throw new InvalidArgumentError('localAddress must be valid string IP address') + } + + if (maxResponseSize != null && (!Number.isInteger(maxResponseSize) || maxResponseSize < -1)) { + throw new InvalidArgumentError('maxResponseSize must be a positive number') + } + + if ( + autoSelectFamilyAttemptTimeout != null && + (!Number.isInteger(autoSelectFamilyAttemptTimeout) || autoSelectFamilyAttemptTimeout < -1) + ) { + throw new InvalidArgumentError('autoSelectFamilyAttemptTimeout must be a positive number') + } + + // h2 + if (allowH2 != null && typeof allowH2 !== 'boolean') { + throw new InvalidArgumentError('allowH2 must be a valid boolean value') + } + + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { + throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0') + } + + super() + + if (typeof connect !== 'function') { + connect = buildConnector({ + ...tls, + maxCachedSessions, + allowH2, + socketPath, + timeout: connectTimeout, + ...(autoSelectFamily ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), + ...connect + }) + } + + this[kUrl] = util.parseOrigin(url) + this[kConnector] = connect + this[kPipelining] = pipelining != null ? pipelining : 1 + this[kMaxHeadersSize] = maxHeaderSize + this[kKeepAliveDefaultTimeout] = keepAliveTimeout == null ? 4e3 : keepAliveTimeout + this[kKeepAliveMaxTimeout] = keepAliveMaxTimeout == null ? 600e3 : keepAliveMaxTimeout + this[kKeepAliveTimeoutThreshold] = keepAliveTimeoutThreshold == null ? 2e3 : keepAliveTimeoutThreshold + this[kKeepAliveTimeoutValue] = this[kKeepAliveDefaultTimeout] + this[kServerName] = null + this[kLocalAddress] = localAddress != null ? localAddress : null + this[kResuming] = 0 // 0, idle, 1, scheduled, 2 resuming + this[kNeedDrain] = 0 // 0, idle, 1, scheduled, 2 resuming + this[kHostHeader] = `host: ${this[kUrl].hostname}${this[kUrl].port ? `:${this[kUrl].port}` : ''}\r\n` + this[kBodyTimeout] = bodyTimeout != null ? bodyTimeout : 300e3 + this[kHeadersTimeout] = headersTimeout != null ? headersTimeout : 300e3 + this[kStrictContentLength] = strictContentLength == null ? true : strictContentLength + this[kMaxRequests] = maxRequestsPerClient + this[kClosedResolve] = null + this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1 + this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server + this[kHTTPContext] = null + + // kQueue is built up of 3 sections separated by + // the kRunningIdx and kPendingIdx indices. + // | complete | running | pending | + // ^ kRunningIdx ^ kPendingIdx ^ kQueue.length + // kRunningIdx points to the first running element. + // kPendingIdx points to the first pending element. + // This implements a fast queue with an amortized + // time of O(1). + + this[kQueue] = [] + this[kRunningIdx] = 0 + this[kPendingIdx] = 0 + + this[kResume] = (sync) => resume(this, sync) + this[kOnError] = (err) => onError(this, err) + } + + get pipelining () { + return this[kPipelining] + } + + set pipelining (value) { + this[kPipelining] = value + this[kResume](true) + } + + get [kPending] () { + return this[kQueue].length - this[kPendingIdx] + } + + get [kRunning] () { + return this[kPendingIdx] - this[kRunningIdx] + } + + get [kSize] () { + return this[kQueue].length - this[kRunningIdx] + } + + get [kConnected] () { + return !!this[kHTTPContext] && !this[kConnecting] && !this[kHTTPContext].destroyed + } + + get [kBusy] () { + return Boolean( + this[kHTTPContext]?.busy(null) || + (this[kSize] >= (getPipelining(this) || 1)) || + this[kPending] > 0 + ) + } + + /* istanbul ignore: only used for test */ + [kConnect] (cb) { + connect(this) + this.once('connect', cb) + } + + [kDispatch] (opts, handler) { + const origin = opts.origin || this[kUrl].origin + const request = new Request(origin, opts, handler) + + this[kQueue].push(request) + if (this[kResuming]) { + // Do nothing. + } else if (util.bodyLength(request.body) == null && util.isIterable(request.body)) { + // Wait a tick in case stream/iterator is ended in the same tick. + this[kResuming] = 1 + queueMicrotask(() => resume(this)) + } else { + this[kResume](true) + } + + if (this[kResuming] && this[kNeedDrain] !== 2 && this[kBusy]) { + this[kNeedDrain] = 2 + } + + return this[kNeedDrain] < 2 + } + + async [kClose] () { + // TODO: for H2 we need to gracefully flush the remaining enqueued + // request and close each stream. + return new Promise((resolve) => { + if (this[kSize]) { + this[kClosedResolve] = resolve + } else { + resolve(null) + } + }) + } + + async [kDestroy] (err) { + return new Promise((resolve) => { + const requests = this[kQueue].splice(this[kPendingIdx]) + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + util.errorRequest(this, request, err) + } + + const callback = () => { + if (this[kClosedResolve]) { + // TODO (fix): Should we error here with ClientDestroyedError? + this[kClosedResolve]() + this[kClosedResolve] = null + } + resolve(null) + } + + if (this[kHTTPContext]) { + this[kHTTPContext].destroy(err, callback) + this[kHTTPContext] = null + } else { + queueMicrotask(callback) + } + + this[kResume]() + }) + } +} + +function onError (client, err) { + if ( + client[kRunning] === 0 && + err.code !== 'UND_ERR_INFO' && + err.code !== 'UND_ERR_SOCKET' + ) { + // Error is not caused by running request and not a recoverable + // socket error. + + assert(client[kPendingIdx] === client[kRunningIdx]) + + const requests = client[kQueue].splice(client[kRunningIdx]) + + for (let i = 0; i < requests.length; i++) { + const request = requests[i] + util.errorRequest(client, request, err) + } + assert(client[kSize] === 0) + } +} + +/** + * @param {Client} client + * @returns + */ +async function connect (client) { + assert(!client[kConnecting]) + assert(!client[kHTTPContext]) + + let { host, hostname, protocol, port } = client[kUrl] + + // Resolve ipv6 + if (hostname[0] === '[') { + const idx = hostname.indexOf(']') + + assert(idx !== -1) + const ip = hostname.substring(1, idx) + + assert(net.isIPv6(ip)) + hostname = ip + } + + client[kConnecting] = true + + if (channels.beforeConnect.hasSubscribers) { + channels.beforeConnect.publish({ + connectParams: { + host, + hostname, + protocol, + port, + version: client[kHTTPContext]?.version, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, + connector: client[kConnector] + }) + } + + try { + const socket = await new Promise((resolve, reject) => { + client[kConnector]({ + host, + hostname, + protocol, + port, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, (err, socket) => { + if (err) { + reject(err) + } else { + resolve(socket) + } + }) + }) + + if (client.destroyed) { + util.destroy(socket.on('error', noop), new ClientDestroyedError()) + return + } + + assert(socket) + + try { + client[kHTTPContext] = socket.alpnProtocol === 'h2' + ? await connectH2(client, socket) + : await connectH1(client, socket) + } catch (err) { + socket.destroy().on('error', noop) + throw err + } + + client[kConnecting] = false + + socket[kCounter] = 0 + socket[kMaxRequests] = client[kMaxRequests] + socket[kClient] = client + socket[kError] = null + + if (channels.connected.hasSubscribers) { + channels.connected.publish({ + connectParams: { + host, + hostname, + protocol, + port, + version: client[kHTTPContext]?.version, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, + connector: client[kConnector], + socket + }) + } + client.emit('connect', client[kUrl], [client]) + } catch (err) { + if (client.destroyed) { + return + } + + client[kConnecting] = false + + if (channels.connectError.hasSubscribers) { + channels.connectError.publish({ + connectParams: { + host, + hostname, + protocol, + port, + version: client[kHTTPContext]?.version, + servername: client[kServerName], + localAddress: client[kLocalAddress] + }, + connector: client[kConnector], + error: err + }) + } + + if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') { + assert(client[kRunning] === 0) + while (client[kPending] > 0 && client[kQueue][client[kPendingIdx]].servername === client[kServerName]) { + const request = client[kQueue][client[kPendingIdx]++] + util.errorRequest(client, request, err) + } + } else { + onError(client, err) + } + + client.emit('connectionError', client[kUrl], [client], err) + } + + client[kResume]() +} + +function emitDrain (client) { + client[kNeedDrain] = 0 + client.emit('drain', client[kUrl], [client]) +} + +function resume (client, sync) { + if (client[kResuming] === 2) { + return + } + + client[kResuming] = 2 + + _resume(client, sync) + client[kResuming] = 0 + + if (client[kRunningIdx] > 256) { + client[kQueue].splice(0, client[kRunningIdx]) + client[kPendingIdx] -= client[kRunningIdx] + client[kRunningIdx] = 0 + } +} + +function _resume (client, sync) { + while (true) { + if (client.destroyed) { + assert(client[kPending] === 0) + return + } + + if (client[kClosedResolve] && !client[kSize]) { + client[kClosedResolve]() + client[kClosedResolve] = null + return + } + + if (client[kHTTPContext]) { + client[kHTTPContext].resume() + } + + if (client[kBusy]) { + client[kNeedDrain] = 2 + } else if (client[kNeedDrain] === 2) { + if (sync) { + client[kNeedDrain] = 1 + queueMicrotask(() => emitDrain(client)) + } else { + emitDrain(client) + } + continue + } + + if (client[kPending] === 0) { + return + } + + if (client[kRunning] >= (getPipelining(client) || 1)) { + return + } + + const request = client[kQueue][client[kPendingIdx]] + + if (client[kUrl].protocol === 'https:' && client[kServerName] !== request.servername) { + if (client[kRunning] > 0) { + return + } + + client[kServerName] = request.servername + client[kHTTPContext]?.destroy(new InformationalError('servername changed'), () => { + client[kHTTPContext] = null + resume(client) + }) + } + + if (client[kConnecting]) { + return + } + + if (!client[kHTTPContext]) { + connect(client) + return + } + + if (client[kHTTPContext].destroyed) { + return + } + + if (client[kHTTPContext].busy(request)) { + return + } + + if (!request.aborted && client[kHTTPContext].write(request)) { + client[kPendingIdx]++ + } else { + client[kQueue].splice(client[kPendingIdx], 1) + } + } +} + +module.exports = Client diff --git a/lib/dispatcher/dispatcher-base.js b/lib/dispatcher/dispatcher-base.js new file mode 100644 index 0000000..615754d --- /dev/null +++ b/lib/dispatcher/dispatcher-base.js @@ -0,0 +1,161 @@ +'use strict' + +const Dispatcher = require('./dispatcher') +const UnwrapHandler = require('../handler/unwrap-handler') +const { + ClientDestroyedError, + ClientClosedError, + InvalidArgumentError +} = require('../core/errors') +const { kDestroy, kClose, kClosed, kDestroyed, kDispatch } = require('../core/symbols') + +const kOnDestroyed = Symbol('onDestroyed') +const kOnClosed = Symbol('onClosed') + +class DispatcherBase extends Dispatcher { + constructor () { + super() + + this[kDestroyed] = false + this[kOnDestroyed] = null + this[kClosed] = false + this[kOnClosed] = [] + } + + get destroyed () { + return this[kDestroyed] + } + + get closed () { + return this[kClosed] + } + + close (callback) { + if (callback === undefined) { + return new Promise((resolve, reject) => { + this.close((err, data) => { + return err ? reject(err) : resolve(data) + }) + }) + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (this[kDestroyed]) { + queueMicrotask(() => callback(new ClientDestroyedError(), null)) + return + } + + if (this[kClosed]) { + if (this[kOnClosed]) { + this[kOnClosed].push(callback) + } else { + queueMicrotask(() => callback(null, null)) + } + return + } + + this[kClosed] = true + this[kOnClosed].push(callback) + + const onClosed = () => { + const callbacks = this[kOnClosed] + this[kOnClosed] = null + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](null, null) + } + } + + // Should not error. + this[kClose]() + .then(() => this.destroy()) + .then(() => { + queueMicrotask(onClosed) + }) + } + + destroy (err, callback) { + if (typeof err === 'function') { + callback = err + err = null + } + + if (callback === undefined) { + return new Promise((resolve, reject) => { + this.destroy(err, (err, data) => { + return err ? /* istanbul ignore next: should never error */ reject(err) : resolve(data) + }) + }) + } + + if (typeof callback !== 'function') { + throw new InvalidArgumentError('invalid callback') + } + + if (this[kDestroyed]) { + if (this[kOnDestroyed]) { + this[kOnDestroyed].push(callback) + } else { + queueMicrotask(() => callback(null, null)) + } + return + } + + if (!err) { + err = new ClientDestroyedError() + } + + this[kDestroyed] = true + this[kOnDestroyed] = this[kOnDestroyed] || [] + this[kOnDestroyed].push(callback) + + const onDestroyed = () => { + const callbacks = this[kOnDestroyed] + this[kOnDestroyed] = null + for (let i = 0; i < callbacks.length; i++) { + callbacks[i](null, null) + } + } + + // Should not error. + this[kDestroy](err).then(() => { + queueMicrotask(onDestroyed) + }) + } + + dispatch (opts, handler) { + if (!handler || typeof handler !== 'object') { + throw new InvalidArgumentError('handler must be an object') + } + + handler = UnwrapHandler.unwrap(handler) + + try { + if (!opts || typeof opts !== 'object') { + throw new InvalidArgumentError('opts must be an object.') + } + + if (this[kDestroyed] || this[kOnDestroyed]) { + throw new ClientDestroyedError() + } + + if (this[kClosed]) { + throw new ClientClosedError() + } + + return this[kDispatch](opts, handler) + } catch (err) { + if (typeof handler.onError !== 'function') { + throw err + } + + handler.onError(err) + + return false + } + } +} + +module.exports = DispatcherBase diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js new file mode 100644 index 0000000..824dfb6 --- /dev/null +++ b/lib/dispatcher/dispatcher.js @@ -0,0 +1,48 @@ +'use strict' +const EventEmitter = require('node:events') +const WrapHandler = require('../handler/wrap-handler') + +const wrapInterceptor = (dispatch) => (opts, handler) => dispatch(opts, WrapHandler.wrap(handler)) + +class Dispatcher extends EventEmitter { + dispatch () { + throw new Error('not implemented') + } + + close () { + throw new Error('not implemented') + } + + destroy () { + throw new Error('not implemented') + } + + compose (...args) { + // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ... + const interceptors = Array.isArray(args[0]) ? args[0] : args + let dispatch = this.dispatch.bind(this) + + for (const interceptor of interceptors) { + if (interceptor == null) { + continue + } + + if (typeof interceptor !== 'function') { + throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`) + } + + dispatch = interceptor(dispatch) + dispatch = wrapInterceptor(dispatch) + + if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) { + throw new TypeError('invalid interceptor') + } + } + + return new Proxy(this, { + get: (target, key) => key === 'dispatch' ? dispatch : target[key] + }) + } +} + +module.exports = Dispatcher diff --git a/lib/dispatcher/env-http-proxy-agent.js b/lib/dispatcher/env-http-proxy-agent.js new file mode 100644 index 0000000..897011a --- /dev/null +++ b/lib/dispatcher/env-http-proxy-agent.js @@ -0,0 +1,160 @@ +'use strict' + +const DispatcherBase = require('./dispatcher-base') +const { kClose, kDestroy, kClosed, kDestroyed, kDispatch, kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent } = require('../core/symbols') +const ProxyAgent = require('./proxy-agent') +const Agent = require('./agent') + +const DEFAULT_PORTS = { + 'http:': 80, + 'https:': 443 +} + +let experimentalWarned = false + +class EnvHttpProxyAgent extends DispatcherBase { + #noProxyValue = null + #noProxyEntries = null + #opts = null + + constructor (opts = {}) { + super() + this.#opts = opts + + if (!experimentalWarned) { + experimentalWarned = true + process.emitWarning('EnvHttpProxyAgent is experimental, expect them to change at any time.', { + code: 'UNDICI-EHPA' + }) + } + + const { httpProxy, httpsProxy, noProxy, ...agentOpts } = opts + + this[kNoProxyAgent] = new Agent(agentOpts) + + const HTTP_PROXY = httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY + if (HTTP_PROXY) { + this[kHttpProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTP_PROXY }) + } else { + this[kHttpProxyAgent] = this[kNoProxyAgent] + } + + const HTTPS_PROXY = httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY + if (HTTPS_PROXY) { + this[kHttpsProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTPS_PROXY }) + } else { + this[kHttpsProxyAgent] = this[kHttpProxyAgent] + } + + this.#parseNoProxy() + } + + [kDispatch] (opts, handler) { + const url = new URL(opts.origin) + const agent = this.#getProxyAgentForUrl(url) + return agent.dispatch(opts, handler) + } + + async [kClose] () { + await this[kNoProxyAgent].close() + if (!this[kHttpProxyAgent][kClosed]) { + await this[kHttpProxyAgent].close() + } + if (!this[kHttpsProxyAgent][kClosed]) { + await this[kHttpsProxyAgent].close() + } + } + + async [kDestroy] (err) { + await this[kNoProxyAgent].destroy(err) + if (!this[kHttpProxyAgent][kDestroyed]) { + await this[kHttpProxyAgent].destroy(err) + } + if (!this[kHttpsProxyAgent][kDestroyed]) { + await this[kHttpsProxyAgent].destroy(err) + } + } + + #getProxyAgentForUrl (url) { + let { protocol, host: hostname, port } = url + + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, '').toLowerCase() + port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0 + if (!this.#shouldProxy(hostname, port)) { + return this[kNoProxyAgent] + } + if (protocol === 'https:') { + return this[kHttpsProxyAgent] + } + return this[kHttpProxyAgent] + } + + #shouldProxy (hostname, port) { + if (this.#noProxyChanged) { + this.#parseNoProxy() + } + + if (this.#noProxyEntries.length === 0) { + return true // Always proxy if NO_PROXY is not set or empty. + } + if (this.#noProxyValue === '*') { + return false // Never proxy if wildcard is set. + } + + for (let i = 0; i < this.#noProxyEntries.length; i++) { + const entry = this.#noProxyEntries[i] + if (entry.port && entry.port !== port) { + continue // Skip if ports don't match. + } + if (!/^[.*]/.test(entry.hostname)) { + // No wildcards, so don't proxy only if there is not an exact match. + if (hostname === entry.hostname) { + return false + } + } else { + // Don't proxy if the hostname ends with the no_proxy host. + if (hostname.endsWith(entry.hostname.replace(/^\*/, ''))) { + return false + } + } + } + + return true + } + + #parseNoProxy () { + const noProxyValue = this.#opts.noProxy ?? this.#noProxyEnv + const noProxySplit = noProxyValue.split(/[,\s]/) + const noProxyEntries = [] + + for (let i = 0; i < noProxySplit.length; i++) { + const entry = noProxySplit[i] + if (!entry) { + continue + } + const parsed = entry.match(/^(.+):(\d+)$/) + noProxyEntries.push({ + hostname: (parsed ? parsed[1] : entry).toLowerCase(), + port: parsed ? Number.parseInt(parsed[2], 10) : 0 + }) + } + + this.#noProxyValue = noProxyValue + this.#noProxyEntries = noProxyEntries + } + + get #noProxyChanged () { + if (this.#opts.noProxy !== undefined) { + return false + } + return this.#noProxyValue !== this.#noProxyEnv + } + + get #noProxyEnv () { + return process.env.no_proxy ?? process.env.NO_PROXY ?? '' + } +} + +module.exports = EnvHttpProxyAgent diff --git a/lib/dispatcher/fixed-queue.js b/lib/dispatcher/fixed-queue.js new file mode 100644 index 0000000..5f7a08b --- /dev/null +++ b/lib/dispatcher/fixed-queue.js @@ -0,0 +1,159 @@ +'use strict' + +// Extracted from node/lib/internal/fixed_queue.js + +// Currently optimal queue size, tested on V8 6.0 - 6.6. Must be power of two. +const kSize = 2048 +const kMask = kSize - 1 + +// The FixedQueue is implemented as a singly-linked list of fixed-size +// circular buffers. It looks something like this: +// +// head tail +// | | +// v v +// +-----------+ <-----\ +-----------+ <------\ +-----------+ +// | [null] | \----- | next | \------- | next | +// +-----------+ +-----------+ +-----------+ +// | item | <-- bottom | item | <-- bottom | undefined | +// | item | | item | | undefined | +// | item | | item | | undefined | +// | item | | item | | undefined | +// | item | | item | bottom --> | item | +// | item | | item | | item | +// | ... | | ... | | ... | +// | item | | item | | item | +// | item | | item | | item | +// | undefined | <-- top | item | | item | +// | undefined | | item | | item | +// | undefined | | undefined | <-- top top --> | undefined | +// +-----------+ +-----------+ +-----------+ +// +// Or, if there is only one circular buffer, it looks something +// like either of these: +// +// head tail head tail +// | | | | +// v v v v +// +-----------+ +-----------+ +// | [null] | | [null] | +// +-----------+ +-----------+ +// | undefined | | item | +// | undefined | | item | +// | item | <-- bottom top --> | undefined | +// | item | | undefined | +// | undefined | <-- top bottom --> | item | +// | undefined | | item | +// +-----------+ +-----------+ +// +// Adding a value means moving `top` forward by one, removing means +// moving `bottom` forward by one. After reaching the end, the queue +// wraps around. +// +// When `top === bottom` the current queue is empty and when +// `top + 1 === bottom` it's full. This wastes a single space of storage +// but allows much quicker checks. + +/** + * @type {FixedCircularBuffer} + * @template T + */ +class FixedCircularBuffer { + constructor () { + /** + * @type {number} + */ + this.bottom = 0 + /** + * @type {number} + */ + this.top = 0 + /** + * @type {Array} + */ + this.list = new Array(kSize).fill(undefined) + /** + * @type {T|null} + */ + this.next = null + } + + /** + * @returns {boolean} + */ + isEmpty () { + return this.top === this.bottom + } + + /** + * @returns {boolean} + */ + isFull () { + return ((this.top + 1) & kMask) === this.bottom + } + + /** + * @param {T} data + * @returns {void} + */ + push (data) { + this.list[this.top] = data + this.top = (this.top + 1) & kMask + } + + /** + * @returns {T|null} + */ + shift () { + const nextItem = this.list[this.bottom] + if (nextItem === undefined) { return null } + this.list[this.bottom] = undefined + this.bottom = (this.bottom + 1) & kMask + return nextItem + } +} + +/** + * @template T + */ +module.exports = class FixedQueue { + constructor () { + /** + * @type {FixedCircularBuffer} + */ + this.head = this.tail = new FixedCircularBuffer() + } + + /** + * @returns {boolean} + */ + isEmpty () { + return this.head.isEmpty() + } + + /** + * @param {T} data + */ + push (data) { + if (this.head.isFull()) { + // Head is full: Creates a new queue, sets the old queue's `.next` to it, + // and sets it as the new main queue. + this.head = this.head.next = new FixedCircularBuffer() + } + this.head.push(data) + } + + /** + * @returns {T|null} + */ + shift () { + const tail = this.tail + const next = tail.shift() + if (tail.isEmpty() && tail.next !== null) { + // If there is another queue, it forms the new tail. + this.tail = tail.next + tail.next = null + } + return next + } +} diff --git a/lib/dispatcher/pool-base.js b/lib/dispatcher/pool-base.js new file mode 100644 index 0000000..d0ba2c3 --- /dev/null +++ b/lib/dispatcher/pool-base.js @@ -0,0 +1,194 @@ +'use strict' + +const DispatcherBase = require('./dispatcher-base') +const FixedQueue = require('./fixed-queue') +const { kConnected, kSize, kRunning, kPending, kQueued, kBusy, kFree, kUrl, kClose, kDestroy, kDispatch } = require('../core/symbols') +const PoolStats = require('./pool-stats') + +const kClients = Symbol('clients') +const kNeedDrain = Symbol('needDrain') +const kQueue = Symbol('queue') +const kClosedResolve = Symbol('closed resolve') +const kOnDrain = Symbol('onDrain') +const kOnConnect = Symbol('onConnect') +const kOnDisconnect = Symbol('onDisconnect') +const kOnConnectionError = Symbol('onConnectionError') +const kGetDispatcher = Symbol('get dispatcher') +const kAddClient = Symbol('add client') +const kRemoveClient = Symbol('remove client') +const kStats = Symbol('stats') + +class PoolBase extends DispatcherBase { + constructor () { + super() + + this[kQueue] = new FixedQueue() + this[kClients] = [] + this[kQueued] = 0 + + const pool = this + + this[kOnDrain] = function onDrain (origin, targets) { + const queue = pool[kQueue] + + let needDrain = false + + while (!needDrain) { + const item = queue.shift() + if (!item) { + break + } + pool[kQueued]-- + needDrain = !this.dispatch(item.opts, item.handler) + } + + this[kNeedDrain] = needDrain + + if (!this[kNeedDrain] && pool[kNeedDrain]) { + pool[kNeedDrain] = false + pool.emit('drain', origin, [pool, ...targets]) + } + + if (pool[kClosedResolve] && queue.isEmpty()) { + Promise + .all(pool[kClients].map(c => c.close())) + .then(pool[kClosedResolve]) + } + } + + this[kOnConnect] = (origin, targets) => { + pool.emit('connect', origin, [pool, ...targets]) + } + + this[kOnDisconnect] = (origin, targets, err) => { + pool.emit('disconnect', origin, [pool, ...targets], err) + } + + this[kOnConnectionError] = (origin, targets, err) => { + pool.emit('connectionError', origin, [pool, ...targets], err) + } + + this[kStats] = new PoolStats(this) + } + + get [kBusy] () { + return this[kNeedDrain] + } + + get [kConnected] () { + return this[kClients].filter(client => client[kConnected]).length + } + + get [kFree] () { + return this[kClients].filter(client => client[kConnected] && !client[kNeedDrain]).length + } + + get [kPending] () { + let ret = this[kQueued] + for (const { [kPending]: pending } of this[kClients]) { + ret += pending + } + return ret + } + + get [kRunning] () { + let ret = 0 + for (const { [kRunning]: running } of this[kClients]) { + ret += running + } + return ret + } + + get [kSize] () { + let ret = this[kQueued] + for (const { [kSize]: size } of this[kClients]) { + ret += size + } + return ret + } + + get stats () { + return this[kStats] + } + + async [kClose] () { + if (this[kQueue].isEmpty()) { + await Promise.all(this[kClients].map(c => c.close())) + } else { + await new Promise((resolve) => { + this[kClosedResolve] = resolve + }) + } + } + + async [kDestroy] (err) { + while (true) { + const item = this[kQueue].shift() + if (!item) { + break + } + item.handler.onError(err) + } + + await Promise.all(this[kClients].map(c => c.destroy(err))) + } + + [kDispatch] (opts, handler) { + const dispatcher = this[kGetDispatcher]() + + if (!dispatcher) { + this[kNeedDrain] = true + this[kQueue].push({ opts, handler }) + this[kQueued]++ + } else if (!dispatcher.dispatch(opts, handler)) { + dispatcher[kNeedDrain] = true + this[kNeedDrain] = !this[kGetDispatcher]() + } + + return !this[kNeedDrain] + } + + [kAddClient] (client) { + client + .on('drain', this[kOnDrain]) + .on('connect', this[kOnConnect]) + .on('disconnect', this[kOnDisconnect]) + .on('connectionError', this[kOnConnectionError]) + + this[kClients].push(client) + + if (this[kNeedDrain]) { + queueMicrotask(() => { + if (this[kNeedDrain]) { + this[kOnDrain](client[kUrl], [this, client]) + } + }) + } + + return this + } + + [kRemoveClient] (client) { + client.close(() => { + const idx = this[kClients].indexOf(client) + if (idx !== -1) { + this[kClients].splice(idx, 1) + } + }) + + this[kNeedDrain] = this[kClients].some(dispatcher => ( + !dispatcher[kNeedDrain] && + dispatcher.closed !== true && + dispatcher.destroyed !== true + )) + } +} + +module.exports = { + PoolBase, + kClients, + kNeedDrain, + kAddClient, + kRemoveClient, + kGetDispatcher +} diff --git a/lib/dispatcher/pool-stats.js b/lib/dispatcher/pool-stats.js new file mode 100644 index 0000000..c739211 --- /dev/null +++ b/lib/dispatcher/pool-stats.js @@ -0,0 +1,36 @@ +'use strict' + +const { kFree, kConnected, kPending, kQueued, kRunning, kSize } = require('../core/symbols') +const kPool = Symbol('pool') + +class PoolStats { + constructor (pool) { + this[kPool] = pool + } + + get connected () { + return this[kPool][kConnected] + } + + get free () { + return this[kPool][kFree] + } + + get pending () { + return this[kPool][kPending] + } + + get queued () { + return this[kPool][kQueued] + } + + get running () { + return this[kPool][kRunning] + } + + get size () { + return this[kPool][kSize] + } +} + +module.exports = PoolStats diff --git a/lib/dispatcher/pool.js b/lib/dispatcher/pool.js new file mode 100644 index 0000000..0830315 --- /dev/null +++ b/lib/dispatcher/pool.js @@ -0,0 +1,90 @@ +'use strict' + +const { + PoolBase, + kClients, + kNeedDrain, + kAddClient, + kGetDispatcher +} = require('./pool-base') +const Client = require('./client') +const { + InvalidArgumentError +} = require('../core/errors') +const util = require('../core/util') +const { kUrl } = require('../core/symbols') +const buildConnector = require('../core/connect') + +const kOptions = Symbol('options') +const kConnections = Symbol('connections') +const kFactory = Symbol('factory') + +function defaultFactory (origin, opts) { + return new Client(origin, opts) +} + +class Pool extends PoolBase { + constructor (origin, { + connections, + factory = defaultFactory, + connect, + connectTimeout, + tls, + maxCachedSessions, + socketPath, + autoSelectFamily, + autoSelectFamilyAttemptTimeout, + allowH2, + ...options + } = {}) { + if (connections != null && (!Number.isFinite(connections) || connections < 0)) { + throw new InvalidArgumentError('invalid connections') + } + + if (typeof factory !== 'function') { + throw new InvalidArgumentError('factory must be a function.') + } + + if (connect != null && typeof connect !== 'function' && typeof connect !== 'object') { + throw new InvalidArgumentError('connect must be a function or an object') + } + + super() + + if (typeof connect !== 'function') { + connect = buildConnector({ + ...tls, + maxCachedSessions, + allowH2, + socketPath, + timeout: connectTimeout, + ...(autoSelectFamily ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), + ...connect + }) + } + + this[kConnections] = connections || null + this[kUrl] = util.parseOrigin(origin) + this[kOptions] = { ...util.deepClone(options), connect, allowH2 } + this[kOptions].interceptors = options.interceptors + ? { ...options.interceptors } + : undefined + this[kFactory] = factory + } + + [kGetDispatcher] () { + for (const client of this[kClients]) { + if (!client[kNeedDrain]) { + return client + } + } + + if (!this[kConnections] || this[kClients].length < this[kConnections]) { + const dispatcher = this[kFactory](this[kUrl], this[kOptions]) + this[kAddClient](dispatcher) + return dispatcher + } + } +} + +module.exports = Pool diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js new file mode 100644 index 0000000..c5b4d51 --- /dev/null +++ b/lib/dispatcher/proxy-agent.js @@ -0,0 +1,189 @@ +'use strict' + +const { kProxy, kClose, kDestroy } = require('../core/symbols') +const { URL } = require('node:url') +const Agent = require('./agent') +const Pool = require('./pool') +const DispatcherBase = require('./dispatcher-base') +const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors') +const buildConnector = require('../core/connect') + +const kAgent = Symbol('proxy agent') +const kClient = Symbol('proxy client') +const kProxyHeaders = Symbol('proxy headers') +const kRequestTls = Symbol('request tls settings') +const kProxyTls = Symbol('proxy tls settings') +const kConnectEndpoint = Symbol('connect endpoint function') + +function defaultProtocolPort (protocol) { + return protocol === 'https:' ? 443 : 80 +} + +function defaultFactory (origin, opts) { + return new Pool(origin, opts) +} + +const noop = () => {} + +class ProxyAgent extends DispatcherBase { + constructor (opts) { + if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) { + throw new InvalidArgumentError('Proxy uri is mandatory') + } + + const { clientFactory = defaultFactory } = opts + if (typeof clientFactory !== 'function') { + throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.') + } + + super() + + const url = this.#getUrl(opts) + const { href, origin, port, protocol, username, password, hostname: proxyHostname } = url + + this[kProxy] = { uri: href, protocol } + this[kRequestTls] = opts.requestTls + this[kProxyTls] = opts.proxyTls + this[kProxyHeaders] = opts.headers || {} + + if (opts.auth && opts.token) { + throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') + } else if (opts.auth) { + /* @deprecated in favour of opts.token */ + this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}` + } else if (opts.token) { + this[kProxyHeaders]['proxy-authorization'] = opts.token + } else if (username && password) { + this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}` + } + + const connect = buildConnector({ ...opts.proxyTls }) + this[kConnectEndpoint] = buildConnector({ ...opts.requestTls }) + this[kClient] = clientFactory(url, { connect }) + this[kAgent] = new Agent({ + ...opts, + connect: async (opts, callback) => { + let requestedPath = opts.host + if (!opts.port) { + requestedPath += `:${defaultProtocolPort(opts.protocol)}` + } + try { + const { socket, statusCode } = await this[kClient].connect({ + origin, + port, + path: requestedPath, + signal: opts.signal, + headers: { + ...this[kProxyHeaders], + host: opts.host + }, + servername: this[kProxyTls]?.servername || proxyHostname + }) + if (statusCode !== 200) { + socket.on('error', noop).destroy() + callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`)) + } + if (opts.protocol !== 'https:') { + callback(null, socket) + return + } + let servername + if (this[kRequestTls]) { + servername = this[kRequestTls].servername + } else { + servername = opts.servername + } + this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback) + } catch (err) { + if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') { + // Throw a custom error to avoid loop in client.js#connect + callback(new SecureProxyConnectionError(err)) + } else { + callback(err) + } + } + } + }) + } + + dispatch (opts, handler) { + const headers = buildHeaders(opts.headers) + throwIfProxyAuthIsSent(headers) + + if (headers && !('host' in headers) && !('Host' in headers)) { + const { host } = new URL(opts.origin) + headers.host = host + } + + return this[kAgent].dispatch( + { + ...opts, + headers + }, + handler + ) + } + + /** + * @param {import('../types/proxy-agent').ProxyAgent.Options | string | URL} opts + * @returns {URL} + */ + #getUrl (opts) { + if (typeof opts === 'string') { + return new URL(opts) + } else if (opts instanceof URL) { + return opts + } else { + return new URL(opts.uri) + } + } + + async [kClose] () { + await this[kAgent].close() + await this[kClient].close() + } + + async [kDestroy] () { + await this[kAgent].destroy() + await this[kClient].destroy() + } +} + +/** + * @param {string[] | Record} headers + * @returns {Record} + */ +function buildHeaders (headers) { + // When using undici.fetch, the headers list is stored + // as an array. + if (Array.isArray(headers)) { + /** @type {Record} */ + const headersPair = {} + + for (let i = 0; i < headers.length; i += 2) { + headersPair[headers[i]] = headers[i + 1] + } + + return headersPair + } + + return headers +} + +/** + * @param {Record} headers + * + * Previous versions of ProxyAgent suggests the Proxy-Authorization in request headers + * Nevertheless, it was changed and to avoid a security vulnerability by end users + * this check was created. + * It should be removed in the next major version for performance reasons + */ +function throwIfProxyAuthIsSent (headers) { + const existProxyAuth = headers && Object.keys(headers) + .find((key) => key.toLowerCase() === 'proxy-authorization') + if (existProxyAuth) { + throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor') + } +} + +module.exports = ProxyAgent diff --git a/lib/dispatcher/retry-agent.js b/lib/dispatcher/retry-agent.js new file mode 100644 index 0000000..0c2120d --- /dev/null +++ b/lib/dispatcher/retry-agent.js @@ -0,0 +1,35 @@ +'use strict' + +const Dispatcher = require('./dispatcher') +const RetryHandler = require('../handler/retry-handler') + +class RetryAgent extends Dispatcher { + #agent = null + #options = null + constructor (agent, options = {}) { + super(options) + this.#agent = agent + this.#options = options + } + + dispatch (opts, handler) { + const retry = new RetryHandler({ + ...opts, + retryOptions: this.#options + }, { + dispatch: this.#agent.dispatch.bind(this.#agent), + handler + }) + return this.#agent.dispatch(opts, retry) + } + + close () { + return this.#agent.close() + } + + destroy () { + return this.#agent.destroy() + } +} + +module.exports = RetryAgent diff --git a/lib/global.js b/lib/global.js new file mode 100644 index 0000000..0c7528f --- /dev/null +++ b/lib/global.js @@ -0,0 +1,32 @@ +'use strict' + +// We include a version number for the Dispatcher API. In case of breaking changes, +// this version number must be increased to avoid conflicts. +const globalDispatcher = Symbol.for('undici.globalDispatcher.1') +const { InvalidArgumentError } = require('./core/errors') +const Agent = require('./dispatcher/agent') + +if (getGlobalDispatcher() === undefined) { + setGlobalDispatcher(new Agent()) +} + +function setGlobalDispatcher (agent) { + if (!agent || typeof agent.dispatch !== 'function') { + throw new InvalidArgumentError('Argument agent must implement Agent') + } + Object.defineProperty(globalThis, globalDispatcher, { + value: agent, + writable: true, + enumerable: false, + configurable: false + }) +} + +function getGlobalDispatcher () { + return globalThis[globalDispatcher] +} + +module.exports = { + setGlobalDispatcher, + getGlobalDispatcher +} diff --git a/lib/handler/cache-handler.js b/lib/handler/cache-handler.js new file mode 100644 index 0000000..e02ff9c --- /dev/null +++ b/lib/handler/cache-handler.js @@ -0,0 +1,448 @@ +'use strict' + +const util = require('../core/util') +const { + parseCacheControlHeader, + parseVaryHeader, + isEtagUsable +} = require('../util/cache') +const { parseHttpDate } = require('../util/date.js') + +function noop () {} + +// Status codes that we can use some heuristics on to cache +const HEURISTICALLY_CACHEABLE_STATUS_CODES = [ + 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501 +] + +const MAX_RESPONSE_AGE = 2147483647000 + +/** + * @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler + * + * @implements {DispatchHandler} + */ +class CacheHandler { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + #cacheKey + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions['type']} + */ + #cacheType + + /** + * @type {number | undefined} + */ + #cacheByDefault + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheStore} + */ + #store + + /** + * @type {import('../../types/dispatcher.d.ts').default.DispatchHandler} + */ + #handler + + /** + * @type {import('node:stream').Writable | undefined} + */ + #writeStream + + /** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} opts + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler + */ + constructor ({ store, type, cacheByDefault }, cacheKey, handler) { + this.#store = store + this.#cacheType = type + this.#cacheByDefault = cacheByDefault + this.#cacheKey = cacheKey + this.#handler = handler + } + + onRequestStart (controller, context) { + this.#writeStream?.destroy() + this.#writeStream = undefined + this.#handler.onRequestStart?.(controller, context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + /** + * @param {import('../../types/dispatcher.d.ts').default.DispatchController} controller + * @param {number} statusCode + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {string} statusMessage + */ + onResponseStart ( + controller, + statusCode, + resHeaders, + statusMessage + ) { + const downstreamOnHeaders = () => + this.#handler.onResponseStart?.( + controller, + statusCode, + resHeaders, + statusMessage + ) + + if ( + !util.safeHTTPMethods.includes(this.#cacheKey.method) && + statusCode >= 200 && + statusCode <= 399 + ) { + // Successful response to an unsafe method, delete it from cache + // https://www.rfc-editor.org/rfc/rfc9111.html#name-invalidating-stored-response + try { + this.#store.delete(this.#cacheKey)?.catch?.(noop) + } catch { + // Fail silently + } + return downstreamOnHeaders() + } + + const cacheControlHeader = resHeaders['cache-control'] + const heuristicallyCacheable = resHeaders['last-modified'] && HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode) + if ( + !cacheControlHeader && + !resHeaders['expires'] && + !heuristicallyCacheable && + !this.#cacheByDefault + ) { + // Don't have anything to tell us this response is cachable and we're not + // caching by default + return downstreamOnHeaders() + } + + const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {} + if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives)) { + return downstreamOnHeaders() + } + + const now = Date.now() + const resAge = resHeaders.age ? getAge(resHeaders.age) : undefined + if (resAge && resAge >= MAX_RESPONSE_AGE) { + // Response considered stale + return downstreamOnHeaders() + } + + const resDate = typeof resHeaders.date === 'string' + ? parseHttpDate(resHeaders.date) + : undefined + + const staleAt = + determineStaleAt(this.#cacheType, now, resAge, resHeaders, resDate, cacheControlDirectives) ?? + this.#cacheByDefault + if (staleAt === undefined || (resAge && resAge > staleAt)) { + return downstreamOnHeaders() + } + + const baseTime = resDate ? resDate.getTime() : now + const absoluteStaleAt = staleAt + baseTime + if (now >= absoluteStaleAt) { + // Response is already stale + return downstreamOnHeaders() + } + + let varyDirectives + if (this.#cacheKey.headers && resHeaders.vary) { + varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers) + if (!varyDirectives) { + // Parse error + return downstreamOnHeaders() + } + } + + const deleteAt = determineDeleteAt(baseTime, cacheControlDirectives, absoluteStaleAt) + const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives) + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode, + statusMessage, + headers: strippedHeaders, + vary: varyDirectives, + cacheControlDirectives, + cachedAt: resAge ? now - resAge : now, + staleAt: absoluteStaleAt, + deleteAt + } + + if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) { + value.etag = resHeaders.etag + } + + this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value) + if (!this.#writeStream) { + return downstreamOnHeaders() + } + + const handler = this + this.#writeStream + .on('drain', () => controller.resume()) + .on('error', function () { + // TODO (fix): Make error somehow observable? + handler.#writeStream = undefined + + // Delete the value in case the cache store is holding onto state from + // the call to createWriteStream + handler.#store.delete(handler.#cacheKey) + }) + .on('close', function () { + if (handler.#writeStream === this) { + handler.#writeStream = undefined + } + + // TODO (fix): Should we resume even if was paused downstream? + controller.resume() + }) + + return downstreamOnHeaders() + } + + onResponseData (controller, chunk) { + if (this.#writeStream?.write(chunk) === false) { + controller.pause() + } + + this.#handler.onResponseData?.(controller, chunk) + } + + onResponseEnd (controller, trailers) { + this.#writeStream?.end() + this.#handler.onResponseEnd?.(controller, trailers) + } + + onResponseError (controller, err) { + this.#writeStream?.destroy(err) + this.#writeStream = undefined + this.#handler.onResponseError?.(controller, err) + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + * + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {number} statusCode + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + */ +function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) { + if (statusCode !== 200 && statusCode !== 307) { + return false + } + + if (cacheControlDirectives['no-store']) { + return false + } + + if (cacheType === 'shared' && cacheControlDirectives.private === true) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5 + if (resHeaders.vary?.includes('*')) { + return false + } + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen + if (resHeaders.authorization) { + if (!cacheControlDirectives.public || typeof resHeaders.authorization !== 'string') { + return false + } + + if ( + Array.isArray(cacheControlDirectives['no-cache']) && + cacheControlDirectives['no-cache'].includes('authorization') + ) { + return false + } + + if ( + Array.isArray(cacheControlDirectives['private']) && + cacheControlDirectives['private'].includes('authorization') + ) { + return false + } + } + + return true +} + +/** + * @param {string | string[]} ageHeader + * @returns {number | undefined} + */ +function getAge (ageHeader) { + const age = parseInt(Array.isArray(ageHeader) ? ageHeader[0] : ageHeader) + + return isNaN(age) ? undefined : age * 1000 +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType + * @param {number} now + * @param {number | undefined} age + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {Date | undefined} responseDate + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * + * @returns {number | undefined} time that the value is stale at in seconds or undefined if it shouldn't be cached + */ +function determineStaleAt (cacheType, now, age, resHeaders, responseDate, cacheControlDirectives) { + if (cacheType === 'shared') { + // Prioritize s-maxage since we're a shared cache + // s-maxage > max-age > Expire + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.10-3 + const sMaxAge = cacheControlDirectives['s-maxage'] + if (sMaxAge !== undefined) { + return sMaxAge > 0 ? sMaxAge * 1000 : undefined + } + } + + const maxAge = cacheControlDirectives['max-age'] + if (maxAge !== undefined) { + return maxAge > 0 ? maxAge * 1000 : undefined + } + + if (typeof resHeaders.expires === 'string') { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3 + const expiresDate = parseHttpDate(resHeaders.expires) + if (expiresDate) { + if (now >= expiresDate.getTime()) { + return undefined + } + + if (responseDate) { + if (responseDate >= expiresDate) { + return undefined + } + + if (age !== undefined && age > (expiresDate - responseDate)) { + return undefined + } + } + + return expiresDate.getTime() - now + } + } + + if (typeof resHeaders['last-modified'] === 'string') { + // https://www.rfc-editor.org/rfc/rfc9111.html#name-calculating-heuristic-fresh + const lastModified = new Date(resHeaders['last-modified']) + if (isValidDate(lastModified)) { + if (lastModified.getTime() >= now) { + return undefined + } + + const responseAge = now - lastModified.getTime() + + return responseAge * 0.1 + } + } + + if (cacheControlDirectives.immutable) { + // https://www.rfc-editor.org/rfc/rfc8246.html#section-2.2 + return 31536000 + } + + return undefined +} + +/** + * @param {number} now + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @param {number} staleAt + */ +function determineDeleteAt (now, cacheControlDirectives, staleAt) { + let staleWhileRevalidate = -Infinity + let staleIfError = -Infinity + let immutable = -Infinity + + if (cacheControlDirectives['stale-while-revalidate']) { + staleWhileRevalidate = staleAt + (cacheControlDirectives['stale-while-revalidate'] * 1000) + } + + if (cacheControlDirectives['stale-if-error']) { + staleIfError = staleAt + (cacheControlDirectives['stale-if-error'] * 1000) + } + + if (staleWhileRevalidate === -Infinity && staleIfError === -Infinity) { + immutable = now + 31536000000 + } + + return Math.max(staleAt, staleWhileRevalidate, staleIfError, immutable) +} + +/** + * Strips headers required to be removed in cached responses + * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives + * @returns {Record} + */ +function stripNecessaryHeaders (resHeaders, cacheControlDirectives) { + const headersToRemove = [ + 'connection', + 'proxy-authenticate', + 'proxy-authentication-info', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'transfer-encoding', + 'upgrade', + // We'll add age back when serving it + 'age' + ] + + if (resHeaders['connection']) { + if (Array.isArray(resHeaders['connection'])) { + // connection: a + // connection: b + headersToRemove.push(...resHeaders['connection'].map(header => header.trim())) + } else { + // connection: a, b + headersToRemove.push(...resHeaders['connection'].split(',').map(header => header.trim())) + } + } + + if (Array.isArray(cacheControlDirectives['no-cache'])) { + headersToRemove.push(...cacheControlDirectives['no-cache']) + } + + if (Array.isArray(cacheControlDirectives['private'])) { + headersToRemove.push(...cacheControlDirectives['private']) + } + + let strippedHeaders + for (const headerName of headersToRemove) { + if (resHeaders[headerName]) { + strippedHeaders ??= { ...resHeaders } + delete strippedHeaders[headerName] + } + } + + return strippedHeaders ?? resHeaders +} + +/** + * @param {Date} date + * @returns {boolean} + */ +function isValidDate (date) { + return date instanceof Date && Number.isFinite(date.valueOf()) +} + +module.exports = CacheHandler diff --git a/lib/handler/cache-revalidation-handler.js b/lib/handler/cache-revalidation-handler.js new file mode 100644 index 0000000..9672936 --- /dev/null +++ b/lib/handler/cache-revalidation-handler.js @@ -0,0 +1,124 @@ +'use strict' + +const assert = require('node:assert') + +/** + * This takes care of revalidation requests we send to the origin. If we get + * a response indicating that what we have is cached (via a HTTP 304), we can + * continue using the cached value. Otherwise, we'll receive the new response + * here, which we then just pass on to the next handler (most likely a + * CacheHandler). Note that this assumes the proper headers were already + * included in the request to tell the origin that we want to revalidate the + * response (i.e. if-modified-since). + * + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-validation + * + * @implements {import('../../types/dispatcher.d.ts').default.DispatchHandler} + */ +class CacheRevalidationHandler { + #successful = false + + /** + * @type {((boolean, any) => void) | null} + */ + #callback + + /** + * @type {(import('../../types/dispatcher.d.ts').default.DispatchHandler)} + */ + #handler + + #context + + /** + * @type {boolean} + */ + #allowErrorStatusCodes + + /** + * @param {(boolean) => void} callback Function to call if the cached value is valid + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler + * @param {boolean} allowErrorStatusCodes + */ + constructor (callback, handler, allowErrorStatusCodes) { + if (typeof callback !== 'function') { + throw new TypeError('callback must be a function') + } + + this.#callback = callback + this.#handler = handler + this.#allowErrorStatusCodes = allowErrorStatusCodes + } + + onRequestStart (_, context) { + this.#successful = false + this.#context = context + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.#handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + onResponseStart ( + controller, + statusCode, + headers, + statusMessage + ) { + assert(this.#callback != null) + + // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-a-validation-respo + // https://datatracker.ietf.org/doc/html/rfc5861#section-4 + this.#successful = statusCode === 304 || + (this.#allowErrorStatusCodes && statusCode >= 500 && statusCode <= 504) + this.#callback(this.#successful, this.#context) + this.#callback = null + + if (this.#successful) { + return true + } + + this.#handler.onRequestStart?.(controller, this.#context) + this.#handler.onResponseStart?.( + controller, + statusCode, + headers, + statusMessage + ) + } + + onResponseData (controller, chunk) { + if (this.#successful) { + return + } + + return this.#handler.onResponseData?.(controller, chunk) + } + + onResponseEnd (controller, trailers) { + if (this.#successful) { + return + } + + this.#handler.onResponseEnd?.(controller, trailers) + } + + onResponseError (controller, err) { + if (this.#successful) { + return + } + + if (this.#callback) { + this.#callback(false) + this.#callback = null + } + + if (typeof this.#handler.onResponseError === 'function') { + this.#handler.onResponseError(controller, err) + } else { + throw err + } + } +} + +module.exports = CacheRevalidationHandler diff --git a/lib/handler/decorator-handler.js b/lib/handler/decorator-handler.js new file mode 100644 index 0000000..50fbb0c --- /dev/null +++ b/lib/handler/decorator-handler.js @@ -0,0 +1,67 @@ +'use strict' + +const assert = require('node:assert') +const WrapHandler = require('./wrap-handler') + +/** + * @deprecated + */ +module.exports = class DecoratorHandler { + #handler + #onCompleteCalled = false + #onErrorCalled = false + #onResponseStartCalled = false + + constructor (handler) { + if (typeof handler !== 'object' || handler === null) { + throw new TypeError('handler must be an object') + } + this.#handler = WrapHandler.wrap(handler) + } + + onRequestStart (...args) { + this.#handler.onRequestStart?.(...args) + } + + onRequestUpgrade (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + + return this.#handler.onRequestUpgrade?.(...args) + } + + onResponseStart (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + assert(!this.#onResponseStartCalled) + + this.#onResponseStartCalled = true + + return this.#handler.onResponseStart?.(...args) + } + + onResponseData (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + + return this.#handler.onResponseData?.(...args) + } + + onResponseEnd (...args) { + assert(!this.#onCompleteCalled) + assert(!this.#onErrorCalled) + + this.#onCompleteCalled = true + return this.#handler.onResponseEnd?.(...args) + } + + onResponseError (...args) { + this.#onErrorCalled = true + return this.#handler.onResponseError?.(...args) + } + + /** + * @deprecated + */ + onBodySent () {} +} diff --git a/lib/handler/redirect-handler.js b/lib/handler/redirect-handler.js new file mode 100644 index 0000000..dd28e1d --- /dev/null +++ b/lib/handler/redirect-handler.js @@ -0,0 +1,227 @@ +'use strict' + +const util = require('../core/util') +const { kBodyUsed } = require('../core/symbols') +const assert = require('node:assert') +const { InvalidArgumentError } = require('../core/errors') +const EE = require('node:events') + +const redirectableStatusCodes = [300, 301, 302, 303, 307, 308] + +const kBody = Symbol('body') + +const noop = () => {} + +class BodyAsyncIterable { + constructor (body) { + this[kBody] = body + this[kBodyUsed] = false + } + + async * [Symbol.asyncIterator] () { + assert(!this[kBodyUsed], 'disturbed') + this[kBodyUsed] = true + yield * this[kBody] + } +} + +class RedirectHandler { + static buildDispatch (dispatcher, maxRedirections) { + if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + const dispatch = dispatcher.dispatch.bind(dispatcher) + return (opts, originalHandler) => dispatch(opts, new RedirectHandler(dispatch, maxRedirections, opts, originalHandler)) + } + + constructor (dispatch, maxRedirections, opts, handler) { + if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + this.dispatch = dispatch + this.location = null + this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy + this.maxRedirections = maxRedirections + this.handler = handler + this.history = [] + + if (util.isStream(this.opts.body)) { + // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp + // so that it can be dispatched again? + // TODO (fix): Do we need 100-expect support to provide a way to do this properly? + if (util.bodyLength(this.opts.body) === 0) { + this.opts.body + .on('data', function () { + assert(false) + }) + } + + if (typeof this.opts.body.readableDidRead !== 'boolean') { + this.opts.body[kBodyUsed] = false + EE.prototype.on.call(this.opts.body, 'data', function () { + this[kBodyUsed] = true + }) + } + } else if (this.opts.body && typeof this.opts.body.pipeTo === 'function') { + // TODO (fix): We can't access ReadableStream internal state + // to determine whether or not it has been disturbed. This is just + // a workaround. + this.opts.body = new BodyAsyncIterable(this.opts.body) + } else if ( + this.opts.body && + typeof this.opts.body !== 'string' && + !ArrayBuffer.isView(this.opts.body) && + util.isIterable(this.opts.body) && + !util.isFormDataLike(this.opts.body) + ) { + // TODO: Should we allow re-using iterable if !this.opts.idempotent + // or through some other flag? + this.opts.body = new BodyAsyncIterable(this.opts.body) + } + } + + onRequestStart (controller, context) { + this.handler.onRequestStart?.(controller, { ...context, history: this.history }) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + if (this.opts.throwOnMaxRedirect && this.history.length >= this.maxRedirections) { + throw new Error('max redirects') + } + + // https://tools.ietf.org/html/rfc7231#section-6.4.2 + // https://fetch.spec.whatwg.org/#http-redirect-fetch + // In case of HTTP 301 or 302 with POST, change the method to GET + if ((statusCode === 301 || statusCode === 302) && this.opts.method === 'POST') { + this.opts.method = 'GET' + if (util.isStream(this.opts.body)) { + util.destroy(this.opts.body.on('error', noop)) + } + this.opts.body = null + } + + // https://tools.ietf.org/html/rfc7231#section-6.4.4 + // In case of HTTP 303, always replace method to be either HEAD or GET + if (statusCode === 303 && this.opts.method !== 'HEAD') { + this.opts.method = 'GET' + if (util.isStream(this.opts.body)) { + util.destroy(this.opts.body.on('error', noop)) + } + this.opts.body = null + } + + this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) || redirectableStatusCodes.indexOf(statusCode) === -1 + ? null + : headers.location + + if (this.opts.origin) { + this.history.push(new URL(this.opts.path, this.opts.origin)) + } + + if (!this.location) { + this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage) + return + } + + const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin))) + const path = search ? `${pathname}${search}` : pathname + + // Remove headers referring to the original URL. + // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers. + // https://tools.ietf.org/html/rfc7231#section-6.4 + this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin) + this.opts.path = path + this.opts.origin = origin + this.opts.maxRedirections = 0 + this.opts.query = null + } + + onResponseData (controller, chunk) { + if (this.location) { + /* + https://tools.ietf.org/html/rfc7231#section-6.4 + + TLDR: undici always ignores 3xx response bodies. + + Redirection is used to serve the requested resource from another URL, so it assumes that + no body is generated (and thus can be ignored). Even though generating a body is not prohibited. + + For status 301, 302, 303, 307 and 308 (the latter from RFC 7238), the specs mention that the body usually + (which means it's optional and not mandated) contain just an hyperlink to the value of + the Location response header, so the body can be ignored safely. + + For status 300, which is "Multiple Choices", the spec mentions both generating a Location + response header AND a response body with the other possible location to follow. + Since the spec explicitly chooses not to specify a format for such body and leave it to + servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it. + */ + } else { + this.handler.onResponseData?.(controller, chunk) + } + } + + onResponseEnd (controller, trailers) { + if (this.location) { + /* + https://tools.ietf.org/html/rfc7231#section-6.4 + + TLDR: undici always ignores 3xx response trailers as they are not expected in case of redirections + and neither are useful if present. + + See comment on onData method above for more detailed information. + */ + this.dispatch(this.opts, this) + } else { + this.handler.onResponseEnd(controller, trailers) + } + } + + onResponseError (controller, error) { + this.handler.onResponseError?.(controller, error) + } +} + +// https://tools.ietf.org/html/rfc7231#section-6.4.4 +function shouldRemoveHeader (header, removeContent, unknownOrigin) { + if (header.length === 4) { + return util.headerNameToString(header) === 'host' + } + if (removeContent && util.headerNameToString(header).startsWith('content-')) { + return true + } + if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) { + const name = util.headerNameToString(header) + return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization' + } + return false +} + +// https://tools.ietf.org/html/rfc7231#section-6.4 +function cleanRequestHeaders (headers, removeContent, unknownOrigin) { + const ret = [] + if (Array.isArray(headers)) { + for (let i = 0; i < headers.length; i += 2) { + if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) { + ret.push(headers[i], headers[i + 1]) + } + } + } else if (headers && typeof headers === 'object') { + const entries = typeof headers[Symbol.iterator] === 'function' ? headers : Object.entries(headers) + for (const [key, value] of entries) { + if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) { + ret.push(key, value) + } + } + } else { + assert(headers == null, 'headers must be an object or an array') + } + return ret +} + +module.exports = RedirectHandler diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js new file mode 100644 index 0000000..d929b0c --- /dev/null +++ b/lib/handler/retry-handler.js @@ -0,0 +1,342 @@ +'use strict' +const assert = require('node:assert') + +const { kRetryHandlerDefaultRetry } = require('../core/symbols') +const { RequestRetryError } = require('../core/errors') +const WrapHandler = require('./wrap-handler') +const { + isDisturbed, + parseRangeHeader, + wrapRequestBody +} = require('../core/util') + +function calculateRetryAfterHeader (retryAfter) { + const current = Date.now() + return new Date(retryAfter).getTime() - current +} + +class RetryHandler { + constructor (opts, { dispatch, handler }) { + const { retryOptions, ...dispatchOpts } = opts + const { + // Retry scoped + retry: retryFn, + maxRetries, + maxTimeout, + minTimeout, + timeoutFactor, + // Response scoped + methods, + errorCodes, + retryAfter, + statusCodes + } = retryOptions ?? {} + + this.dispatch = dispatch + this.handler = WrapHandler.wrap(handler) + this.opts = { ...dispatchOpts, body: wrapRequestBody(opts.body) } + this.retryOpts = { + retry: retryFn ?? RetryHandler[kRetryHandlerDefaultRetry], + retryAfter: retryAfter ?? true, + maxTimeout: maxTimeout ?? 30 * 1000, // 30s, + minTimeout: minTimeout ?? 500, // .5s + timeoutFactor: timeoutFactor ?? 2, + maxRetries: maxRetries ?? 5, + // What errors we should retry + methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'], + // Indicates which errors to retry + statusCodes: statusCodes ?? [500, 502, 503, 504, 429], + // List of errors to retry + errorCodes: errorCodes ?? [ + 'ECONNRESET', + 'ECONNREFUSED', + 'ENOTFOUND', + 'ENETDOWN', + 'ENETUNREACH', + 'EHOSTDOWN', + 'EHOSTUNREACH', + 'EPIPE', + 'UND_ERR_SOCKET' + ] + } + + this.retryCount = 0 + this.retryCountCheckpoint = 0 + this.headersSent = false + this.start = 0 + this.end = null + this.etag = null + } + + onRequestStart (controller, context) { + if (!this.headersSent) { + this.handler.onRequestStart?.(controller, context) + } + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + this.handler.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + static [kRetryHandlerDefaultRetry] (err, { state, opts }, cb) { + const { statusCode, code, headers } = err + const { method, retryOptions } = opts + const { + maxRetries, + minTimeout, + maxTimeout, + timeoutFactor, + statusCodes, + errorCodes, + methods + } = retryOptions + const { counter } = state + + // Any code that is not a Undici's originated and allowed to retry + if (code && code !== 'UND_ERR_REQ_RETRY' && !errorCodes.includes(code)) { + cb(err) + return + } + + // If a set of method are provided and the current method is not in the list + if (Array.isArray(methods) && !methods.includes(method)) { + cb(err) + return + } + + // If a set of status code are provided and the current status code is not in the list + if ( + statusCode != null && + Array.isArray(statusCodes) && + !statusCodes.includes(statusCode) + ) { + cb(err) + return + } + + // If we reached the max number of retries + if (counter > maxRetries) { + cb(err) + return + } + + let retryAfterHeader = headers?.['retry-after'] + if (retryAfterHeader) { + retryAfterHeader = Number(retryAfterHeader) + retryAfterHeader = Number.isNaN(retryAfterHeader) + ? calculateRetryAfterHeader(retryAfterHeader) + : retryAfterHeader * 1e3 // Retry-After is in seconds + } + + const retryTimeout = + retryAfterHeader > 0 + ? Math.min(retryAfterHeader, maxTimeout) + : Math.min(minTimeout * timeoutFactor ** (counter - 1), maxTimeout) + + setTimeout(() => cb(null), retryTimeout) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + this.retryCount += 1 + + if (statusCode >= 300) { + if (this.retryOpts.statusCodes.includes(statusCode) === false) { + this.headersSent = true + this.handler.onResponseStart?.( + controller, + statusCode, + headers, + statusMessage + ) + return + } else { + throw new RequestRetryError('Request failed', statusCode, { + headers, + data: { + count: this.retryCount + } + }) + } + } + + // Checkpoint for resume from where we left it + if (this.headersSent) { + // Only Partial Content 206 supposed to provide Content-Range, + // any other status code that partially consumed the payload + // should not be retried because it would result in downstream + // wrongly concatenate multiple responses. + if (statusCode !== 206 && (this.start > 0 || statusCode !== 200)) { + throw new RequestRetryError('server does not support the range header and the payload was partially consumed', statusCode, { + headers, + data: { count: this.retryCount } + }) + } + + const contentRange = parseRangeHeader(headers['content-range']) + // If no content range + if (!contentRange) { + throw new RequestRetryError('Content-Range mismatch', statusCode, { + headers, + data: { count: this.retryCount } + }) + } + + // Let's start with a weak etag check + if (this.etag != null && this.etag !== headers.etag) { + throw new RequestRetryError('ETag mismatch', statusCode, { + headers, + data: { count: this.retryCount } + }) + } + + const { start, size, end = size ? size - 1 : null } = contentRange + + assert(this.start === start, 'content-range mismatch') + assert(this.end == null || this.end === end, 'content-range mismatch') + + return + } + + if (this.end == null) { + if (statusCode === 206) { + // First time we receive 206 + const range = parseRangeHeader(headers['content-range']) + + if (range == null) { + this.headersSent = true + this.handler.onResponseStart?.( + controller, + statusCode, + headers, + statusMessage + ) + return + } + + const { start, size, end = size ? size - 1 : null } = range + assert( + start != null && Number.isFinite(start), + 'content-range mismatch' + ) + assert(end != null && Number.isFinite(end), 'invalid content-length') + + this.start = start + this.end = end + } + + // We make our best to checkpoint the body for further range headers + if (this.end == null) { + const contentLength = headers['content-length'] + this.end = contentLength != null ? Number(contentLength) - 1 : null + } + + assert(Number.isFinite(this.start)) + assert( + this.end == null || Number.isFinite(this.end), + 'invalid content-length' + ) + + this.resume = true + this.etag = headers.etag != null ? headers.etag : null + + // Weak etags are not useful for comparison nor cache + // for instance not safe to assume if the response is byte-per-byte + // equal + if ( + this.etag != null && + this.etag[0] === 'W' && + this.etag[1] === '/' + ) { + this.etag = null + } + + this.headersSent = true + this.handler.onResponseStart?.( + controller, + statusCode, + headers, + statusMessage + ) + } else { + throw new RequestRetryError('Request failed', statusCode, { + headers, + data: { count: this.retryCount } + }) + } + } + + onResponseData (controller, chunk) { + this.start += chunk.length + + this.handler.onResponseData?.(controller, chunk) + } + + onResponseEnd (controller, trailers) { + this.retryCount = 0 + return this.handler.onResponseEnd?.(controller, trailers) + } + + onResponseError (controller, err) { + if (controller?.aborted || isDisturbed(this.opts.body)) { + this.handler.onResponseError?.(controller, err) + return + } + + // We reconcile in case of a mix between network errors + // and server error response + if (this.retryCount - this.retryCountCheckpoint > 0) { + // We count the difference between the last checkpoint and the current retry count + this.retryCount = + this.retryCountCheckpoint + + (this.retryCount - this.retryCountCheckpoint) + } else { + this.retryCount += 1 + } + + this.retryOpts.retry( + err, + { + state: { counter: this.retryCount }, + opts: { retryOptions: this.retryOpts, ...this.opts } + }, + onRetry.bind(this) + ) + + /** + * @this {RetryHandler} + * @param {Error} [err] + * @returns + */ + function onRetry (err) { + if (err != null || controller?.aborted || isDisturbed(this.opts.body)) { + return this.handler.onResponseError?.(controller, err) + } + + if (this.start !== 0) { + const headers = { range: `bytes=${this.start}-${this.end ?? ''}` } + + // Weak etag check - weak etags will make comparison algorithms never match + if (this.etag != null) { + headers['if-match'] = this.etag + } + + this.opts = { + ...this.opts, + headers: { + ...this.opts.headers, + ...headers + } + } + } + + try { + this.retryCountCheckpoint = this.retryCount + this.dispatch(this.opts, this) + } catch (err) { + this.handler.onResponseError?.(controller, err) + } + } + } +} + +module.exports = RetryHandler diff --git a/lib/handler/unwrap-handler.js b/lib/handler/unwrap-handler.js new file mode 100644 index 0000000..865593a --- /dev/null +++ b/lib/handler/unwrap-handler.js @@ -0,0 +1,96 @@ +'use strict' + +const { parseHeaders } = require('../core/util') +const { InvalidArgumentError } = require('../core/errors') + +const kResume = Symbol('resume') + +class UnwrapController { + #paused = false + #reason = null + #aborted = false + #abort + + [kResume] = null + + constructor (abort) { + this.#abort = abort + } + + pause () { + this.#paused = true + } + + resume () { + if (this.#paused) { + this.#paused = false + this[kResume]?.() + } + } + + abort (reason) { + if (!this.#aborted) { + this.#aborted = true + this.#reason = reason + this.#abort(reason) + } + } + + get aborted () { + return this.#aborted + } + + get reason () { + return this.#reason + } + + get paused () { + return this.#paused + } +} + +module.exports = class UnwrapHandler { + #handler + #controller + + constructor (handler) { + this.#handler = handler + } + + static unwrap (handler) { + // TODO (fix): More checks... + return !handler.onRequestStart ? handler : new UnwrapHandler(handler) + } + + onConnect (abort, context) { + this.#controller = new UnwrapController(abort) + this.#handler.onRequestStart?.(this.#controller, context) + } + + onUpgrade (statusCode, rawHeaders, socket) { + this.#handler.onRequestUpgrade?.(this.#controller, statusCode, parseHeaders(rawHeaders), socket) + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + this.#controller[kResume] = resume + this.#handler.onResponseStart?.(this.#controller, statusCode, parseHeaders(rawHeaders), statusMessage) + return !this.#controller.paused + } + + onData (data) { + this.#handler.onResponseData?.(this.#controller, data) + return !this.#controller.paused + } + + onComplete (rawTrailers) { + this.#handler.onResponseEnd?.(this.#controller, parseHeaders(rawTrailers)) + } + + onError (err) { + if (!this.#handler.onResponseError) { + throw new InvalidArgumentError('invalid onError method') + } + + this.#handler.onResponseError?.(this.#controller, err) + } +} diff --git a/lib/handler/wrap-handler.js b/lib/handler/wrap-handler.js new file mode 100644 index 0000000..47caa5f --- /dev/null +++ b/lib/handler/wrap-handler.js @@ -0,0 +1,95 @@ +'use strict' + +const { InvalidArgumentError } = require('../core/errors') + +module.exports = class WrapHandler { + #handler + + constructor (handler) { + this.#handler = handler + } + + static wrap (handler) { + // TODO (fix): More checks... + return handler.onRequestStart ? handler : new WrapHandler(handler) + } + + // Unwrap Interface + + onConnect (abort, context) { + return this.#handler.onConnect?.(abort, context) + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + return this.#handler.onHeaders?.(statusCode, rawHeaders, resume, statusMessage) + } + + onUpgrade (statusCode, rawHeaders, socket) { + return this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) + } + + onData (data) { + return this.#handler.onData?.(data) + } + + onComplete (trailers) { + return this.#handler.onComplete?.(trailers) + } + + onError (err) { + if (!this.#handler.onError) { + throw err + } + + return this.#handler.onError?.(err) + } + + // Wrap Interface + + onRequestStart (controller, context) { + this.#handler.onConnect?.((reason) => controller.abort(reason), context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + const rawHeaders = [] + for (const [key, val] of Object.entries(headers)) { + rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val)) + } + + this.#handler.onUpgrade?.(statusCode, rawHeaders, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + const rawHeaders = [] + for (const [key, val] of Object.entries(headers)) { + rawHeaders.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val)) + } + + if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) { + controller.pause() + } + } + + onResponseData (controller, data) { + if (this.#handler.onData?.(data) === false) { + controller.pause() + } + } + + onResponseEnd (controller, trailers) { + const rawTrailers = [] + for (const [key, val] of Object.entries(trailers)) { + rawTrailers.push(Buffer.from(key), Array.isArray(val) ? val.map(v => Buffer.from(v)) : Buffer.from(val)) + } + + this.#handler.onComplete?.(rawTrailers) + } + + onResponseError (controller, err) { + if (!this.#handler.onError) { + throw new InvalidArgumentError('invalid onError method') + } + + this.#handler.onError?.(err) + } +} diff --git a/lib/interceptor/cache.js b/lib/interceptor/cache.js new file mode 100644 index 0000000..6d12256 --- /dev/null +++ b/lib/interceptor/cache.js @@ -0,0 +1,362 @@ +'use strict' + +const assert = require('node:assert') +const { Readable } = require('node:stream') +const util = require('../core/util') +const CacheHandler = require('../handler/cache-handler') +const MemoryCacheStore = require('../cache/memory-cache-store') +const CacheRevalidationHandler = require('../handler/cache-revalidation-handler') +const { assertCacheStore, assertCacheMethods, makeCacheKey, parseCacheControlHeader } = require('../util/cache.js') +const { AbortError } = require('../core/errors.js') + +/** + * @typedef {(options: import('../../types/dispatcher.d.ts').default.DispatchOptions, handler: import('../../types/dispatcher.d.ts').default.DispatchHandler) => void} DispatchFn + */ + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives + * @returns {boolean} + */ +function needsRevalidation (result, cacheControlDirectives) { + if (cacheControlDirectives?.['no-cache']) { + // Always revalidate requests with the no-cache directive + return true + } + + const now = Date.now() + if (now > result.staleAt) { + // Response is stale + if (cacheControlDirectives?.['max-stale']) { + // There's a threshold where we can serve stale responses, let's see if + // we're in it + // https://www.rfc-editor.org/rfc/rfc9111.html#name-max-stale + const gracePeriod = result.staleAt + (cacheControlDirectives['max-stale'] * 1000) + return now > gracePeriod + } + + return true + } + + if (cacheControlDirectives?.['min-fresh']) { + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3 + + // At this point, staleAt is always > now + const timeLeftTillStale = result.staleAt - now + const threshold = cacheControlDirectives['min-fresh'] * 1000 + + return timeLeftTillStale <= threshold + } + + return false +} + +/** + * @param {DispatchFn} dispatch + * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl + */ +function handleUncachedResponse ( + dispatch, + globalOpts, + cacheKey, + handler, + opts, + reqCacheControl +) { + if (reqCacheControl?.['only-if-cached']) { + let aborted = false + try { + if (typeof handler.onConnect === 'function') { + handler.onConnect(() => { + aborted = true + }) + + if (aborted) { + return + } + } + + if (typeof handler.onHeaders === 'function') { + handler.onHeaders(504, [], () => {}, 'Gateway Timeout') + if (aborted) { + return + } + } + + if (typeof handler.onComplete === 'function') { + handler.onComplete([]) + } + } catch (err) { + if (typeof handler.onError === 'function') { + handler.onError(err) + } + } + + return true + } + + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) +} + +/** + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result + * @param {number} age + * @param {any} context + * @param {boolean} isStale + */ +function sendCachedValue (handler, opts, result, age, context, isStale) { + // TODO (perf): Readable.from path can be optimized... + const stream = util.isStream(result.body) + ? result.body + : Readable.from(result.body ?? []) + + assert(!stream.destroyed, 'stream should not be destroyed') + assert(!stream.readableDidRead, 'stream should not be readableDidRead') + + const controller = { + resume () { + stream.resume() + }, + pause () { + stream.pause() + }, + get paused () { + return stream.isPaused() + }, + get aborted () { + return stream.destroyed + }, + get reason () { + return stream.errored + }, + abort (reason) { + stream.destroy(reason ?? new AbortError()) + } + } + + stream + .on('error', function (err) { + if (!this.readableEnded) { + if (typeof handler.onResponseError === 'function') { + handler.onResponseError(controller, err) + } else { + throw err + } + } + }) + .on('close', function () { + if (!this.errored) { + handler.onResponseEnd?.(controller, {}) + } + }) + + handler.onRequestStart?.(controller, context) + + if (stream.destroyed) { + return + } + + // Add the age header + // https://www.rfc-editor.org/rfc/rfc9111.html#name-age + const headers = { ...result.headers, age: String(age) } + + if (isStale) { + // Add warning header + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Warning + headers.warning = '110 - "response is stale"' + } + + handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage) + + if (opts.method === 'HEAD') { + stream.destroy() + } else { + stream.on('data', function (chunk) { + handler.onResponseData?.(controller, chunk) + }) + } +} + +/** + * @param {DispatchFn} dispatch + * @param {import('../../types/cache-interceptor.d.ts').default.CacheHandlerOptions} globalOpts + * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey + * @param {import('../../types/dispatcher.d.ts').default.DispatchHandler} handler + * @param {import('../../types/dispatcher.d.ts').default.RequestOptions} opts + * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} reqCacheControl + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined} result + */ +function handleResult ( + dispatch, + globalOpts, + cacheKey, + handler, + opts, + reqCacheControl, + result +) { + if (!result) { + return handleUncachedResponse(dispatch, globalOpts, cacheKey, handler, opts, reqCacheControl) + } + + const now = Date.now() + if (now > result.deleteAt) { + // Response is expired, cache store shouldn't have given this to us + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) + } + + const age = Math.round((now - result.cachedAt) / 1000) + if (reqCacheControl?.['max-age'] && age >= reqCacheControl['max-age']) { + // Response is considered expired for this specific request + // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1 + return dispatch(opts, handler) + } + + // Check if the response is stale + if (needsRevalidation(result, reqCacheControl)) { + if (util.isStream(opts.body) && util.bodyLength(opts.body) !== 0) { + // If body is is stream we can't revalidate... + // TODO (fix): This could be less strict... + return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler)) + } + + let withinStaleIfErrorThreshold = false + const staleIfErrorExpiry = result.cacheControlDirectives['stale-if-error'] ?? reqCacheControl?.['stale-if-error'] + if (staleIfErrorExpiry) { + withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000)) + } + + let headers = { + ...opts.headers, + 'if-modified-since': new Date(result.cachedAt).toUTCString() + } + + if (result.etag) { + headers['if-none-match'] = result.etag + } + + if (result.vary) { + headers = { + ...headers, + ...result.vary + } + } + + // We need to revalidate the response + return dispatch( + { + ...opts, + headers + }, + new CacheRevalidationHandler( + (success, context) => { + if (success) { + sendCachedValue(handler, opts, result, age, context, true) + } else if (util.isStream(result.body)) { + result.body.on('error', () => {}).destroy() + } + }, + new CacheHandler(globalOpts, cacheKey, handler), + withinStaleIfErrorThreshold + ) + ) + } + + // Dump request body. + if (util.isStream(opts.body)) { + opts.body.on('error', () => {}).destroy() + } + + sendCachedValue(handler, opts, result, age, null, false) +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions} [opts] + * @returns {import('../../types/dispatcher.d.ts').default.DispatcherComposeInterceptor} + */ +module.exports = (opts = {}) => { + const { + store = new MemoryCacheStore(), + methods = ['GET'], + cacheByDefault = undefined, + type = 'shared' + } = opts + + if (typeof opts !== 'object' || opts === null) { + throw new TypeError(`expected type of opts to be an Object, got ${opts === null ? 'null' : typeof opts}`) + } + + assertCacheStore(store, 'opts.store') + assertCacheMethods(methods, 'opts.methods') + + if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') { + throw new TypeError(`exepcted opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`) + } + + if (typeof type !== 'undefined' && type !== 'shared' && type !== 'private') { + throw new TypeError(`exepcted opts.type to be shared, private, or undefined, got ${typeof type}`) + } + + const globalOpts = { + store, + methods, + cacheByDefault, + type + } + + const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false) + + return dispatch => { + return (opts, handler) => { + if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) { + // Not a method we want to cache or we don't have the origin, skip + return dispatch(opts, handler) + } + + const reqCacheControl = opts.headers?.['cache-control'] + ? parseCacheControlHeader(opts.headers['cache-control']) + : undefined + + if (reqCacheControl?.['no-store']) { + return dispatch(opts, handler) + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const cacheKey = makeCacheKey(opts) + const result = store.get(cacheKey) + + if (result && typeof result.then === 'function') { + result.then(result => { + handleResult(dispatch, + globalOpts, + cacheKey, + handler, + opts, + reqCacheControl, + result + ) + }) + } else { + handleResult( + dispatch, + globalOpts, + cacheKey, + handler, + opts, + reqCacheControl, + result + ) + } + + return true + } + } +} diff --git a/lib/interceptor/dns.js b/lib/interceptor/dns.js new file mode 100644 index 0000000..3828760 --- /dev/null +++ b/lib/interceptor/dns.js @@ -0,0 +1,432 @@ +'use strict' +const { isIP } = require('node:net') +const { lookup } = require('node:dns') +const DecoratorHandler = require('../handler/decorator-handler') +const { InvalidArgumentError, InformationalError } = require('../core/errors') +const maxInt = Math.pow(2, 31) - 1 + +class DNSInstance { + #maxTTL = 0 + #maxItems = 0 + #records = new Map() + dualStack = true + affinity = null + lookup = null + pick = null + + constructor (opts) { + this.#maxTTL = opts.maxTTL + this.#maxItems = opts.maxItems + this.dualStack = opts.dualStack + this.affinity = opts.affinity + this.lookup = opts.lookup ?? this.#defaultLookup + this.pick = opts.pick ?? this.#defaultPick + } + + get full () { + return this.#records.size === this.#maxItems + } + + runLookup (origin, opts, cb) { + const ips = this.#records.get(origin.hostname) + + // If full, we just return the origin + if (ips == null && this.full) { + cb(null, origin) + return + } + + const newOpts = { + affinity: this.affinity, + dualStack: this.dualStack, + lookup: this.lookup, + pick: this.pick, + ...opts.dns, + maxTTL: this.#maxTTL, + maxItems: this.#maxItems + } + + // If no IPs we lookup + if (ips == null) { + this.lookup(origin, newOpts, (err, addresses) => { + if (err || addresses == null || addresses.length === 0) { + cb(err ?? new InformationalError('No DNS entries found')) + return + } + + this.setRecords(origin, addresses) + const records = this.#records.get(origin.hostname) + + const ip = this.pick( + origin, + records, + newOpts.affinity + ) + + let port + if (typeof ip.port === 'number') { + port = `:${ip.port}` + } else if (origin.port !== '') { + port = `:${origin.port}` + } else { + port = '' + } + + cb( + null, + new URL(`${origin.protocol}//${ + ip.family === 6 ? `[${ip.address}]` : ip.address + }${port}`) + ) + }) + } else { + // If there's IPs we pick + const ip = this.pick( + origin, + ips, + newOpts.affinity + ) + + // If no IPs we lookup - deleting old records + if (ip == null) { + this.#records.delete(origin.hostname) + this.runLookup(origin, opts, cb) + return + } + + let port + if (typeof ip.port === 'number') { + port = `:${ip.port}` + } else if (origin.port !== '') { + port = `:${origin.port}` + } else { + port = '' + } + + cb( + null, + new URL(`${origin.protocol}//${ + ip.family === 6 ? `[${ip.address}]` : ip.address + }${port}`) + ) + } + } + + #defaultLookup (origin, opts, cb) { + lookup( + origin.hostname, + { + all: true, + family: this.dualStack === false ? this.affinity : 0, + order: 'ipv4first' + }, + (err, addresses) => { + if (err) { + return cb(err) + } + + const results = new Map() + + for (const addr of addresses) { + // On linux we found duplicates, we attempt to remove them with + // the latest record + results.set(`${addr.address}:${addr.family}`, addr) + } + + cb(null, results.values()) + } + ) + } + + #defaultPick (origin, hostnameRecords, affinity) { + let ip = null + const { records, offset } = hostnameRecords + + let family + if (this.dualStack) { + if (affinity == null) { + // Balance between ip families + if (offset == null || offset === maxInt) { + hostnameRecords.offset = 0 + affinity = 4 + } else { + hostnameRecords.offset++ + affinity = (hostnameRecords.offset & 1) === 1 ? 6 : 4 + } + } + + if (records[affinity] != null && records[affinity].ips.length > 0) { + family = records[affinity] + } else { + family = records[affinity === 4 ? 6 : 4] + } + } else { + family = records[affinity] + } + + // If no IPs we return null + if (family == null || family.ips.length === 0) { + return ip + } + + if (family.offset == null || family.offset === maxInt) { + family.offset = 0 + } else { + family.offset++ + } + + const position = family.offset % family.ips.length + ip = family.ips[position] ?? null + + if (ip == null) { + return ip + } + + if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms + // We delete expired records + // It is possible that they have different TTL, so we manage them individually + family.ips.splice(position, 1) + return this.pick(origin, hostnameRecords, affinity) + } + + return ip + } + + pickFamily (origin, ipFamily) { + const records = this.#records.get(origin.hostname)?.records + if (!records) { + return null + } + + const family = records[ipFamily] + if (!family) { + return null + } + + if (family.offset == null || family.offset === maxInt) { + family.offset = 0 + } else { + family.offset++ + } + + const position = family.offset % family.ips.length + const ip = family.ips[position] ?? null + if (ip == null) { + return ip + } + + if (Date.now() - ip.timestamp > ip.ttl) { // record TTL is already in ms + // We delete expired records + // It is possible that they have different TTL, so we manage them individually + family.ips.splice(position, 1) + } + + return ip + } + + setRecords (origin, addresses) { + const timestamp = Date.now() + const records = { records: { 4: null, 6: null } } + for (const record of addresses) { + record.timestamp = timestamp + if (typeof record.ttl === 'number') { + // The record TTL is expected to be in ms + record.ttl = Math.min(record.ttl, this.#maxTTL) + } else { + record.ttl = this.#maxTTL + } + + const familyRecords = records.records[record.family] ?? { ips: [] } + + familyRecords.ips.push(record) + records.records[record.family] = familyRecords + } + + this.#records.set(origin.hostname, records) + } + + deleteRecords (origin) { + this.#records.delete(origin.hostname) + } + + getHandler (meta, opts) { + return new DNSDispatchHandler(this, meta, opts) + } +} + +class DNSDispatchHandler extends DecoratorHandler { + #state = null + #opts = null + #dispatch = null + #origin = null + #controller = null + #newOrigin = null + #firstTry = true + + constructor (state, { origin, handler, dispatch, newOrigin }, opts) { + super(handler) + this.#origin = origin + this.#newOrigin = newOrigin + this.#opts = { ...opts } + this.#state = state + this.#dispatch = dispatch + } + + onResponseError (controller, err) { + switch (err.code) { + case 'ETIMEDOUT': + case 'ECONNREFUSED': { + if (this.#state.dualStack) { + if (!this.#firstTry) { + super.onResponseError(controller, err) + return + } + this.#firstTry = false + + // Pick an ip address from the other family + const otherFamily = this.#newOrigin.hostname[0] === '[' ? 4 : 6 + const ip = this.#state.pickFamily(this.#origin, otherFamily) + if (ip == null) { + super.onResponseError(controller, err) + return + } + + let port + if (typeof ip.port === 'number') { + port = `:${ip.port}` + } else if (this.#origin.port !== '') { + port = `:${this.#origin.port}` + } else { + port = '' + } + + const dispatchOpts = { + ...this.#opts, + origin: `${this.#origin.protocol}//${ + ip.family === 6 ? `[${ip.address}]` : ip.address + }${port}` + } + this.#dispatch(dispatchOpts, this) + return + } + + // if dual-stack disabled, we error out + super.onResponseError(controller, err) + break + } + case 'ENOTFOUND': + this.#state.deleteRecords(this.#origin) + super.onResponseError(controller, err) + break + default: + super.onResponseError(controller, err) + break + } + } +} + +module.exports = interceptorOpts => { + if ( + interceptorOpts?.maxTTL != null && + (typeof interceptorOpts?.maxTTL !== 'number' || interceptorOpts?.maxTTL < 0) + ) { + throw new InvalidArgumentError('Invalid maxTTL. Must be a positive number') + } + + if ( + interceptorOpts?.maxItems != null && + (typeof interceptorOpts?.maxItems !== 'number' || + interceptorOpts?.maxItems < 1) + ) { + throw new InvalidArgumentError( + 'Invalid maxItems. Must be a positive number and greater than zero' + ) + } + + if ( + interceptorOpts?.affinity != null && + interceptorOpts?.affinity !== 4 && + interceptorOpts?.affinity !== 6 + ) { + throw new InvalidArgumentError('Invalid affinity. Must be either 4 or 6') + } + + if ( + interceptorOpts?.dualStack != null && + typeof interceptorOpts?.dualStack !== 'boolean' + ) { + throw new InvalidArgumentError('Invalid dualStack. Must be a boolean') + } + + if ( + interceptorOpts?.lookup != null && + typeof interceptorOpts?.lookup !== 'function' + ) { + throw new InvalidArgumentError('Invalid lookup. Must be a function') + } + + if ( + interceptorOpts?.pick != null && + typeof interceptorOpts?.pick !== 'function' + ) { + throw new InvalidArgumentError('Invalid pick. Must be a function') + } + + const dualStack = interceptorOpts?.dualStack ?? true + let affinity + if (dualStack) { + affinity = interceptorOpts?.affinity ?? null + } else { + affinity = interceptorOpts?.affinity ?? 4 + } + + const opts = { + maxTTL: interceptorOpts?.maxTTL ?? 10e3, // Expressed in ms + lookup: interceptorOpts?.lookup ?? null, + pick: interceptorOpts?.pick ?? null, + dualStack, + affinity, + maxItems: interceptorOpts?.maxItems ?? Infinity + } + + const instance = new DNSInstance(opts) + + return dispatch => { + return function dnsInterceptor (origDispatchOpts, handler) { + const origin = + origDispatchOpts.origin.constructor === URL + ? origDispatchOpts.origin + : new URL(origDispatchOpts.origin) + + if (isIP(origin.hostname) !== 0) { + return dispatch(origDispatchOpts, handler) + } + + instance.runLookup(origin, origDispatchOpts, (err, newOrigin) => { + if (err) { + return handler.onResponseError(null, err) + } + + const dispatchOpts = { + ...origDispatchOpts, + servername: origin.hostname, // For SNI on TLS + origin: newOrigin.origin, + headers: { + host: origin.host, + ...origDispatchOpts.headers + } + } + + dispatch( + dispatchOpts, + instance.getHandler( + { origin, dispatch, handler, newOrigin }, + origDispatchOpts + ) + ) + }) + + return true + } + } +} diff --git a/lib/interceptor/dump.js b/lib/interceptor/dump.js new file mode 100644 index 0000000..61c09d5 --- /dev/null +++ b/lib/interceptor/dump.js @@ -0,0 +1,111 @@ +'use strict' + +const { InvalidArgumentError, RequestAbortedError } = require('../core/errors') +const DecoratorHandler = require('../handler/decorator-handler') + +class DumpHandler extends DecoratorHandler { + #maxSize = 1024 * 1024 + #dumped = false + #size = 0 + #controller = null + aborted = false + reason = false + + constructor ({ maxSize, signal }, handler) { + if (maxSize != null && (!Number.isFinite(maxSize) || maxSize < 1)) { + throw new InvalidArgumentError('maxSize must be a number greater than 0') + } + + super(handler) + + this.#maxSize = maxSize ?? this.#maxSize + // this.#handler = handler + } + + #abort (reason) { + this.aborted = true + this.reason = reason + } + + onRequestStart (controller, context) { + controller.abort = this.#abort.bind(this) + this.#controller = controller + + return super.onRequestStart(controller, context) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + const contentLength = headers['content-length'] + + if (contentLength != null && contentLength > this.#maxSize) { + throw new RequestAbortedError( + `Response size (${contentLength}) larger than maxSize (${ + this.#maxSize + })` + ) + } + + if (this.aborted === true) { + return true + } + + return super.onResponseStart(controller, statusCode, headers, statusMessage) + } + + onResponseError (controller, err) { + if (this.#dumped) { + return + } + + err = this.#controller.reason ?? err + + super.onResponseError(controller, err) + } + + onResponseData (controller, chunk) { + this.#size = this.#size + chunk.length + + if (this.#size >= this.#maxSize) { + this.#dumped = true + + if (this.aborted === true) { + super.onResponseError(controller, this.reason) + } else { + super.onResponseEnd(controller, {}) + } + } + + return true + } + + onResponseEnd (controller, trailers) { + if (this.#dumped) { + return + } + + if (this.#controller.aborted === true) { + super.onResponseError(controller, this.reason) + return + } + + super.onResponseEnd(controller, trailers) + } +} + +function createDumpInterceptor ( + { maxSize: defaultMaxSize } = { + maxSize: 1024 * 1024 + } +) { + return dispatch => { + return function Intercept (opts, handler) { + const { dumpMaxSize = defaultMaxSize } = opts + + const dumpHandler = new DumpHandler({ maxSize: dumpMaxSize, signal: opts.signal }, handler) + + return dispatch(opts, dumpHandler) + } + } +} + +module.exports = createDumpInterceptor diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js new file mode 100644 index 0000000..55bad59 --- /dev/null +++ b/lib/interceptor/redirect.js @@ -0,0 +1,21 @@ +'use strict' + +const RedirectHandler = require('../handler/redirect-handler') + +function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections } = {}) { + return (dispatch) => { + return function Intercept (opts, handler) { + const { maxRedirections = defaultMaxRedirections, ...rest } = opts + + if (maxRedirections == null || maxRedirections === 0) { + return dispatch(opts, handler) + } + + const dispatchOpts = { ...rest, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + const redirectHandler = new RedirectHandler(dispatch, maxRedirections, dispatchOpts, handler) + return dispatch(dispatchOpts, redirectHandler) + } + } +} + +module.exports = createRedirectInterceptor diff --git a/lib/interceptor/response-error.js b/lib/interceptor/response-error.js new file mode 100644 index 0000000..a8105aa --- /dev/null +++ b/lib/interceptor/response-error.js @@ -0,0 +1,95 @@ +'use strict' + +// const { parseHeaders } = require('../core/util') +const DecoratorHandler = require('../handler/decorator-handler') +const { ResponseError } = require('../core/errors') + +class ResponseErrorHandler extends DecoratorHandler { + #statusCode + #contentType + #decoder + #headers + #body + + constructor (_opts, { handler }) { + super(handler) + } + + #checkContentType (contentType) { + return (this.#contentType ?? '').indexOf(contentType) === 0 + } + + onRequestStart (controller, context) { + this.#statusCode = 0 + this.#contentType = null + this.#decoder = null + this.#headers = null + this.#body = '' + + return super.onRequestStart(controller, context) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + this.#statusCode = statusCode + this.#headers = headers + this.#contentType = headers['content-type'] + + if (this.#statusCode < 400) { + return super.onResponseStart(controller, statusCode, headers, statusMessage) + } + + if (this.#checkContentType('application/json') || this.#checkContentType('text/plain')) { + this.#decoder = new TextDecoder('utf-8') + } + } + + onResponseData (controller, chunk) { + if (this.#statusCode < 400) { + return super.onResponseData(controller, chunk) + } + + this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? '' + } + + onResponseEnd (controller, trailers) { + if (this.#statusCode >= 400) { + this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? '' + + if (this.#checkContentType('application/json')) { + try { + this.#body = JSON.parse(this.#body) + } catch { + // Do nothing... + } + } + + let err + const stackTraceLimit = Error.stackTraceLimit + Error.stackTraceLimit = 0 + try { + err = new ResponseError('Response Error', this.#statusCode, { + body: this.#body, + headers: this.#headers + }) + } finally { + Error.stackTraceLimit = stackTraceLimit + } + + super.onResponseError(controller, err) + } else { + super.onResponseEnd(controller, trailers) + } + } + + onResponseError (controller, err) { + super.onResponseError(controller, err) + } +} + +module.exports = () => { + return (dispatch) => { + return function Intercept (opts, handler) { + return dispatch(opts, new ResponseErrorHandler(opts, { handler })) + } + } +} diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js new file mode 100644 index 0000000..1c16fd8 --- /dev/null +++ b/lib/interceptor/retry.js @@ -0,0 +1,19 @@ +'use strict' +const RetryHandler = require('../handler/retry-handler') + +module.exports = globalOpts => { + return dispatch => { + return function retryInterceptor (opts, handler) { + return dispatch( + opts, + new RetryHandler( + { ...opts, retryOptions: { ...globalOpts, ...opts.retryOptions } }, + { + handler, + dispatch + } + ) + ) + } + } +} diff --git a/lib/llhttp/.gitkeep b/lib/llhttp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/llhttp/constants.d.ts b/lib/llhttp/constants.d.ts new file mode 100644 index 0000000..83fa4eb --- /dev/null +++ b/lib/llhttp/constants.d.ts @@ -0,0 +1,97 @@ +export type IntDict = Record; +export declare const ERROR: IntDict; +export declare const TYPE: IntDict; +export declare const FLAGS: IntDict; +export declare const LENIENT_FLAGS: IntDict; +export declare const METHODS: IntDict; +export declare const STATUSES: IntDict; +export declare const FINISH: IntDict; +export declare const HEADER_STATE: IntDict; +export declare const METHODS_HTTP: number[]; +export declare const METHODS_ICE: number[]; +export declare const METHODS_RTSP: number[]; +export declare const METHOD_MAP: IntDict; +export declare const H_METHOD_MAP: { + [k: string]: number; +}; +export declare const STATUSES_HTTP: number[]; +export type CharList = Array; +export declare const ALPHA: CharList; +export declare const NUM_MAP: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; +}; +export declare const HEX_MAP: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; + A: number; + B: number; + C: number; + D: number; + E: number; + F: number; + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +}; +export declare const NUM: CharList; +export declare const ALPHANUM: CharList; +export declare const MARK: CharList; +export declare const USERINFO_CHARS: CharList; +export declare const URL_CHAR: CharList; +export declare const HEX: CharList; +export declare const TOKEN: CharList; +export declare const HEADER_CHARS: CharList; +export declare const CONNECTION_TOKEN_CHARS: CharList; +export declare const QUOTED_STRING: CharList; +export declare const HTAB_SP_VCHAR_OBS_TEXT: CharList; +export declare const MAJOR: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; +}; +export declare const MINOR: { + 0: number; + 1: number; + 2: number; + 3: number; + 4: number; + 5: number; + 6: number; + 7: number; + 8: number; + 9: number; +}; +export declare const SPECIAL_HEADERS: { + connection: number; + 'content-length': number; + 'proxy-connection': number; + 'transfer-encoding': number; + upgrade: number; +}; diff --git a/lib/llhttp/constants.js b/lib/llhttp/constants.js new file mode 100644 index 0000000..3e1dc01 --- /dev/null +++ b/lib/llhttp/constants.js @@ -0,0 +1,498 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SPECIAL_HEADERS = exports.MINOR = exports.MAJOR = exports.HTAB_SP_VCHAR_OBS_TEXT = exports.QUOTED_STRING = exports.CONNECTION_TOKEN_CHARS = exports.HEADER_CHARS = exports.TOKEN = exports.HEX = exports.URL_CHAR = exports.USERINFO_CHARS = exports.MARK = exports.ALPHANUM = exports.NUM = exports.HEX_MAP = exports.NUM_MAP = exports.ALPHA = exports.STATUSES_HTTP = exports.H_METHOD_MAP = exports.METHOD_MAP = exports.METHODS_RTSP = exports.METHODS_ICE = exports.METHODS_HTTP = exports.HEADER_STATE = exports.FINISH = exports.STATUSES = exports.METHODS = exports.LENIENT_FLAGS = exports.FLAGS = exports.TYPE = exports.ERROR = void 0; +const utils_1 = require("./utils"); +// Emums +exports.ERROR = { + OK: 0, + INTERNAL: 1, + STRICT: 2, + CR_EXPECTED: 25, + LF_EXPECTED: 3, + UNEXPECTED_CONTENT_LENGTH: 4, + UNEXPECTED_SPACE: 30, + CLOSED_CONNECTION: 5, + INVALID_METHOD: 6, + INVALID_URL: 7, + INVALID_CONSTANT: 8, + INVALID_VERSION: 9, + INVALID_HEADER_TOKEN: 10, + INVALID_CONTENT_LENGTH: 11, + INVALID_CHUNK_SIZE: 12, + INVALID_STATUS: 13, + INVALID_EOF_STATE: 14, + INVALID_TRANSFER_ENCODING: 15, + CB_MESSAGE_BEGIN: 16, + CB_HEADERS_COMPLETE: 17, + CB_MESSAGE_COMPLETE: 18, + CB_CHUNK_HEADER: 19, + CB_CHUNK_COMPLETE: 20, + PAUSED: 21, + PAUSED_UPGRADE: 22, + PAUSED_H2_UPGRADE: 23, + USER: 24, + CB_URL_COMPLETE: 26, + CB_STATUS_COMPLETE: 27, + CB_METHOD_COMPLETE: 32, + CB_VERSION_COMPLETE: 33, + CB_HEADER_FIELD_COMPLETE: 28, + CB_HEADER_VALUE_COMPLETE: 29, + CB_CHUNK_EXTENSION_NAME_COMPLETE: 34, + CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35, + CB_RESET: 31, +}; +exports.TYPE = { + BOTH: 0, // default + REQUEST: 1, + RESPONSE: 2, +}; +exports.FLAGS = { + CONNECTION_KEEP_ALIVE: 1 << 0, + CONNECTION_CLOSE: 1 << 1, + CONNECTION_UPGRADE: 1 << 2, + CHUNKED: 1 << 3, + UPGRADE: 1 << 4, + CONTENT_LENGTH: 1 << 5, + SKIPBODY: 1 << 6, + TRAILING: 1 << 7, + // 1 << 8 is unused + TRANSFER_ENCODING: 1 << 9, +}; +exports.LENIENT_FLAGS = { + HEADERS: 1 << 0, + CHUNKED_LENGTH: 1 << 1, + KEEP_ALIVE: 1 << 2, + TRANSFER_ENCODING: 1 << 3, + VERSION: 1 << 4, + DATA_AFTER_CLOSE: 1 << 5, + OPTIONAL_LF_AFTER_CR: 1 << 6, + OPTIONAL_CRLF_AFTER_CHUNK: 1 << 7, + OPTIONAL_CR_BEFORE_LF: 1 << 8, + SPACES_AFTER_CHUNK_SIZE: 1 << 9, +}; +exports.METHODS = { + 'DELETE': 0, + 'GET': 1, + 'HEAD': 2, + 'POST': 3, + 'PUT': 4, + /* pathological */ + 'CONNECT': 5, + 'OPTIONS': 6, + 'TRACE': 7, + /* WebDAV */ + 'COPY': 8, + 'LOCK': 9, + 'MKCOL': 10, + 'MOVE': 11, + 'PROPFIND': 12, + 'PROPPATCH': 13, + 'SEARCH': 14, + 'UNLOCK': 15, + 'BIND': 16, + 'REBIND': 17, + 'UNBIND': 18, + 'ACL': 19, + /* subversion */ + 'REPORT': 20, + 'MKACTIVITY': 21, + 'CHECKOUT': 22, + 'MERGE': 23, + /* upnp */ + 'M-SEARCH': 24, + 'NOTIFY': 25, + 'SUBSCRIBE': 26, + 'UNSUBSCRIBE': 27, + /* RFC-5789 */ + 'PATCH': 28, + 'PURGE': 29, + /* CalDAV */ + 'MKCALENDAR': 30, + /* RFC-2068, section 19.6.1.2 */ + 'LINK': 31, + 'UNLINK': 32, + /* icecast */ + 'SOURCE': 33, + /* RFC-7540, section 11.6 */ + 'PRI': 34, + /* RFC-2326 RTSP */ + 'DESCRIBE': 35, + 'ANNOUNCE': 36, + 'SETUP': 37, + 'PLAY': 38, + 'PAUSE': 39, + 'TEARDOWN': 40, + 'GET_PARAMETER': 41, + 'SET_PARAMETER': 42, + 'REDIRECT': 43, + 'RECORD': 44, + /* RAOP */ + 'FLUSH': 45, + /* DRAFT https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-02.html */ + 'QUERY': 46, +}; +exports.STATUSES = { + CONTINUE: 100, + SWITCHING_PROTOCOLS: 101, + PROCESSING: 102, + EARLY_HINTS: 103, + RESPONSE_IS_STALE: 110, // Unofficial + REVALIDATION_FAILED: 111, // Unofficial + DISCONNECTED_OPERATION: 112, // Unofficial + HEURISTIC_EXPIRATION: 113, // Unofficial + MISCELLANEOUS_WARNING: 199, // Unofficial + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NON_AUTHORITATIVE_INFORMATION: 203, + NO_CONTENT: 204, + RESET_CONTENT: 205, + PARTIAL_CONTENT: 206, + MULTI_STATUS: 207, + ALREADY_REPORTED: 208, + TRANSFORMATION_APPLIED: 214, // Unofficial + IM_USED: 226, + MISCELLANEOUS_PERSISTENT_WARNING: 299, // Unofficial + MULTIPLE_CHOICES: 300, + MOVED_PERMANENTLY: 301, + FOUND: 302, + SEE_OTHER: 303, + NOT_MODIFIED: 304, + USE_PROXY: 305, + SWITCH_PROXY: 306, // No longer used + TEMPORARY_REDIRECT: 307, + PERMANENT_REDIRECT: 308, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, + NOT_ACCEPTABLE: 406, + PROXY_AUTHENTICATION_REQUIRED: 407, + REQUEST_TIMEOUT: 408, + CONFLICT: 409, + GONE: 410, + LENGTH_REQUIRED: 411, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + URI_TOO_LONG: 414, + UNSUPPORTED_MEDIA_TYPE: 415, + RANGE_NOT_SATISFIABLE: 416, + EXPECTATION_FAILED: 417, + IM_A_TEAPOT: 418, + PAGE_EXPIRED: 419, // Unofficial + ENHANCE_YOUR_CALM: 420, // Unofficial + MISDIRECTED_REQUEST: 421, + UNPROCESSABLE_ENTITY: 422, + LOCKED: 423, + FAILED_DEPENDENCY: 424, + TOO_EARLY: 425, + UPGRADE_REQUIRED: 426, + PRECONDITION_REQUIRED: 428, + TOO_MANY_REQUESTS: 429, + REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430, // Unofficial + REQUEST_HEADER_FIELDS_TOO_LARGE: 431, + LOGIN_TIMEOUT: 440, // Unofficial + NO_RESPONSE: 444, // Unofficial + RETRY_WITH: 449, // Unofficial + BLOCKED_BY_PARENTAL_CONTROL: 450, // Unofficial + UNAVAILABLE_FOR_LEGAL_REASONS: 451, + CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460, // Unofficial + INVALID_X_FORWARDED_FOR: 463, // Unofficial + REQUEST_HEADER_TOO_LARGE: 494, // Unofficial + SSL_CERTIFICATE_ERROR: 495, // Unofficial + SSL_CERTIFICATE_REQUIRED: 496, // Unofficial + HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497, // Unofficial + INVALID_TOKEN: 498, // Unofficial + CLIENT_CLOSED_REQUEST: 499, // Unofficial + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + HTTP_VERSION_NOT_SUPPORTED: 505, + VARIANT_ALSO_NEGOTIATES: 506, + INSUFFICIENT_STORAGE: 507, + LOOP_DETECTED: 508, + BANDWIDTH_LIMIT_EXCEEDED: 509, + NOT_EXTENDED: 510, + NETWORK_AUTHENTICATION_REQUIRED: 511, + WEB_SERVER_UNKNOWN_ERROR: 520, // Unofficial + WEB_SERVER_IS_DOWN: 521, // Unofficial + CONNECTION_TIMEOUT: 522, // Unofficial + ORIGIN_IS_UNREACHABLE: 523, // Unofficial + TIMEOUT_OCCURED: 524, // Unofficial + SSL_HANDSHAKE_FAILED: 525, // Unofficial + INVALID_SSL_CERTIFICATE: 526, // Unofficial + RAILGUN_ERROR: 527, // Unofficial + SITE_IS_OVERLOADED: 529, // Unofficial + SITE_IS_FROZEN: 530, // Unofficial + IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561, // Unofficial + NETWORK_READ_TIMEOUT: 598, // Unofficial + NETWORK_CONNECT_TIMEOUT: 599, // Unofficial +}; +exports.FINISH = { + SAFE: 0, + SAFE_WITH_CB: 1, + UNSAFE: 2, +}; +exports.HEADER_STATE = { + GENERAL: 0, + CONNECTION: 1, + CONTENT_LENGTH: 2, + TRANSFER_ENCODING: 3, + UPGRADE: 4, + CONNECTION_KEEP_ALIVE: 5, + CONNECTION_CLOSE: 6, + CONNECTION_UPGRADE: 7, + TRANSFER_ENCODING_CHUNKED: 8, +}; +// C headers +exports.METHODS_HTTP = [ + exports.METHODS.DELETE, + exports.METHODS.GET, + exports.METHODS.HEAD, + exports.METHODS.POST, + exports.METHODS.PUT, + exports.METHODS.CONNECT, + exports.METHODS.OPTIONS, + exports.METHODS.TRACE, + exports.METHODS.COPY, + exports.METHODS.LOCK, + exports.METHODS.MKCOL, + exports.METHODS.MOVE, + exports.METHODS.PROPFIND, + exports.METHODS.PROPPATCH, + exports.METHODS.SEARCH, + exports.METHODS.UNLOCK, + exports.METHODS.BIND, + exports.METHODS.REBIND, + exports.METHODS.UNBIND, + exports.METHODS.ACL, + exports.METHODS.REPORT, + exports.METHODS.MKACTIVITY, + exports.METHODS.CHECKOUT, + exports.METHODS.MERGE, + exports.METHODS['M-SEARCH'], + exports.METHODS.NOTIFY, + exports.METHODS.SUBSCRIBE, + exports.METHODS.UNSUBSCRIBE, + exports.METHODS.PATCH, + exports.METHODS.PURGE, + exports.METHODS.MKCALENDAR, + exports.METHODS.LINK, + exports.METHODS.UNLINK, + exports.METHODS.PRI, + // TODO(indutny): should we allow it with HTTP? + exports.METHODS.SOURCE, + exports.METHODS.QUERY, +]; +exports.METHODS_ICE = [ + exports.METHODS.SOURCE, +]; +exports.METHODS_RTSP = [ + exports.METHODS.OPTIONS, + exports.METHODS.DESCRIBE, + exports.METHODS.ANNOUNCE, + exports.METHODS.SETUP, + exports.METHODS.PLAY, + exports.METHODS.PAUSE, + exports.METHODS.TEARDOWN, + exports.METHODS.GET_PARAMETER, + exports.METHODS.SET_PARAMETER, + exports.METHODS.REDIRECT, + exports.METHODS.RECORD, + exports.METHODS.FLUSH, + // For AirPlay + exports.METHODS.GET, + exports.METHODS.POST, +]; +exports.METHOD_MAP = (0, utils_1.enumToMap)(exports.METHODS); +exports.H_METHOD_MAP = Object.fromEntries(Object.entries(exports.METHODS).filter(([k]) => k.startsWith('H'))); +exports.STATUSES_HTTP = [ + exports.STATUSES.CONTINUE, + exports.STATUSES.SWITCHING_PROTOCOLS, + exports.STATUSES.PROCESSING, + exports.STATUSES.EARLY_HINTS, + exports.STATUSES.RESPONSE_IS_STALE, + exports.STATUSES.REVALIDATION_FAILED, + exports.STATUSES.DISCONNECTED_OPERATION, + exports.STATUSES.HEURISTIC_EXPIRATION, + exports.STATUSES.MISCELLANEOUS_WARNING, + exports.STATUSES.OK, + exports.STATUSES.CREATED, + exports.STATUSES.ACCEPTED, + exports.STATUSES.NON_AUTHORITATIVE_INFORMATION, + exports.STATUSES.NO_CONTENT, + exports.STATUSES.RESET_CONTENT, + exports.STATUSES.PARTIAL_CONTENT, + exports.STATUSES.MULTI_STATUS, + exports.STATUSES.ALREADY_REPORTED, + exports.STATUSES.TRANSFORMATION_APPLIED, + exports.STATUSES.IM_USED, + exports.STATUSES.MISCELLANEOUS_PERSISTENT_WARNING, + exports.STATUSES.MULTIPLE_CHOICES, + exports.STATUSES.MOVED_PERMANENTLY, + exports.STATUSES.FOUND, + exports.STATUSES.SEE_OTHER, + exports.STATUSES.NOT_MODIFIED, + exports.STATUSES.USE_PROXY, + exports.STATUSES.SWITCH_PROXY, + exports.STATUSES.TEMPORARY_REDIRECT, + exports.STATUSES.PERMANENT_REDIRECT, + exports.STATUSES.BAD_REQUEST, + exports.STATUSES.UNAUTHORIZED, + exports.STATUSES.PAYMENT_REQUIRED, + exports.STATUSES.FORBIDDEN, + exports.STATUSES.NOT_FOUND, + exports.STATUSES.METHOD_NOT_ALLOWED, + exports.STATUSES.NOT_ACCEPTABLE, + exports.STATUSES.PROXY_AUTHENTICATION_REQUIRED, + exports.STATUSES.REQUEST_TIMEOUT, + exports.STATUSES.CONFLICT, + exports.STATUSES.GONE, + exports.STATUSES.LENGTH_REQUIRED, + exports.STATUSES.PRECONDITION_FAILED, + exports.STATUSES.PAYLOAD_TOO_LARGE, + exports.STATUSES.URI_TOO_LONG, + exports.STATUSES.UNSUPPORTED_MEDIA_TYPE, + exports.STATUSES.RANGE_NOT_SATISFIABLE, + exports.STATUSES.EXPECTATION_FAILED, + exports.STATUSES.IM_A_TEAPOT, + exports.STATUSES.PAGE_EXPIRED, + exports.STATUSES.ENHANCE_YOUR_CALM, + exports.STATUSES.MISDIRECTED_REQUEST, + exports.STATUSES.UNPROCESSABLE_ENTITY, + exports.STATUSES.LOCKED, + exports.STATUSES.FAILED_DEPENDENCY, + exports.STATUSES.TOO_EARLY, + exports.STATUSES.UPGRADE_REQUIRED, + exports.STATUSES.PRECONDITION_REQUIRED, + exports.STATUSES.TOO_MANY_REQUESTS, + exports.STATUSES.REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL, + exports.STATUSES.REQUEST_HEADER_FIELDS_TOO_LARGE, + exports.STATUSES.LOGIN_TIMEOUT, + exports.STATUSES.NO_RESPONSE, + exports.STATUSES.RETRY_WITH, + exports.STATUSES.BLOCKED_BY_PARENTAL_CONTROL, + exports.STATUSES.UNAVAILABLE_FOR_LEGAL_REASONS, + exports.STATUSES.CLIENT_CLOSED_LOAD_BALANCED_REQUEST, + exports.STATUSES.INVALID_X_FORWARDED_FOR, + exports.STATUSES.REQUEST_HEADER_TOO_LARGE, + exports.STATUSES.SSL_CERTIFICATE_ERROR, + exports.STATUSES.SSL_CERTIFICATE_REQUIRED, + exports.STATUSES.HTTP_REQUEST_SENT_TO_HTTPS_PORT, + exports.STATUSES.INVALID_TOKEN, + exports.STATUSES.CLIENT_CLOSED_REQUEST, + exports.STATUSES.INTERNAL_SERVER_ERROR, + exports.STATUSES.NOT_IMPLEMENTED, + exports.STATUSES.BAD_GATEWAY, + exports.STATUSES.SERVICE_UNAVAILABLE, + exports.STATUSES.GATEWAY_TIMEOUT, + exports.STATUSES.HTTP_VERSION_NOT_SUPPORTED, + exports.STATUSES.VARIANT_ALSO_NEGOTIATES, + exports.STATUSES.INSUFFICIENT_STORAGE, + exports.STATUSES.LOOP_DETECTED, + exports.STATUSES.BANDWIDTH_LIMIT_EXCEEDED, + exports.STATUSES.NOT_EXTENDED, + exports.STATUSES.NETWORK_AUTHENTICATION_REQUIRED, + exports.STATUSES.WEB_SERVER_UNKNOWN_ERROR, + exports.STATUSES.WEB_SERVER_IS_DOWN, + exports.STATUSES.CONNECTION_TIMEOUT, + exports.STATUSES.ORIGIN_IS_UNREACHABLE, + exports.STATUSES.TIMEOUT_OCCURED, + exports.STATUSES.SSL_HANDSHAKE_FAILED, + exports.STATUSES.INVALID_SSL_CERTIFICATE, + exports.STATUSES.RAILGUN_ERROR, + exports.STATUSES.SITE_IS_OVERLOADED, + exports.STATUSES.SITE_IS_FROZEN, + exports.STATUSES.IDENTITY_PROVIDER_AUTHENTICATION_ERROR, + exports.STATUSES.NETWORK_READ_TIMEOUT, + exports.STATUSES.NETWORK_CONNECT_TIMEOUT, +]; +exports.ALPHA = []; +for (let i = 'A'.charCodeAt(0); i <= 'Z'.charCodeAt(0); i++) { + // Upper case + exports.ALPHA.push(String.fromCharCode(i)); + // Lower case + exports.ALPHA.push(String.fromCharCode(i + 0x20)); +} +exports.NUM_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, +}; +exports.HEX_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, + A: 0XA, B: 0XB, C: 0XC, D: 0XD, E: 0XE, F: 0XF, + a: 0xa, b: 0xb, c: 0xc, d: 0xd, e: 0xe, f: 0xf, +}; +exports.NUM = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', +]; +exports.ALPHANUM = exports.ALPHA.concat(exports.NUM); +exports.MARK = ['-', '_', '.', '!', '~', '*', '\'', '(', ')']; +exports.USERINFO_CHARS = exports.ALPHANUM + .concat(exports.MARK) + .concat(['%', ';', ':', '&', '=', '+', '$', ',']); +// TODO(indutny): use RFC +exports.URL_CHAR = [ + '!', '"', '$', '%', '&', '\'', + '(', ')', '*', '+', ',', '-', '.', '/', + ':', ';', '<', '=', '>', + '@', '[', '\\', ']', '^', '_', + '`', + '{', '|', '}', '~', +].concat(exports.ALPHANUM); +exports.HEX = exports.NUM.concat(['a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F']); +/* Tokens as defined by rfc 2616. Also lowercases them. + * token = 1* + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + */ +exports.TOKEN = [ + '!', '#', '$', '%', '&', '\'', + '*', '+', '-', '.', + '^', '_', '`', + '|', '~', +].concat(exports.ALPHANUM); +/* + * Verify that a char is a valid visible (printable) US-ASCII + * character or %x80-FF + */ +exports.HEADER_CHARS = ['\t']; +for (let i = 32; i <= 255; i++) { + if (i !== 127) { + exports.HEADER_CHARS.push(i); + } +} +// ',' = \x44 +exports.CONNECTION_TOKEN_CHARS = exports.HEADER_CHARS.filter((c) => c !== 44); +exports.QUOTED_STRING = ['\t', ' ']; +for (let i = 0x21; i <= 0xff; i++) { + if (i !== 0x22 && i !== 0x5c) { // All characters in ASCII except \ and " + exports.QUOTED_STRING.push(i); + } +} +exports.HTAB_SP_VCHAR_OBS_TEXT = ['\t', ' ']; +// VCHAR: https://tools.ietf.org/html/rfc5234#appendix-B.1 +for (let i = 0x21; i <= 0x7E; i++) { + exports.HTAB_SP_VCHAR_OBS_TEXT.push(i); +} +// OBS_TEXT: https://datatracker.ietf.org/doc/html/rfc9110#name-collected-abnf +for (let i = 0x80; i <= 0xff; i++) { + exports.HTAB_SP_VCHAR_OBS_TEXT.push(i); +} +exports.MAJOR = exports.NUM_MAP; +exports.MINOR = exports.MAJOR; +exports.SPECIAL_HEADERS = { + 'connection': exports.HEADER_STATE.CONNECTION, + 'content-length': exports.HEADER_STATE.CONTENT_LENGTH, + 'proxy-connection': exports.HEADER_STATE.CONNECTION, + 'transfer-encoding': exports.HEADER_STATE.TRANSFER_ENCODING, + 'upgrade': exports.HEADER_STATE.UPGRADE, +}; +//# sourceMappingURL=constants.js.map \ No newline at end of file diff --git a/lib/llhttp/utils.d.ts b/lib/llhttp/utils.d.ts new file mode 100644 index 0000000..b1a9a3a --- /dev/null +++ b/lib/llhttp/utils.d.ts @@ -0,0 +1,2 @@ +import { IntDict } from './constants'; +export declare function enumToMap(obj: IntDict, filter?: ReadonlyArray, exceptions?: ReadonlyArray): IntDict; diff --git a/lib/llhttp/utils.js b/lib/llhttp/utils.js new file mode 100644 index 0000000..aaaa5d8 --- /dev/null +++ b/lib/llhttp/utils.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.enumToMap = void 0; +function enumToMap(obj, filter = [], exceptions = []) { + var _a, _b; + const emptyFilter = ((_a = filter === null || filter === void 0 ? void 0 : filter.length) !== null && _a !== void 0 ? _a : 0) === 0; + const emptyExceptions = ((_b = exceptions === null || exceptions === void 0 ? void 0 : exceptions.length) !== null && _b !== void 0 ? _b : 0) === 0; + return Object.fromEntries(Object.entries(obj).filter(([, value]) => { + return (typeof value === 'number' && + (emptyFilter || filter.includes(value)) && + (emptyExceptions || !exceptions.includes(value))); + })); +} +exports.enumToMap = enumToMap; +//# sourceMappingURL=utils.js.map \ No newline at end of file diff --git a/lib/mock/mock-agent.js b/lib/mock/mock-agent.js new file mode 100644 index 0000000..6ee6857 --- /dev/null +++ b/lib/mock/mock-agent.js @@ -0,0 +1,157 @@ +'use strict' + +const { kClients } = require('../core/symbols') +const Agent = require('../dispatcher/agent') +const { + kAgent, + kMockAgentSet, + kMockAgentGet, + kDispatches, + kIsMockActive, + kNetConnect, + kGetNetConnect, + kOptions, + kFactory +} = require('./mock-symbols') +const MockClient = require('./mock-client') +const MockPool = require('./mock-pool') +const { matchValue, buildMockOptions } = require('./mock-utils') +const { InvalidArgumentError, UndiciError } = require('../core/errors') +const Dispatcher = require('../dispatcher/dispatcher') +const PendingInterceptorsFormatter = require('./pending-interceptors-formatter') + +class MockAgent extends Dispatcher { + constructor (opts) { + super(opts) + + this[kNetConnect] = true + this[kIsMockActive] = true + + // Instantiate Agent and encapsulate + if ((opts?.agent && typeof opts.agent.dispatch !== 'function')) { + throw new InvalidArgumentError('Argument opts.agent must implement Agent') + } + const agent = opts?.agent ? opts.agent : new Agent(opts) + this[kAgent] = agent + + this[kClients] = agent[kClients] + this[kOptions] = buildMockOptions(opts) + } + + get (origin) { + let dispatcher = this[kMockAgentGet](origin) + + if (!dispatcher) { + dispatcher = this[kFactory](origin) + this[kMockAgentSet](origin, dispatcher) + } + return dispatcher + } + + dispatch (opts, handler) { + // Call MockAgent.get to perform additional setup before dispatching as normal + this.get(opts.origin) + return this[kAgent].dispatch(opts, handler) + } + + async close () { + await this[kAgent].close() + this[kClients].clear() + } + + deactivate () { + this[kIsMockActive] = false + } + + activate () { + this[kIsMockActive] = true + } + + enableNetConnect (matcher) { + if (typeof matcher === 'string' || typeof matcher === 'function' || matcher instanceof RegExp) { + if (Array.isArray(this[kNetConnect])) { + this[kNetConnect].push(matcher) + } else { + this[kNetConnect] = [matcher] + } + } else if (typeof matcher === 'undefined') { + this[kNetConnect] = true + } else { + throw new InvalidArgumentError('Unsupported matcher. Must be one of String|Function|RegExp.') + } + } + + disableNetConnect () { + this[kNetConnect] = false + } + + // This is required to bypass issues caused by using global symbols - see: + // https://github.com/nodejs/undici/issues/1447 + get isMockActive () { + return this[kIsMockActive] + } + + [kMockAgentSet] (origin, dispatcher) { + this[kClients].set(origin, dispatcher) + } + + [kFactory] (origin) { + const mockOptions = Object.assign({ agent: this }, this[kOptions]) + return this[kOptions] && this[kOptions].connections === 1 + ? new MockClient(origin, mockOptions) + : new MockPool(origin, mockOptions) + } + + [kMockAgentGet] (origin) { + // First check if we can immediately find it + const client = this[kClients].get(origin) + if (client) { + return client + } + + // If the origin is not a string create a dummy parent pool and return to user + if (typeof origin !== 'string') { + const dispatcher = this[kFactory]('http://localhost:9999') + this[kMockAgentSet](origin, dispatcher) + return dispatcher + } + + // If we match, create a pool and assign the same dispatches + for (const [keyMatcher, nonExplicitDispatcher] of Array.from(this[kClients])) { + if (nonExplicitDispatcher && typeof keyMatcher !== 'string' && matchValue(keyMatcher, origin)) { + const dispatcher = this[kFactory](origin) + this[kMockAgentSet](origin, dispatcher) + dispatcher[kDispatches] = nonExplicitDispatcher[kDispatches] + return dispatcher + } + } + } + + [kGetNetConnect] () { + return this[kNetConnect] + } + + pendingInterceptors () { + const mockAgentClients = this[kClients] + + return Array.from(mockAgentClients.entries()) + .flatMap(([origin, scope]) => scope[kDispatches].map(dispatch => ({ ...dispatch, origin }))) + .filter(({ pending }) => pending) + } + + assertNoPendingInterceptors ({ pendingInterceptorsFormatter = new PendingInterceptorsFormatter() } = {}) { + const pending = this.pendingInterceptors() + + if (pending.length === 0) { + return + } + + throw new UndiciError( + pending.length === 1 + ? `1 interceptor is pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.trim() + : `${pending.length} interceptors are pending:\n\n${pendingInterceptorsFormatter.format(pending)}`.trim() + ) + } +} + +module.exports = MockAgent diff --git a/lib/mock/mock-client.js b/lib/mock/mock-client.js new file mode 100644 index 0000000..f8a786c --- /dev/null +++ b/lib/mock/mock-client.js @@ -0,0 +1,64 @@ +'use strict' + +const { promisify } = require('node:util') +const Client = require('../dispatcher/client') +const { buildMockDispatch } = require('./mock-utils') +const { + kDispatches, + kMockAgent, + kClose, + kOriginalClose, + kOrigin, + kOriginalDispatch, + kConnected, + kIgnoreTrailingSlash +} = require('./mock-symbols') +const { MockInterceptor } = require('./mock-interceptor') +const Symbols = require('../core/symbols') +const { InvalidArgumentError } = require('../core/errors') + +/** + * MockClient provides an API that extends the Client to influence the mockDispatches. + */ +class MockClient extends Client { + constructor (origin, opts) { + if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') { + throw new InvalidArgumentError('Argument opts.agent must implement Agent') + } + + super(origin, opts) + + this[kMockAgent] = opts.agent + this[kOrigin] = origin + this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false + this[kDispatches] = [] + this[kConnected] = 1 + this[kOriginalDispatch] = this.dispatch + this[kOriginalClose] = this.close.bind(this) + + this.dispatch = buildMockDispatch.call(this) + this.close = this[kClose] + } + + get [Symbols.kConnected] () { + return this[kConnected] + } + + /** + * Sets up the base interceptor for mocking replies from undici. + */ + intercept (opts) { + return new MockInterceptor( + opts && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts }, + this[kDispatches] + ) + } + + async [kClose] () { + await promisify(this[kOriginalClose])() + this[kConnected] = 0 + this[kMockAgent][Symbols.kClients].delete(this[kOrigin]) + } +} + +module.exports = MockClient diff --git a/lib/mock/mock-errors.js b/lib/mock/mock-errors.js new file mode 100644 index 0000000..ebdc786 --- /dev/null +++ b/lib/mock/mock-errors.js @@ -0,0 +1,19 @@ +'use strict' + +const { UndiciError } = require('../core/errors') + +/** + * The request does not match any registered mock dispatches. + */ +class MockNotMatchedError extends UndiciError { + constructor (message) { + super(message) + this.name = 'MockNotMatchedError' + this.message = message || 'The request does not match any registered mock dispatches' + this.code = 'UND_MOCK_ERR_MOCK_NOT_MATCHED' + } +} + +module.exports = { + MockNotMatchedError +} diff --git a/lib/mock/mock-interceptor.js b/lib/mock/mock-interceptor.js new file mode 100644 index 0000000..1ea7aac --- /dev/null +++ b/lib/mock/mock-interceptor.js @@ -0,0 +1,209 @@ +'use strict' + +const { getResponseData, buildKey, addMockDispatch } = require('./mock-utils') +const { + kDispatches, + kDispatchKey, + kDefaultHeaders, + kDefaultTrailers, + kContentLength, + kMockDispatch, + kIgnoreTrailingSlash +} = require('./mock-symbols') +const { InvalidArgumentError } = require('../core/errors') +const { serializePathWithQuery } = require('../core/util') + +/** + * Defines the scope API for an interceptor reply + */ +class MockScope { + constructor (mockDispatch) { + this[kMockDispatch] = mockDispatch + } + + /** + * Delay a reply by a set amount in ms. + */ + delay (waitInMs) { + if (typeof waitInMs !== 'number' || !Number.isInteger(waitInMs) || waitInMs <= 0) { + throw new InvalidArgumentError('waitInMs must be a valid integer > 0') + } + + this[kMockDispatch].delay = waitInMs + return this + } + + /** + * For a defined reply, never mark as consumed. + */ + persist () { + this[kMockDispatch].persist = true + return this + } + + /** + * Allow one to define a reply for a set amount of matching requests. + */ + times (repeatTimes) { + if (typeof repeatTimes !== 'number' || !Number.isInteger(repeatTimes) || repeatTimes <= 0) { + throw new InvalidArgumentError('repeatTimes must be a valid integer > 0') + } + + this[kMockDispatch].times = repeatTimes + return this + } +} + +/** + * Defines an interceptor for a Mock + */ +class MockInterceptor { + constructor (opts, mockDispatches) { + if (typeof opts !== 'object') { + throw new InvalidArgumentError('opts must be an object') + } + if (typeof opts.path === 'undefined') { + throw new InvalidArgumentError('opts.path must be defined') + } + if (typeof opts.method === 'undefined') { + opts.method = 'GET' + } + // See https://github.com/nodejs/undici/issues/1245 + // As per RFC 3986, clients are not supposed to send URI + // fragments to servers when they retrieve a document, + if (typeof opts.path === 'string') { + if (opts.query) { + opts.path = serializePathWithQuery(opts.path, opts.query) + } else { + // Matches https://github.com/nodejs/undici/blob/main/lib/web/fetch/index.js#L1811 + const parsedURL = new URL(opts.path, 'data://') + opts.path = parsedURL.pathname + parsedURL.search + } + } + if (typeof opts.method === 'string') { + opts.method = opts.method.toUpperCase() + } + + this[kDispatchKey] = buildKey(opts) + this[kDispatches] = mockDispatches + this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false + this[kDefaultHeaders] = {} + this[kDefaultTrailers] = {} + this[kContentLength] = false + } + + createMockScopeDispatchData ({ statusCode, data, responseOptions }) { + const responseData = getResponseData(data) + const contentLength = this[kContentLength] ? { 'content-length': responseData.length } : {} + const headers = { ...this[kDefaultHeaders], ...contentLength, ...responseOptions.headers } + const trailers = { ...this[kDefaultTrailers], ...responseOptions.trailers } + + return { statusCode, data, headers, trailers } + } + + validateReplyParameters (replyParameters) { + if (typeof replyParameters.statusCode === 'undefined') { + throw new InvalidArgumentError('statusCode must be defined') + } + if (typeof replyParameters.responseOptions !== 'object' || replyParameters.responseOptions === null) { + throw new InvalidArgumentError('responseOptions must be an object') + } + } + + /** + * Mock an undici request with a defined reply. + */ + reply (replyOptionsCallbackOrStatusCode) { + // Values of reply aren't available right now as they + // can only be available when the reply callback is invoked. + if (typeof replyOptionsCallbackOrStatusCode === 'function') { + // We'll first wrap the provided callback in another function, + // this function will properly resolve the data from the callback + // when invoked. + const wrappedDefaultsCallback = (opts) => { + // Our reply options callback contains the parameter for statusCode, data and options. + const resolvedData = replyOptionsCallbackOrStatusCode(opts) + + // Check if it is in the right format + if (typeof resolvedData !== 'object' || resolvedData === null) { + throw new InvalidArgumentError('reply options callback must return an object') + } + + const replyParameters = { data: '', responseOptions: {}, ...resolvedData } + this.validateReplyParameters(replyParameters) + // Since the values can be obtained immediately we return them + // from this higher order function that will be resolved later. + return { + ...this.createMockScopeDispatchData(replyParameters) + } + } + + // Add usual dispatch data, but this time set the data parameter to function that will eventually provide data. + const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], wrappedDefaultsCallback, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] }) + return new MockScope(newMockDispatch) + } + + // We can have either one or three parameters, if we get here, + // we should have 1-3 parameters. So we spread the arguments of + // this function to obtain the parameters, since replyData will always + // just be the statusCode. + const replyParameters = { + statusCode: replyOptionsCallbackOrStatusCode, + data: arguments[1] === undefined ? '' : arguments[1], + responseOptions: arguments[2] === undefined ? {} : arguments[2] + } + this.validateReplyParameters(replyParameters) + + // Send in-already provided data like usual + const dispatchData = this.createMockScopeDispatchData(replyParameters) + const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], dispatchData, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] }) + return new MockScope(newMockDispatch) + } + + /** + * Mock an undici request with a defined error. + */ + replyWithError (error) { + if (typeof error === 'undefined') { + throw new InvalidArgumentError('error must be defined') + } + + const newMockDispatch = addMockDispatch(this[kDispatches], this[kDispatchKey], { error }, { ignoreTrailingSlash: this[kIgnoreTrailingSlash] }) + return new MockScope(newMockDispatch) + } + + /** + * Set default reply headers on the interceptor for subsequent replies + */ + defaultReplyHeaders (headers) { + if (typeof headers === 'undefined') { + throw new InvalidArgumentError('headers must be defined') + } + + this[kDefaultHeaders] = headers + return this + } + + /** + * Set default reply trailers on the interceptor for subsequent replies + */ + defaultReplyTrailers (trailers) { + if (typeof trailers === 'undefined') { + throw new InvalidArgumentError('trailers must be defined') + } + + this[kDefaultTrailers] = trailers + return this + } + + /** + * Set reply content length header for replies on the interceptor + */ + replyContentLength () { + this[kContentLength] = true + return this + } +} + +module.exports.MockInterceptor = MockInterceptor +module.exports.MockScope = MockScope diff --git a/lib/mock/mock-pool.js b/lib/mock/mock-pool.js new file mode 100644 index 0000000..a266211 --- /dev/null +++ b/lib/mock/mock-pool.js @@ -0,0 +1,64 @@ +'use strict' + +const { promisify } = require('node:util') +const Pool = require('../dispatcher/pool') +const { buildMockDispatch } = require('./mock-utils') +const { + kDispatches, + kMockAgent, + kClose, + kOriginalClose, + kOrigin, + kOriginalDispatch, + kConnected, + kIgnoreTrailingSlash +} = require('./mock-symbols') +const { MockInterceptor } = require('./mock-interceptor') +const Symbols = require('../core/symbols') +const { InvalidArgumentError } = require('../core/errors') + +/** + * MockPool provides an API that extends the Pool to influence the mockDispatches. + */ +class MockPool extends Pool { + constructor (origin, opts) { + if (!opts || !opts.agent || typeof opts.agent.dispatch !== 'function') { + throw new InvalidArgumentError('Argument opts.agent must implement Agent') + } + + super(origin, opts) + + this[kMockAgent] = opts.agent + this[kOrigin] = origin + this[kIgnoreTrailingSlash] = opts.ignoreTrailingSlash ?? false + this[kDispatches] = [] + this[kConnected] = 1 + this[kOriginalDispatch] = this.dispatch + this[kOriginalClose] = this.close.bind(this) + + this.dispatch = buildMockDispatch.call(this) + this.close = this[kClose] + } + + get [Symbols.kConnected] () { + return this[kConnected] + } + + /** + * Sets up the base interceptor for mocking replies from undici. + */ + intercept (opts) { + return new MockInterceptor( + opts && { ignoreTrailingSlash: this[kIgnoreTrailingSlash], ...opts }, + this[kDispatches] + ) + } + + async [kClose] () { + await promisify(this[kOriginalClose])() + this[kConnected] = 0 + this[kMockAgent][Symbols.kClients].delete(this[kOrigin]) + } +} + +module.exports = MockPool diff --git a/lib/mock/mock-symbols.js b/lib/mock/mock-symbols.js new file mode 100644 index 0000000..492eddd --- /dev/null +++ b/lib/mock/mock-symbols.js @@ -0,0 +1,25 @@ +'use strict' + +module.exports = { + kAgent: Symbol('agent'), + kOptions: Symbol('options'), + kFactory: Symbol('factory'), + kDispatches: Symbol('dispatches'), + kDispatchKey: Symbol('dispatch key'), + kDefaultHeaders: Symbol('default headers'), + kDefaultTrailers: Symbol('default trailers'), + kContentLength: Symbol('content length'), + kMockAgent: Symbol('mock agent'), + kMockAgentSet: Symbol('mock agent set'), + kMockAgentGet: Symbol('mock agent get'), + kMockDispatch: Symbol('mock dispatch'), + kClose: Symbol('close'), + kOriginalClose: Symbol('original agent close'), + kOriginalDispatch: Symbol('original dispatch'), + kOrigin: Symbol('origin'), + kIsMockActive: Symbol('is mock active'), + kNetConnect: Symbol('net connect'), + kGetNetConnect: Symbol('get net connect'), + kConnected: Symbol('connected'), + kIgnoreTrailingSlash: Symbol('ignore trailing slash') +} diff --git a/lib/mock/mock-utils.js b/lib/mock/mock-utils.js new file mode 100644 index 0000000..b19aaaf --- /dev/null +++ b/lib/mock/mock-utils.js @@ -0,0 +1,391 @@ +'use strict' + +const { MockNotMatchedError } = require('./mock-errors') +const { + kDispatches, + kMockAgent, + kOriginalDispatch, + kOrigin, + kGetNetConnect +} = require('./mock-symbols') +const { serializePathWithQuery } = require('../core/util') +const { STATUS_CODES } = require('node:http') +const { + types: { + isPromise + } +} = require('node:util') + +function matchValue (match, value) { + if (typeof match === 'string') { + return match === value + } + if (match instanceof RegExp) { + return match.test(value) + } + if (typeof match === 'function') { + return match(value) === true + } + return false +} + +function lowerCaseEntries (headers) { + return Object.fromEntries( + Object.entries(headers).map(([headerName, headerValue]) => { + return [headerName.toLocaleLowerCase(), headerValue] + }) + ) +} + +/** + * @param {import('../../index').Headers|string[]|Record} headers + * @param {string} key + */ +function getHeaderByName (headers, key) { + if (Array.isArray(headers)) { + for (let i = 0; i < headers.length; i += 2) { + if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) { + return headers[i + 1] + } + } + + return undefined + } else if (typeof headers.get === 'function') { + return headers.get(key) + } else { + return lowerCaseEntries(headers)[key.toLocaleLowerCase()] + } +} + +/** @param {string[]} headers */ +function buildHeadersFromArray (headers) { // fetch HeadersList + const clone = headers.slice() + const entries = [] + for (let index = 0; index < clone.length; index += 2) { + entries.push([clone[index], clone[index + 1]]) + } + return Object.fromEntries(entries) +} + +function matchHeaders (mockDispatch, headers) { + if (typeof mockDispatch.headers === 'function') { + if (Array.isArray(headers)) { // fetch HeadersList + headers = buildHeadersFromArray(headers) + } + return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {}) + } + if (typeof mockDispatch.headers === 'undefined') { + return true + } + if (typeof headers !== 'object' || typeof mockDispatch.headers !== 'object') { + return false + } + + for (const [matchHeaderName, matchHeaderValue] of Object.entries(mockDispatch.headers)) { + const headerValue = getHeaderByName(headers, matchHeaderName) + + if (!matchValue(matchHeaderValue, headerValue)) { + return false + } + } + return true +} + +function safeUrl (path) { + if (typeof path !== 'string') { + return path + } + + const pathSegments = path.split('?') + + if (pathSegments.length !== 2) { + return path + } + + const qp = new URLSearchParams(pathSegments.pop()) + qp.sort() + return [...pathSegments, qp.toString()].join('?') +} + +function matchKey (mockDispatch, { path, method, body, headers }) { + const pathMatch = matchValue(mockDispatch.path, path) + const methodMatch = matchValue(mockDispatch.method, method) + const bodyMatch = typeof mockDispatch.body !== 'undefined' ? matchValue(mockDispatch.body, body) : true + const headersMatch = matchHeaders(mockDispatch, headers) + return pathMatch && methodMatch && bodyMatch && headersMatch +} + +function getResponseData (data) { + if (Buffer.isBuffer(data)) { + return data + } else if (data instanceof Uint8Array) { + return data + } else if (data instanceof ArrayBuffer) { + return data + } else if (typeof data === 'object') { + return JSON.stringify(data) + } else { + return data.toString() + } +} + +function getMockDispatch (mockDispatches, key) { + const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path + const resolvedPath = typeof basePath === 'string' ? safeUrl(basePath) : basePath + + const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath) + + // Match path + let matchedMockDispatches = mockDispatches + .filter(({ consumed }) => !consumed) + .filter(({ path, ignoreTrailingSlash }) => { + return ignoreTrailingSlash + ? matchValue(removeTrailingSlash(safeUrl(path)), resolvedPathWithoutTrailingSlash) + : matchValue(safeUrl(path), resolvedPath) + }) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`) + } + + // Match method + matchedMockDispatches = matchedMockDispatches.filter(({ method }) => matchValue(method, key.method)) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for method '${key.method}' on path '${resolvedPath}'`) + } + + // Match body + matchedMockDispatches = matchedMockDispatches.filter(({ body }) => typeof body !== 'undefined' ? matchValue(body, key.body) : true) + if (matchedMockDispatches.length === 0) { + throw new MockNotMatchedError(`Mock dispatch not matched for body '${key.body}' on path '${resolvedPath}'`) + } + + // Match headers + matchedMockDispatches = matchedMockDispatches.filter((mockDispatch) => matchHeaders(mockDispatch, key.headers)) + if (matchedMockDispatches.length === 0) { + const headers = typeof key.headers === 'object' ? JSON.stringify(key.headers) : key.headers + throw new MockNotMatchedError(`Mock dispatch not matched for headers '${headers}' on path '${resolvedPath}'`) + } + + return matchedMockDispatches[0] +} + +function addMockDispatch (mockDispatches, key, data, opts) { + const baseData = { timesInvoked: 0, times: 1, persist: false, consumed: false, ...opts } + const replyData = typeof data === 'function' ? { callback: data } : { ...data } + const newMockDispatch = { ...baseData, ...key, pending: true, data: { error: null, ...replyData } } + mockDispatches.push(newMockDispatch) + return newMockDispatch +} + +function deleteMockDispatch (mockDispatches, key) { + const index = mockDispatches.findIndex(dispatch => { + if (!dispatch.consumed) { + return false + } + return matchKey(dispatch, key) + }) + if (index !== -1) { + mockDispatches.splice(index, 1) + } +} + +/** + * @param {string} path Path to remove trailing slash from + */ +function removeTrailingSlash (path) { + while (path.endsWith('/')) { + path = path.slice(0, -1) + } + + if (path.length === 0) { + path = '/' + } + + return path +} + +function buildKey (opts) { + const { path, method, body, headers, query } = opts + + return { + path, + method, + body, + headers, + query + } +} + +function generateKeyValues (data) { + const keys = Object.keys(data) + const result = [] + for (let i = 0; i < keys.length; ++i) { + const key = keys[i] + const value = data[key] + const name = Buffer.from(`${key}`) + if (Array.isArray(value)) { + for (let j = 0; j < value.length; ++j) { + result.push(name, Buffer.from(`${value[j]}`)) + } + } else { + result.push(name, Buffer.from(`${value}`)) + } + } + return result +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + * @param {number} statusCode + */ +function getStatusText (statusCode) { + return STATUS_CODES[statusCode] || 'unknown' +} + +async function getResponse (body) { + const buffers = [] + for await (const data of body) { + buffers.push(data) + } + return Buffer.concat(buffers).toString('utf8') +} + +/** + * Mock dispatch function used to simulate undici dispatches + */ +function mockDispatch (opts, handler) { + // Get mock dispatch from built key + const key = buildKey(opts) + const mockDispatch = getMockDispatch(this[kDispatches], key) + + mockDispatch.timesInvoked++ + + // Here's where we resolve a callback if a callback is present for the dispatch data. + if (mockDispatch.data.callback) { + mockDispatch.data = { ...mockDispatch.data, ...mockDispatch.data.callback(opts) } + } + + // Parse mockDispatch data + const { data: { statusCode, data, headers, trailers, error }, delay, persist } = mockDispatch + const { timesInvoked, times } = mockDispatch + + // If it's used up and not persistent, mark as consumed + mockDispatch.consumed = !persist && timesInvoked >= times + mockDispatch.pending = timesInvoked < times + + // If specified, trigger dispatch error + if (error !== null) { + deleteMockDispatch(this[kDispatches], key) + handler.onError(error) + return true + } + + // Handle the request with a delay if necessary + if (typeof delay === 'number' && delay > 0) { + setTimeout(() => { + handleReply(this[kDispatches]) + }, delay) + } else { + handleReply(this[kDispatches]) + } + + function handleReply (mockDispatches, _data = data) { + // fetch's HeadersList is a 1D string array + const optsHeaders = Array.isArray(opts.headers) + ? buildHeadersFromArray(opts.headers) + : opts.headers + const body = typeof _data === 'function' + ? _data({ ...opts, headers: optsHeaders }) + : _data + + // util.types.isPromise is likely needed for jest. + if (isPromise(body)) { + // If handleReply is asynchronous, throwing an error + // in the callback will reject the promise, rather than + // synchronously throw the error, which breaks some tests. + // Rather, we wait for the callback to resolve if it is a + // promise, and then re-run handleReply with the new body. + body.then((newData) => handleReply(mockDispatches, newData)) + return + } + + const responseData = getResponseData(body) + const responseHeaders = generateKeyValues(headers) + const responseTrailers = generateKeyValues(trailers) + + handler.onConnect?.(err => handler.onError(err), null) + handler.onHeaders?.(statusCode, responseHeaders, resume, getStatusText(statusCode)) + handler.onData?.(Buffer.from(responseData)) + handler.onComplete?.(responseTrailers) + deleteMockDispatch(mockDispatches, key) + } + + function resume () {} + + return true +} + +function buildMockDispatch () { + const agent = this[kMockAgent] + const origin = this[kOrigin] + const originalDispatch = this[kOriginalDispatch] + + return function dispatch (opts, handler) { + if (agent.isMockActive) { + try { + mockDispatch.call(this, opts, handler) + } catch (error) { + if (error instanceof MockNotMatchedError) { + const netConnect = agent[kGetNetConnect]() + if (netConnect === false) { + throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`) + } + if (checkNetConnect(netConnect, origin)) { + originalDispatch.call(this, opts, handler) + } else { + throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)`) + } + } else { + throw error + } + } + } else { + originalDispatch.call(this, opts, handler) + } + } +} + +function checkNetConnect (netConnect, origin) { + const url = new URL(origin) + if (netConnect === true) { + return true + } else if (Array.isArray(netConnect) && netConnect.some((matcher) => matchValue(matcher, url.host))) { + return true + } + return false +} + +function buildMockOptions (opts) { + if (opts) { + const { agent, ...mockOptions } = opts + return mockOptions + } +} + +module.exports = { + getResponseData, + getMockDispatch, + addMockDispatch, + deleteMockDispatch, + buildKey, + generateKeyValues, + matchValue, + getResponse, + getStatusText, + mockDispatch, + buildMockDispatch, + checkNetConnect, + buildMockOptions, + getHeaderByName, + buildHeadersFromArray +} diff --git a/lib/mock/pending-interceptors-formatter.js b/lib/mock/pending-interceptors-formatter.js new file mode 100644 index 0000000..ccca951 --- /dev/null +++ b/lib/mock/pending-interceptors-formatter.js @@ -0,0 +1,43 @@ +'use strict' + +const { Transform } = require('node:stream') +const { Console } = require('node:console') + +const PERSISTENT = process.versions.icu ? '✅' : 'Y ' +const NOT_PERSISTENT = process.versions.icu ? '❌' : 'N ' + +/** + * Gets the output of `console.table(…)` as a string. + */ +module.exports = class PendingInterceptorsFormatter { + constructor ({ disableColors } = {}) { + this.transform = new Transform({ + transform (chunk, _enc, cb) { + cb(null, chunk) + } + }) + + this.logger = new Console({ + stdout: this.transform, + inspectOptions: { + colors: !disableColors && !process.env.CI + } + }) + } + + format (pendingInterceptors) { + const withPrettyHeaders = pendingInterceptors.map( + ({ method, path, data: { statusCode }, persist, times, timesInvoked, origin }) => ({ + Method: method, + Origin: origin, + Path: path, + 'Status code': statusCode, + Persistent: persist ? PERSISTENT : NOT_PERSISTENT, + Invocations: timesInvoked, + Remaining: persist ? Infinity : times - timesInvoked + })) + + this.logger.table(withPrettyHeaders) + return this.transform.read().toString() + } +} diff --git a/lib/util/cache.js b/lib/util/cache.js new file mode 100644 index 0000000..35c5351 --- /dev/null +++ b/lib/util/cache.js @@ -0,0 +1,359 @@ +'use strict' + +const { + safeHTTPMethods +} = require('../core/util') + +/** + * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts + */ +function makeCacheKey (opts) { + if (!opts.origin) { + throw new Error('opts.origin is undefined') + } + + /** @type {Record} */ + let headers + if (opts.headers == null) { + headers = {} + } else if (typeof opts.headers[Symbol.iterator] === 'function') { + headers = {} + for (const x of opts.headers) { + if (!Array.isArray(x)) { + throw new Error('opts.headers is not a valid header map') + } + const [key, val] = x + if (typeof key !== 'string' || typeof val !== 'string') { + throw new Error('opts.headers is not a valid header map') + } + headers[key] = val + } + } else if (typeof opts.headers === 'object') { + headers = opts.headers + } else { + throw new Error('opts.headers is not an object') + } + + return { + origin: opts.origin.toString(), + method: opts.method, + path: opts.path, + headers + } +} + +/** + * @param {any} key + */ +function assertCacheKey (key) { + if (typeof key !== 'object') { + throw new TypeError(`expected key to be object, got ${typeof key}`) + } + + for (const property of ['origin', 'method', 'path']) { + if (typeof key[property] !== 'string') { + throw new TypeError(`expected key.${property} to be string, got ${typeof key[property]}`) + } + } + + if (key.headers !== undefined && typeof key.headers !== 'object') { + throw new TypeError(`expected headers to be object, got ${typeof key}`) + } +} + +/** + * @param {any} value + */ +function assertCacheValue (value) { + if (typeof value !== 'object') { + throw new TypeError(`expected value to be object, got ${typeof value}`) + } + + for (const property of ['statusCode', 'cachedAt', 'staleAt', 'deleteAt']) { + if (typeof value[property] !== 'number') { + throw new TypeError(`expected value.${property} to be number, got ${typeof value[property]}`) + } + } + + if (typeof value.statusMessage !== 'string') { + throw new TypeError(`expected value.statusMessage to be string, got ${typeof value.statusMessage}`) + } + + if (value.headers != null && typeof value.headers !== 'object') { + throw new TypeError(`expected value.rawHeaders to be object, got ${typeof value.headers}`) + } + + if (value.vary !== undefined && typeof value.vary !== 'object') { + throw new TypeError(`expected value.vary to be object, got ${typeof value.vary}`) + } + + if (value.etag !== undefined && typeof value.etag !== 'string') { + throw new TypeError(`expected value.etag to be string, got ${typeof value.etag}`) + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-cache-control + * @see https://www.iana.org/assignments/http-cache-directives/http-cache-directives.xhtml + + * @param {string | string[]} header + * @returns {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} + */ +function parseCacheControlHeader (header) { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} + */ + const output = {} + + let directives + if (Array.isArray(header)) { + directives = [] + + for (const directive of header) { + directives.push(...directive.split(',')) + } + } else { + directives = header.split(',') + } + + for (let i = 0; i < directives.length; i++) { + const directive = directives[i].toLowerCase() + const keyValueDelimiter = directive.indexOf('=') + + let key + let value + if (keyValueDelimiter !== -1) { + key = directive.substring(0, keyValueDelimiter).trimStart() + value = directive.substring(keyValueDelimiter + 1) + } else { + key = directive.trim() + } + + switch (key) { + case 'min-fresh': + case 'max-stale': + case 'max-age': + case 's-maxage': + case 'stale-while-revalidate': + case 'stale-if-error': { + if (value === undefined || value[0] === ' ') { + continue + } + + if ( + value.length >= 2 && + value[0] === '"' && + value[value.length - 1] === '"' + ) { + value = value.substring(1, value.length - 1) + } + + const parsedValue = parseInt(value, 10) + // eslint-disable-next-line no-self-compare + if (parsedValue !== parsedValue) { + continue + } + + if (key === 'max-age' && key in output && output[key] >= parsedValue) { + continue + } + + output[key] = parsedValue + + break + } + case 'private': + case 'no-cache': { + if (value) { + // The private and no-cache directives can be unqualified (aka just + // `private` or `no-cache`) or qualified (w/ a value). When they're + // qualified, it's a list of headers like `no-cache=header1`, + // `no-cache="header1"`, or `no-cache="header1, header2"` + // If we're given multiple headers, the comma messes us up since + // we split the full header by commas. So, let's loop through the + // remaining parts in front of us until we find one that ends in a + // quote. We can then just splice all of the parts in between the + // starting quote and the ending quote out of the directives array + // and continue parsing like normal. + // https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2 + if (value[0] === '"') { + // Something like `no-cache="some-header"` OR `no-cache="some-header, another-header"`. + + // Add the first header on and cut off the leading quote + const headers = [value.substring(1)] + + let foundEndingQuote = value[value.length - 1] === '"' + if (!foundEndingQuote) { + // Something like `no-cache="some-header, another-header"` + // This can still be something invalid, e.g. `no-cache="some-header, ...` + for (let j = i + 1; j < directives.length; j++) { + const nextPart = directives[j] + const nextPartLength = nextPart.length + + headers.push(nextPart.trim()) + + if (nextPartLength !== 0 && nextPart[nextPartLength - 1] === '"') { + foundEndingQuote = true + break + } + } + } + + if (foundEndingQuote) { + let lastHeader = headers[headers.length - 1] + if (lastHeader[lastHeader.length - 1] === '"') { + lastHeader = lastHeader.substring(0, lastHeader.length - 1) + headers[headers.length - 1] = lastHeader + } + + if (key in output) { + output[key] = output[key].concat(headers) + } else { + output[key] = headers + } + } + } else { + // Something like `no-cache=some-header` + if (key in output) { + output[key] = output[key].concat(value) + } else { + output[key] = [value] + } + } + + break + } + } + // eslint-disable-next-line no-fallthrough + case 'public': + case 'no-store': + case 'must-revalidate': + case 'proxy-revalidate': + case 'immutable': + case 'no-transform': + case 'must-understand': + case 'only-if-cached': + if (value) { + // These are qualified (something like `public=...`) when they aren't + // allowed to be, skip + continue + } + + output[key] = true + break + default: + // Ignore unknown directives as per https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.3-1 + continue + } + } + + return output +} + +/** + * @param {string | string[]} varyHeader Vary header from the server + * @param {Record} headers Request headers + * @returns {Record} + */ +function parseVaryHeader (varyHeader, headers) { + if (typeof varyHeader === 'string' && varyHeader.includes('*')) { + return headers + } + + const output = /** @type {Record} */ ({}) + + const varyingHeaders = typeof varyHeader === 'string' + ? varyHeader.split(',') + : varyHeader + for (const header of varyingHeaders) { + const trimmedHeader = header.trim().toLowerCase() + + if (headers[trimmedHeader]) { + output[trimmedHeader] = headers[trimmedHeader] + } else { + return undefined + } + } + + return output +} + +/** + * Note: this deviates from the spec a little. Empty etags ("", W/"") are valid, + * however, including them in cached resposnes serves little to no purpose. + * + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-etag + * + * @param {string} etag + * @returns {boolean} + */ +function isEtagUsable (etag) { + if (etag.length <= 2) { + // Shortest an etag can be is two chars (just ""). This is where we deviate + // from the spec requiring a min of 3 chars however + return false + } + + if (etag[0] === '"' && etag[etag.length - 1] === '"') { + // ETag: ""asd123"" or ETag: "W/"asd123"", kinda undefined behavior in the + // spec. Some servers will accept these while others don't. + // ETag: "asd123" + return !(etag[1] === '"' || etag.startsWith('"W/')) + } + + if (etag.startsWith('W/"') && etag[etag.length - 1] === '"') { + // ETag: W/"", also where we deviate from the spec & require a min of 3 + // chars + // ETag: for W/"", W/"asd123" + return etag.length !== 4 + } + + // Anything else + return false +} + +/** + * @param {unknown} store + * @returns {asserts store is import('../../types/cache-interceptor.d.ts').default.CacheStore} + */ +function assertCacheStore (store, name = 'CacheStore') { + if (typeof store !== 'object' || store === null) { + throw new TypeError(`expected type of ${name} to be a CacheStore, got ${store === null ? 'null' : typeof store}`) + } + + for (const fn of ['get', 'createWriteStream', 'delete']) { + if (typeof store[fn] !== 'function') { + throw new TypeError(`${name} needs to have a \`${fn}()\` function`) + } + } +} +/** + * @param {unknown} methods + * @returns {asserts methods is import('../../types/cache-interceptor.d.ts').default.CacheMethods[]} + */ +function assertCacheMethods (methods, name = 'CacheMethods') { + if (!Array.isArray(methods)) { + throw new TypeError(`expected type of ${name} needs to be an array, got ${methods === null ? 'null' : typeof methods}`) + } + + if (methods.length === 0) { + throw new TypeError(`${name} needs to have at least one method`) + } + + for (const method of methods) { + if (!safeHTTPMethods.includes(method)) { + throw new TypeError(`element of ${name}-array needs to be one of following values: ${safeHTTPMethods.join(', ')}, got ${method}`) + } + } +} + +module.exports = { + makeCacheKey, + assertCacheKey, + assertCacheValue, + parseCacheControlHeader, + parseVaryHeader, + isEtagUsable, + assertCacheMethods, + assertCacheStore +} diff --git a/lib/util/date.js b/lib/util/date.js new file mode 100644 index 0000000..b871c44 --- /dev/null +++ b/lib/util/date.js @@ -0,0 +1,259 @@ +'use strict' + +const IMF_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] +const IMF_SPACES = [4, 7, 11, 16, 25] +const IMF_MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] +const IMF_COLONS = [19, 22] + +const ASCTIME_SPACES = [3, 7, 10, 19] + +const RFC850_DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] + +/** + * @see https://www.rfc-editor.org/rfc/rfc9110.html#name-date-time-formats + * + * @param {string} date + * @param {Date} [now] + * @returns {Date | undefined} + */ +function parseHttpDate (date, now) { + // Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate + // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format + // Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format + + date = date.toLowerCase() + + switch (date[3]) { + case ',': return parseImfDate(date) + case ' ': return parseAscTimeDate(date) + default: return parseRfc850Date(date, now) + } +} + +/** + * @see https://httpwg.org/specs/rfc9110.html#preferred.date.format + * + * @param {string} date + * @returns {Date | undefined} + */ +function parseImfDate (date) { + if (date.length !== 29) { + return undefined + } + + if (!date.endsWith('gmt')) { + // Unsupported timezone + return undefined + } + + for (const spaceInx of IMF_SPACES) { + if (date[spaceInx] !== ' ') { + return undefined + } + } + + for (const colonIdx of IMF_COLONS) { + if (date[colonIdx] !== ':') { + return undefined + } + } + + const dayName = date.substring(0, 3) + if (!IMF_DAYS.includes(dayName)) { + return undefined + } + + const dayString = date.substring(5, 7) + const day = Number.parseInt(dayString) + if (isNaN(day) || (day < 10 && dayString[0] !== '0')) { + // Not a number, 0, or it's less than 10 and didn't start with a 0 + return undefined + } + + const month = date.substring(8, 11) + const monthIdx = IMF_MONTHS.indexOf(month) + if (monthIdx === -1) { + return undefined + } + + const year = Number.parseInt(date.substring(12, 16)) + if (isNaN(year)) { + return undefined + } + + const hourString = date.substring(17, 19) + const hour = Number.parseInt(hourString) + if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) { + return undefined + } + + const minuteString = date.substring(20, 22) + const minute = Number.parseInt(minuteString) + if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) { + return undefined + } + + const secondString = date.substring(23, 25) + const second = Number.parseInt(secondString) + if (isNaN(second) || (second < 10 && secondString[0] !== '0')) { + return undefined + } + + return new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) +} + +/** + * @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats + * + * @param {string} date + * @returns {Date | undefined} + */ +function parseAscTimeDate (date) { + // This is assumed to be in UTC + + if (date.length !== 24) { + return undefined + } + + for (const spaceIdx of ASCTIME_SPACES) { + if (date[spaceIdx] !== ' ') { + return undefined + } + } + + const dayName = date.substring(0, 3) + if (!IMF_DAYS.includes(dayName)) { + return undefined + } + + const month = date.substring(4, 7) + const monthIdx = IMF_MONTHS.indexOf(month) + if (monthIdx === -1) { + return undefined + } + + const dayString = date.substring(8, 10) + const day = Number.parseInt(dayString) + if (isNaN(day) || (day < 10 && dayString[0] !== ' ')) { + return undefined + } + + const hourString = date.substring(11, 13) + const hour = Number.parseInt(hourString) + if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) { + return undefined + } + + const minuteString = date.substring(14, 16) + const minute = Number.parseInt(minuteString) + if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) { + return undefined + } + + const secondString = date.substring(17, 19) + const second = Number.parseInt(secondString) + if (isNaN(second) || (second < 10 && secondString[0] !== '0')) { + return undefined + } + + const year = Number.parseInt(date.substring(20, 24)) + if (isNaN(year)) { + return undefined + } + + return new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) +} + +/** + * @see https://httpwg.org/specs/rfc9110.html#obsolete.date.formats + * + * @param {string} date + * @param {Date} [now] + * @returns {Date | undefined} + */ +function parseRfc850Date (date, now = new Date()) { + if (!date.endsWith('gmt')) { + // Unsupported timezone + return undefined + } + + const commaIndex = date.indexOf(',') + if (commaIndex === -1) { + return undefined + } + + if ((date.length - commaIndex - 1) !== 23) { + return undefined + } + + const dayName = date.substring(0, commaIndex) + if (!RFC850_DAYS.includes(dayName)) { + return undefined + } + + if ( + date[commaIndex + 1] !== ' ' || + date[commaIndex + 4] !== '-' || + date[commaIndex + 8] !== '-' || + date[commaIndex + 11] !== ' ' || + date[commaIndex + 14] !== ':' || + date[commaIndex + 17] !== ':' || + date[commaIndex + 20] !== ' ' + ) { + return undefined + } + + const dayString = date.substring(commaIndex + 2, commaIndex + 4) + const day = Number.parseInt(dayString) + if (isNaN(day) || (day < 10 && dayString[0] !== '0')) { + // Not a number, or it's less than 10 and didn't start with a 0 + return undefined + } + + const month = date.substring(commaIndex + 5, commaIndex + 8) + const monthIdx = IMF_MONTHS.indexOf(month) + if (monthIdx === -1) { + return undefined + } + + // As of this point year is just the decade (i.e. 94) + let year = Number.parseInt(date.substring(commaIndex + 9, commaIndex + 11)) + if (isNaN(year)) { + return undefined + } + + const currentYear = now.getUTCFullYear() + const currentDecade = currentYear % 100 + const currentCentury = Math.floor(currentYear / 100) + + if (year > currentDecade && year - currentDecade >= 50) { + // Over 50 years in future, go to previous century + year += (currentCentury - 1) * 100 + } else { + year += currentCentury * 100 + } + + const hourString = date.substring(commaIndex + 12, commaIndex + 14) + const hour = Number.parseInt(hourString) + if (isNaN(hour) || (hour < 10 && hourString[0] !== '0')) { + return undefined + } + + const minuteString = date.substring(commaIndex + 15, commaIndex + 17) + const minute = Number.parseInt(minuteString) + if (isNaN(minute) || (minute < 10 && minuteString[0] !== '0')) { + return undefined + } + + const secondString = date.substring(commaIndex + 18, commaIndex + 20) + const second = Number.parseInt(secondString) + if (isNaN(second) || (second < 10 && secondString[0] !== '0')) { + return undefined + } + + return new Date(Date.UTC(year, monthIdx, day, hour, minute, second)) +} + +module.exports = { + parseHttpDate +} diff --git a/lib/util/timers.js b/lib/util/timers.js new file mode 100644 index 0000000..c15bbc3 --- /dev/null +++ b/lib/util/timers.js @@ -0,0 +1,423 @@ +'use strict' + +/** + * This module offers an optimized timer implementation designed for scenarios + * where high precision is not critical. + * + * The timer achieves faster performance by using a low-resolution approach, + * with an accuracy target of within 500ms. This makes it particularly useful + * for timers with delays of 1 second or more, where exact timing is less + * crucial. + * + * It's important to note that Node.js timers are inherently imprecise, as + * delays can occur due to the event loop being blocked by other operations. + * Consequently, timers may trigger later than their scheduled time. + */ + +/** + * The fastNow variable contains the internal fast timer clock value. + * + * @type {number} + */ +let fastNow = 0 + +/** + * RESOLUTION_MS represents the target resolution time in milliseconds. + * + * @type {number} + * @default 1000 + */ +const RESOLUTION_MS = 1e3 + +/** + * TICK_MS defines the desired interval in milliseconds between each tick. + * The target value is set to half the resolution time, minus 1 ms, to account + * for potential event loop overhead. + * + * @type {number} + * @default 499 + */ +const TICK_MS = (RESOLUTION_MS >> 1) - 1 + +/** + * fastNowTimeout is a Node.js timer used to manage and process + * the FastTimers stored in the `fastTimers` array. + * + * @type {NodeJS.Timeout} + */ +let fastNowTimeout + +/** + * The kFastTimer symbol is used to identify FastTimer instances. + * + * @type {Symbol} + */ +const kFastTimer = Symbol('kFastTimer') + +/** + * The fastTimers array contains all active FastTimers. + * + * @type {FastTimer[]} + */ +const fastTimers = [] + +/** + * These constants represent the various states of a FastTimer. + */ + +/** + * The `NOT_IN_LIST` constant indicates that the FastTimer is not included + * in the `fastTimers` array. Timers with this status will not be processed + * during the next tick by the `onTick` function. + * + * A FastTimer can be re-added to the `fastTimers` array by invoking the + * `refresh` method on the FastTimer instance. + * + * @type {-2} + */ +const NOT_IN_LIST = -2 + +/** + * The `TO_BE_CLEARED` constant indicates that the FastTimer is scheduled + * for removal from the `fastTimers` array. A FastTimer in this state will + * be removed in the next tick by the `onTick` function and will no longer + * be processed. + * + * This status is also set when the `clear` method is called on the FastTimer instance. + * + * @type {-1} + */ +const TO_BE_CLEARED = -1 + +/** + * The `PENDING` constant signifies that the FastTimer is awaiting processing + * in the next tick by the `onTick` function. Timers with this status will have + * their `_idleStart` value set and their status updated to `ACTIVE` in the next tick. + * + * @type {0} + */ +const PENDING = 0 + +/** + * The `ACTIVE` constant indicates that the FastTimer is active and waiting + * for its timer to expire. During the next tick, the `onTick` function will + * check if the timer has expired, and if so, it will execute the associated callback. + * + * @type {1} + */ +const ACTIVE = 1 + +/** + * The onTick function processes the fastTimers array. + * + * @returns {void} + */ +function onTick () { + /** + * Increment the fastNow value by the TICK_MS value, despite the actual time + * that has passed since the last tick. This approach ensures independence + * from the system clock and delays caused by a blocked event loop. + * + * @type {number} + */ + fastNow += TICK_MS + + /** + * The `idx` variable is used to iterate over the `fastTimers` array. + * Expired timers are removed by replacing them with the last element in the array. + * Consequently, `idx` is only incremented when the current element is not removed. + * + * @type {number} + */ + let idx = 0 + + /** + * The len variable will contain the length of the fastTimers array + * and will be decremented when a FastTimer should be removed from the + * fastTimers array. + * + * @type {number} + */ + let len = fastTimers.length + + while (idx < len) { + /** + * @type {FastTimer} + */ + const timer = fastTimers[idx] + + // If the timer is in the ACTIVE state and the timer has expired, it will + // be processed in the next tick. + if (timer._state === PENDING) { + // Set the _idleStart value to the fastNow value minus the TICK_MS value + // to account for the time the timer was in the PENDING state. + timer._idleStart = fastNow - TICK_MS + timer._state = ACTIVE + } else if ( + timer._state === ACTIVE && + fastNow >= timer._idleStart + timer._idleTimeout + ) { + timer._state = TO_BE_CLEARED + timer._idleStart = -1 + timer._onTimeout(timer._timerArg) + } + + if (timer._state === TO_BE_CLEARED) { + timer._state = NOT_IN_LIST + + // Move the last element to the current index and decrement len if it is + // not the only element in the array. + if (--len !== 0) { + fastTimers[idx] = fastTimers[len] + } + } else { + ++idx + } + } + + // Set the length of the fastTimers array to the new length and thus + // removing the excess FastTimers elements from the array. + fastTimers.length = len + + // If there are still active FastTimers in the array, refresh the Timer. + // If there are no active FastTimers, the timer will be refreshed again + // when a new FastTimer is instantiated. + if (fastTimers.length !== 0) { + refreshTimeout() + } +} + +function refreshTimeout () { + // If the fastNowTimeout is already set, refresh it. + if (fastNowTimeout) { + fastNowTimeout.refresh() + // fastNowTimeout is not instantiated yet, create a new Timer. + } else { + clearTimeout(fastNowTimeout) + fastNowTimeout = setTimeout(onTick, TICK_MS) + + // If the Timer has an unref method, call it to allow the process to exit if + // there are no other active handles. + if (fastNowTimeout.unref) { + fastNowTimeout.unref() + } + } +} + +/** + * The `FastTimer` class is a data structure designed to store and manage + * timer information. + */ +class FastTimer { + [kFastTimer] = true + + /** + * The state of the timer, which can be one of the following: + * - NOT_IN_LIST (-2) + * - TO_BE_CLEARED (-1) + * - PENDING (0) + * - ACTIVE (1) + * + * @type {-2|-1|0|1} + * @private + */ + _state = NOT_IN_LIST + + /** + * The number of milliseconds to wait before calling the callback. + * + * @type {number} + * @private + */ + _idleTimeout = -1 + + /** + * The time in milliseconds when the timer was started. This value is used to + * calculate when the timer should expire. + * + * @type {number} + * @default -1 + * @private + */ + _idleStart = -1 + + /** + * The function to be executed when the timer expires. + * @type {Function} + * @private + */ + _onTimeout + + /** + * The argument to be passed to the callback when the timer expires. + * + * @type {*} + * @private + */ + _timerArg + + /** + * @constructor + * @param {Function} callback A function to be executed after the timer + * expires. + * @param {number} delay The time, in milliseconds that the timer should wait + * before the specified function or code is executed. + * @param {*} arg + */ + constructor (callback, delay, arg) { + this._onTimeout = callback + this._idleTimeout = delay + this._timerArg = arg + + this.refresh() + } + + /** + * Sets the timer's start time to the current time, and reschedules the timer + * to call its callback at the previously specified duration adjusted to the + * current time. + * Using this on a timer that has already called its callback will reactivate + * the timer. + * + * @returns {void} + */ + refresh () { + // In the special case that the timer is not in the list of active timers, + // add it back to the array to be processed in the next tick by the onTick + // function. + if (this._state === NOT_IN_LIST) { + fastTimers.push(this) + } + + // If the timer is the only active timer, refresh the fastNowTimeout for + // better resolution. + if (!fastNowTimeout || fastTimers.length === 1) { + refreshTimeout() + } + + // Setting the state to PENDING will cause the timer to be reset in the + // next tick by the onTick function. + this._state = PENDING + } + + /** + * The `clear` method cancels the timer, preventing it from executing. + * + * @returns {void} + * @private + */ + clear () { + // Set the state to TO_BE_CLEARED to mark the timer for removal in the next + // tick by the onTick function. + this._state = TO_BE_CLEARED + + // Reset the _idleStart value to -1 to indicate that the timer is no longer + // active. + this._idleStart = -1 + } +} + +/** + * This module exports a setTimeout and clearTimeout function that can be + * used as a drop-in replacement for the native functions. + */ +module.exports = { + /** + * The setTimeout() method sets a timer which executes a function once the + * timer expires. + * @param {Function} callback A function to be executed after the timer + * expires. + * @param {number} delay The time, in milliseconds that the timer should + * wait before the specified function or code is executed. + * @param {*} [arg] An optional argument to be passed to the callback function + * when the timer expires. + * @returns {NodeJS.Timeout|FastTimer} + */ + setTimeout (callback, delay, arg) { + // If the delay is less than or equal to the RESOLUTION_MS value return a + // native Node.js Timer instance. + return delay <= RESOLUTION_MS + ? setTimeout(callback, delay, arg) + : new FastTimer(callback, delay, arg) + }, + /** + * The clearTimeout method cancels an instantiated Timer previously created + * by calling setTimeout. + * + * @param {NodeJS.Timeout|FastTimer} timeout + */ + clearTimeout (timeout) { + // If the timeout is a FastTimer, call its own clear method. + if (timeout[kFastTimer]) { + /** + * @type {FastTimer} + */ + timeout.clear() + // Otherwise it is an instance of a native NodeJS.Timeout, so call the + // Node.js native clearTimeout function. + } else { + clearTimeout(timeout) + } + }, + /** + * The setFastTimeout() method sets a fastTimer which executes a function once + * the timer expires. + * @param {Function} callback A function to be executed after the timer + * expires. + * @param {number} delay The time, in milliseconds that the timer should + * wait before the specified function or code is executed. + * @param {*} [arg] An optional argument to be passed to the callback function + * when the timer expires. + * @returns {FastTimer} + */ + setFastTimeout (callback, delay, arg) { + return new FastTimer(callback, delay, arg) + }, + /** + * The clearTimeout method cancels an instantiated FastTimer previously + * created by calling setFastTimeout. + * + * @param {FastTimer} timeout + */ + clearFastTimeout (timeout) { + timeout.clear() + }, + /** + * The now method returns the value of the internal fast timer clock. + * + * @returns {number} + */ + now () { + return fastNow + }, + /** + * Trigger the onTick function to process the fastTimers array. + * Exported for testing purposes only. + * Marking as deprecated to discourage any use outside of testing. + * @deprecated + * @param {number} [delay=0] The delay in milliseconds to add to the now value. + */ + tick (delay = 0) { + fastNow += delay - RESOLUTION_MS + 1 + onTick() + onTick() + }, + /** + * Reset FastTimers. + * Exported for testing purposes only. + * Marking as deprecated to discourage any use outside of testing. + * @deprecated + */ + reset () { + fastNow = 0 + fastTimers.length = 0 + clearTimeout(fastNowTimeout) + fastNowTimeout = null + }, + /** + * Exporting for testing purposes only. + * Marking as deprecated to discourage any use outside of testing. + * @deprecated + */ + kFastTimer +} diff --git a/lib/web/cache/cache.js b/lib/web/cache/cache.js new file mode 100644 index 0000000..435891d --- /dev/null +++ b/lib/web/cache/cache.js @@ -0,0 +1,862 @@ +'use strict' + +const { kConstruct } = require('../../core/symbols') +const { urlEquals, getFieldValues } = require('./util') +const { kEnumerableProperty, isDisturbed } = require('../../core/util') +const { webidl } = require('../fetch/webidl') +const { cloneResponse, fromInnerResponse, getResponseState } = require('../fetch/response') +const { Request, fromInnerRequest, getRequestState } = require('../fetch/request') +const { fetching } = require('../fetch/index') +const { urlIsHttpHttpsScheme, createDeferredPromise, readAllBytes } = require('../fetch/util') +const assert = require('node:assert') + +/** + * @see https://w3c.github.io/ServiceWorker/#dfn-cache-batch-operation + * @typedef {Object} CacheBatchOperation + * @property {'delete' | 'put'} type + * @property {any} request + * @property {any} response + * @property {import('../../types/cache').CacheQueryOptions} options + */ + +/** + * @see https://w3c.github.io/ServiceWorker/#dfn-request-response-list + * @typedef {[any, any][]} requestResponseList + */ + +class Cache { + /** + * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-request-response-list + * @type {requestResponseList} + */ + #relevantRequestResponseList + + constructor () { + if (arguments[0] !== kConstruct) { + webidl.illegalConstructor() + } + + webidl.util.markAsUncloneable(this) + this.#relevantRequestResponseList = arguments[1] + } + + async match (request, options = {}) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.match' + webidl.argumentLengthCheck(arguments, 1, prefix) + + request = webidl.converters.RequestInfo(request, prefix, 'request') + options = webidl.converters.CacheQueryOptions(options, prefix, 'options') + + const p = this.#internalMatchAll(request, options, 1) + + if (p.length === 0) { + return + } + + return p[0] + } + + async matchAll (request = undefined, options = {}) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.matchAll' + if (request !== undefined) request = webidl.converters.RequestInfo(request, prefix, 'request') + options = webidl.converters.CacheQueryOptions(options, prefix, 'options') + + return this.#internalMatchAll(request, options) + } + + async add (request) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.add' + webidl.argumentLengthCheck(arguments, 1, prefix) + + request = webidl.converters.RequestInfo(request, prefix, 'request') + + // 1. + const requests = [request] + + // 2. + const responseArrayPromise = this.addAll(requests) + + // 3. + return await responseArrayPromise + } + + async addAll (requests) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.addAll' + webidl.argumentLengthCheck(arguments, 1, prefix) + + // 1. + const responsePromises = [] + + // 2. + const requestList = [] + + // 3. + for (let request of requests) { + if (request === undefined) { + throw webidl.errors.conversionFailed({ + prefix, + argument: 'Argument 1', + types: ['undefined is not allowed'] + }) + } + + request = webidl.converters.RequestInfo(request) + + if (typeof request === 'string') { + continue + } + + // 3.1 + const r = getRequestState(request) + + // 3.2 + if (!urlIsHttpHttpsScheme(r.url) || r.method !== 'GET') { + throw webidl.errors.exception({ + header: prefix, + message: 'Expected http/s scheme when method is not GET.' + }) + } + } + + // 4. + /** @type {ReturnType[]} */ + const fetchControllers = [] + + // 5. + for (const request of requests) { + // 5.1 + const r = getRequestState(new Request(request)) + + // 5.2 + if (!urlIsHttpHttpsScheme(r.url)) { + throw webidl.errors.exception({ + header: prefix, + message: 'Expected http/s scheme.' + }) + } + + // 5.4 + r.initiator = 'fetch' + r.destination = 'subresource' + + // 5.5 + requestList.push(r) + + // 5.6 + const responsePromise = createDeferredPromise() + + // 5.7 + fetchControllers.push(fetching({ + request: r, + processResponse (response) { + // 1. + if (response.type === 'error' || response.status === 206 || response.status < 200 || response.status > 299) { + responsePromise.reject(webidl.errors.exception({ + header: 'Cache.addAll', + message: 'Received an invalid status code or the request failed.' + })) + } else if (response.headersList.contains('vary')) { // 2. + // 2.1 + const fieldValues = getFieldValues(response.headersList.get('vary')) + + // 2.2 + for (const fieldValue of fieldValues) { + // 2.2.1 + if (fieldValue === '*') { + responsePromise.reject(webidl.errors.exception({ + header: 'Cache.addAll', + message: 'invalid vary field value' + })) + + for (const controller of fetchControllers) { + controller.abort() + } + + return + } + } + } + }, + processResponseEndOfBody (response) { + // 1. + if (response.aborted) { + responsePromise.reject(new DOMException('aborted', 'AbortError')) + return + } + + // 2. + responsePromise.resolve(response) + } + })) + + // 5.8 + responsePromises.push(responsePromise.promise) + } + + // 6. + const p = Promise.all(responsePromises) + + // 7. + const responses = await p + + // 7.1 + const operations = [] + + // 7.2 + let index = 0 + + // 7.3 + for (const response of responses) { + // 7.3.1 + /** @type {CacheBatchOperation} */ + const operation = { + type: 'put', // 7.3.2 + request: requestList[index], // 7.3.3 + response // 7.3.4 + } + + operations.push(operation) // 7.3.5 + + index++ // 7.3.6 + } + + // 7.5 + const cacheJobPromise = createDeferredPromise() + + // 7.6.1 + let errorData = null + + // 7.6.2 + try { + this.#batchCacheOperations(operations) + } catch (e) { + errorData = e + } + + // 7.6.3 + queueMicrotask(() => { + // 7.6.3.1 + if (errorData === null) { + cacheJobPromise.resolve(undefined) + } else { + // 7.6.3.2 + cacheJobPromise.reject(errorData) + } + }) + + // 7.7 + return cacheJobPromise.promise + } + + async put (request, response) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.put' + webidl.argumentLengthCheck(arguments, 2, prefix) + + request = webidl.converters.RequestInfo(request, prefix, 'request') + response = webidl.converters.Response(response, prefix, 'response') + + // 1. + let innerRequest = null + + // 2. + if (webidl.is.Request(request)) { + innerRequest = getRequestState(request) + } else { // 3. + innerRequest = getRequestState(new Request(request)) + } + + // 4. + if (!urlIsHttpHttpsScheme(innerRequest.url) || innerRequest.method !== 'GET') { + throw webidl.errors.exception({ + header: prefix, + message: 'Expected an http/s scheme when method is not GET' + }) + } + + // 5. + const innerResponse = getResponseState(response) + + // 6. + if (innerResponse.status === 206) { + throw webidl.errors.exception({ + header: prefix, + message: 'Got 206 status' + }) + } + + // 7. + if (innerResponse.headersList.contains('vary')) { + // 7.1. + const fieldValues = getFieldValues(innerResponse.headersList.get('vary')) + + // 7.2. + for (const fieldValue of fieldValues) { + // 7.2.1 + if (fieldValue === '*') { + throw webidl.errors.exception({ + header: prefix, + message: 'Got * vary field value' + }) + } + } + } + + // 8. + if (innerResponse.body && (isDisturbed(innerResponse.body.stream) || innerResponse.body.stream.locked)) { + throw webidl.errors.exception({ + header: prefix, + message: 'Response body is locked or disturbed' + }) + } + + // 9. + const clonedResponse = cloneResponse(innerResponse) + + // 10. + const bodyReadPromise = createDeferredPromise() + + // 11. + if (innerResponse.body != null) { + // 11.1 + const stream = innerResponse.body.stream + + // 11.2 + const reader = stream.getReader() + + // 11.3 + readAllBytes(reader, bodyReadPromise.resolve, bodyReadPromise.reject) + } else { + bodyReadPromise.resolve(undefined) + } + + // 12. + /** @type {CacheBatchOperation[]} */ + const operations = [] + + // 13. + /** @type {CacheBatchOperation} */ + const operation = { + type: 'put', // 14. + request: innerRequest, // 15. + response: clonedResponse // 16. + } + + // 17. + operations.push(operation) + + // 19. + const bytes = await bodyReadPromise.promise + + if (clonedResponse.body != null) { + clonedResponse.body.source = bytes + } + + // 19.1 + const cacheJobPromise = createDeferredPromise() + + // 19.2.1 + let errorData = null + + // 19.2.2 + try { + this.#batchCacheOperations(operations) + } catch (e) { + errorData = e + } + + // 19.2.3 + queueMicrotask(() => { + // 19.2.3.1 + if (errorData === null) { + cacheJobPromise.resolve() + } else { // 19.2.3.2 + cacheJobPromise.reject(errorData) + } + }) + + return cacheJobPromise.promise + } + + async delete (request, options = {}) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.delete' + webidl.argumentLengthCheck(arguments, 1, prefix) + + request = webidl.converters.RequestInfo(request, prefix, 'request') + options = webidl.converters.CacheQueryOptions(options, prefix, 'options') + + /** + * @type {Request} + */ + let r = null + + if (webidl.is.Request(request)) { + r = getRequestState(request) + + if (r.method !== 'GET' && !options.ignoreMethod) { + return false + } + } else { + assert(typeof request === 'string') + + r = getRequestState(new Request(request)) + } + + /** @type {CacheBatchOperation[]} */ + const operations = [] + + /** @type {CacheBatchOperation} */ + const operation = { + type: 'delete', + request: r, + options + } + + operations.push(operation) + + const cacheJobPromise = createDeferredPromise() + + let errorData = null + let requestResponses + + try { + requestResponses = this.#batchCacheOperations(operations) + } catch (e) { + errorData = e + } + + queueMicrotask(() => { + if (errorData === null) { + cacheJobPromise.resolve(!!requestResponses?.length) + } else { + cacheJobPromise.reject(errorData) + } + }) + + return cacheJobPromise.promise + } + + /** + * @see https://w3c.github.io/ServiceWorker/#dom-cache-keys + * @param {any} request + * @param {import('../../types/cache').CacheQueryOptions} options + * @returns {Promise} + */ + async keys (request = undefined, options = {}) { + webidl.brandCheck(this, Cache) + + const prefix = 'Cache.keys' + + if (request !== undefined) request = webidl.converters.RequestInfo(request, prefix, 'request') + options = webidl.converters.CacheQueryOptions(options, prefix, 'options') + + // 1. + let r = null + + // 2. + if (request !== undefined) { + // 2.1 + if (webidl.is.Request(request)) { + // 2.1.1 + r = getRequestState(request) + + // 2.1.2 + if (r.method !== 'GET' && !options.ignoreMethod) { + return [] + } + } else if (typeof request === 'string') { // 2.2 + r = getRequestState(new Request(request)) + } + } + + // 4. + const promise = createDeferredPromise() + + // 5. + // 5.1 + const requests = [] + + // 5.2 + if (request === undefined) { + // 5.2.1 + for (const requestResponse of this.#relevantRequestResponseList) { + // 5.2.1.1 + requests.push(requestResponse[0]) + } + } else { // 5.3 + // 5.3.1 + const requestResponses = this.#queryCache(r, options) + + // 5.3.2 + for (const requestResponse of requestResponses) { + // 5.3.2.1 + requests.push(requestResponse[0]) + } + } + + // 5.4 + queueMicrotask(() => { + // 5.4.1 + const requestList = [] + + // 5.4.2 + for (const request of requests) { + const requestObject = fromInnerRequest( + request, + undefined, + new AbortController().signal, + 'immutable' + ) + // 5.4.2.1 + requestList.push(requestObject) + } + + // 5.4.3 + promise.resolve(Object.freeze(requestList)) + }) + + return promise.promise + } + + /** + * @see https://w3c.github.io/ServiceWorker/#batch-cache-operations-algorithm + * @param {CacheBatchOperation[]} operations + * @returns {requestResponseList} + */ + #batchCacheOperations (operations) { + // 1. + const cache = this.#relevantRequestResponseList + + // 2. + const backupCache = [...cache] + + // 3. + const addedItems = [] + + // 4.1 + const resultList = [] + + try { + // 4.2 + for (const operation of operations) { + // 4.2.1 + if (operation.type !== 'delete' && operation.type !== 'put') { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'operation type does not match "delete" or "put"' + }) + } + + // 4.2.2 + if (operation.type === 'delete' && operation.response != null) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'delete operation should not have an associated response' + }) + } + + // 4.2.3 + if (this.#queryCache(operation.request, operation.options, addedItems).length) { + throw new DOMException('???', 'InvalidStateError') + } + + // 4.2.4 + let requestResponses + + // 4.2.5 + if (operation.type === 'delete') { + // 4.2.5.1 + requestResponses = this.#queryCache(operation.request, operation.options) + + // TODO: the spec is wrong, this is needed to pass WPTs + if (requestResponses.length === 0) { + return [] + } + + // 4.2.5.2 + for (const requestResponse of requestResponses) { + const idx = cache.indexOf(requestResponse) + assert(idx !== -1) + + // 4.2.5.2.1 + cache.splice(idx, 1) + } + } else if (operation.type === 'put') { // 4.2.6 + // 4.2.6.1 + if (operation.response == null) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'put operation should have an associated response' + }) + } + + // 4.2.6.2 + const r = operation.request + + // 4.2.6.3 + if (!urlIsHttpHttpsScheme(r.url)) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'expected http or https scheme' + }) + } + + // 4.2.6.4 + if (r.method !== 'GET') { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'not get method' + }) + } + + // 4.2.6.5 + if (operation.options != null) { + throw webidl.errors.exception({ + header: 'Cache.#batchCacheOperations', + message: 'options must not be defined' + }) + } + + // 4.2.6.6 + requestResponses = this.#queryCache(operation.request) + + // 4.2.6.7 + for (const requestResponse of requestResponses) { + const idx = cache.indexOf(requestResponse) + assert(idx !== -1) + + // 4.2.6.7.1 + cache.splice(idx, 1) + } + + // 4.2.6.8 + cache.push([operation.request, operation.response]) + + // 4.2.6.10 + addedItems.push([operation.request, operation.response]) + } + + // 4.2.7 + resultList.push([operation.request, operation.response]) + } + + // 4.3 + return resultList + } catch (e) { // 5. + // 5.1 + this.#relevantRequestResponseList.length = 0 + + // 5.2 + this.#relevantRequestResponseList = backupCache + + // 5.3 + throw e + } + } + + /** + * @see https://w3c.github.io/ServiceWorker/#query-cache + * @param {any} requestQuery + * @param {import('../../types/cache').CacheQueryOptions} options + * @param {requestResponseList} targetStorage + * @returns {requestResponseList} + */ + #queryCache (requestQuery, options, targetStorage) { + /** @type {requestResponseList} */ + const resultList = [] + + const storage = targetStorage ?? this.#relevantRequestResponseList + + for (const requestResponse of storage) { + const [cachedRequest, cachedResponse] = requestResponse + if (this.#requestMatchesCachedItem(requestQuery, cachedRequest, cachedResponse, options)) { + resultList.push(requestResponse) + } + } + + return resultList + } + + /** + * @see https://w3c.github.io/ServiceWorker/#request-matches-cached-item-algorithm + * @param {any} requestQuery + * @param {any} request + * @param {any | null} response + * @param {import('../../types/cache').CacheQueryOptions | undefined} options + * @returns {boolean} + */ + #requestMatchesCachedItem (requestQuery, request, response = null, options) { + // if (options?.ignoreMethod === false && request.method === 'GET') { + // return false + // } + + const queryURL = new URL(requestQuery.url) + + const cachedURL = new URL(request.url) + + if (options?.ignoreSearch) { + cachedURL.search = '' + + queryURL.search = '' + } + + if (!urlEquals(queryURL, cachedURL, true)) { + return false + } + + if ( + response == null || + options?.ignoreVary || + !response.headersList.contains('vary') + ) { + return true + } + + const fieldValues = getFieldValues(response.headersList.get('vary')) + + for (const fieldValue of fieldValues) { + if (fieldValue === '*') { + return false + } + + const requestValue = request.headersList.get(fieldValue) + const queryValue = requestQuery.headersList.get(fieldValue) + + // If one has the header and the other doesn't, or one has + // a different value than the other, return false + if (requestValue !== queryValue) { + return false + } + } + + return true + } + + #internalMatchAll (request, options, maxResponses = Infinity) { + // 1. + let r = null + + // 2. + if (request !== undefined) { + if (webidl.is.Request(request)) { + // 2.1.1 + r = getRequestState(request) + + // 2.1.2 + if (r.method !== 'GET' && !options.ignoreMethod) { + return [] + } + } else if (typeof request === 'string') { + // 2.2.1 + r = getRequestState(new Request(request)) + } + } + + // 5. + // 5.1 + const responses = [] + + // 5.2 + if (request === undefined) { + // 5.2.1 + for (const requestResponse of this.#relevantRequestResponseList) { + responses.push(requestResponse[1]) + } + } else { // 5.3 + // 5.3.1 + const requestResponses = this.#queryCache(r, options) + + // 5.3.2 + for (const requestResponse of requestResponses) { + responses.push(requestResponse[1]) + } + } + + // 5.4 + // We don't implement CORs so we don't need to loop over the responses, yay! + + // 5.5.1 + const responseList = [] + + // 5.5.2 + for (const response of responses) { + // 5.5.2.1 + const responseObject = fromInnerResponse(response, 'immutable') + + responseList.push(responseObject.clone()) + + if (responseList.length >= maxResponses) { + break + } + } + + // 6. + return Object.freeze(responseList) + } +} + +Object.defineProperties(Cache.prototype, { + [Symbol.toStringTag]: { + value: 'Cache', + configurable: true + }, + match: kEnumerableProperty, + matchAll: kEnumerableProperty, + add: kEnumerableProperty, + addAll: kEnumerableProperty, + put: kEnumerableProperty, + delete: kEnumerableProperty, + keys: kEnumerableProperty +}) + +const cacheQueryOptionConverters = [ + { + key: 'ignoreSearch', + converter: webidl.converters.boolean, + defaultValue: () => false + }, + { + key: 'ignoreMethod', + converter: webidl.converters.boolean, + defaultValue: () => false + }, + { + key: 'ignoreVary', + converter: webidl.converters.boolean, + defaultValue: () => false + } +] + +webidl.converters.CacheQueryOptions = webidl.dictionaryConverter(cacheQueryOptionConverters) + +webidl.converters.MultiCacheQueryOptions = webidl.dictionaryConverter([ + ...cacheQueryOptionConverters, + { + key: 'cacheName', + converter: webidl.converters.DOMString + } +]) + +webidl.converters.Response = webidl.interfaceConverter( + webidl.is.Response, + 'Response' +) + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.RequestInfo +) + +module.exports = { + Cache +} diff --git a/lib/web/cache/cachestorage.js b/lib/web/cache/cachestorage.js new file mode 100644 index 0000000..3b1604c --- /dev/null +++ b/lib/web/cache/cachestorage.js @@ -0,0 +1,152 @@ +'use strict' + +const { Cache } = require('./cache') +const { webidl } = require('../fetch/webidl') +const { kEnumerableProperty } = require('../../core/util') +const { kConstruct } = require('../../core/symbols') + +class CacheStorage { + /** + * @see https://w3c.github.io/ServiceWorker/#dfn-relevant-name-to-cache-map + * @type {Map} + */ + async has (cacheName) { + webidl.brandCheck(this, CacheStorage) + + const prefix = 'CacheStorage.has' + webidl.argumentLengthCheck(arguments, 1, prefix) + + cacheName = webidl.converters.DOMString(cacheName, prefix, 'cacheName') + + // 2.1.1 + // 2.2 + return this.#caches.has(cacheName) + } + + /** + * @see https://w3c.github.io/ServiceWorker/#dom-cachestorage-open + * @param {string} cacheName + * @returns {Promise} + */ + async open (cacheName) { + webidl.brandCheck(this, CacheStorage) + + const prefix = 'CacheStorage.open' + webidl.argumentLengthCheck(arguments, 1, prefix) + + cacheName = webidl.converters.DOMString(cacheName, prefix, 'cacheName') + + // 2.1 + if (this.#caches.has(cacheName)) { + // await caches.open('v1') !== await caches.open('v1') + + // 2.1.1 + const cache = this.#caches.get(cacheName) + + // 2.1.1.1 + return new Cache(kConstruct, cache) + } + + // 2.2 + const cache = [] + + // 2.3 + this.#caches.set(cacheName, cache) + + // 2.4 + return new Cache(kConstruct, cache) + } + + /** + * @see https://w3c.github.io/ServiceWorker/#cache-storage-delete + * @param {string} cacheName + * @returns {Promise} + */ + async delete (cacheName) { + webidl.brandCheck(this, CacheStorage) + + const prefix = 'CacheStorage.delete' + webidl.argumentLengthCheck(arguments, 1, prefix) + + cacheName = webidl.converters.DOMString(cacheName, prefix, 'cacheName') + + return this.#caches.delete(cacheName) + } + + /** + * @see https://w3c.github.io/ServiceWorker/#cache-storage-keys + * @returns {Promise} + */ + async keys () { + webidl.brandCheck(this, CacheStorage) + + // 2.1 + const keys = this.#caches.keys() + + // 2.2 + return [...keys] + } +} + +Object.defineProperties(CacheStorage.prototype, { + [Symbol.toStringTag]: { + value: 'CacheStorage', + configurable: true + }, + match: kEnumerableProperty, + has: kEnumerableProperty, + open: kEnumerableProperty, + delete: kEnumerableProperty, + keys: kEnumerableProperty +}) + +module.exports = { + CacheStorage +} diff --git a/lib/web/cache/util.js b/lib/web/cache/util.js new file mode 100644 index 0000000..5ac9d84 --- /dev/null +++ b/lib/web/cache/util.js @@ -0,0 +1,45 @@ +'use strict' + +const assert = require('node:assert') +const { URLSerializer } = require('../fetch/data-url') +const { isValidHeaderName } = require('../fetch/util') + +/** + * @see https://url.spec.whatwg.org/#concept-url-equals + * @param {URL} A + * @param {URL} B + * @param {boolean | undefined} excludeFragment + * @returns {boolean} + */ +function urlEquals (A, B, excludeFragment = false) { + const serializedA = URLSerializer(A, excludeFragment) + + const serializedB = URLSerializer(B, excludeFragment) + + return serializedA === serializedB +} + +/** + * @see https://github.com/chromium/chromium/blob/694d20d134cb553d8d89e5500b9148012b1ba299/content/browser/cache_storage/cache_storage_cache.cc#L260-L262 + * @param {string} header + */ +function getFieldValues (header) { + assert(header !== null) + + const values = [] + + for (let value of header.split(',')) { + value = value.trim() + + if (isValidHeaderName(value)) { + values.push(value) + } + } + + return values +} + +module.exports = { + urlEquals, + getFieldValues +} diff --git a/lib/web/cookies/constants.js b/lib/web/cookies/constants.js new file mode 100644 index 0000000..85f1fec --- /dev/null +++ b/lib/web/cookies/constants.js @@ -0,0 +1,12 @@ +'use strict' + +// https://wicg.github.io/cookie-store/#cookie-maximum-attribute-value-size +const maxAttributeValueSize = 1024 + +// https://wicg.github.io/cookie-store/#cookie-maximum-name-value-pair-size +const maxNameValuePairSize = 4096 + +module.exports = { + maxAttributeValueSize, + maxNameValuePairSize +} diff --git a/lib/web/cookies/index.js b/lib/web/cookies/index.js new file mode 100644 index 0000000..8bcb4e2 --- /dev/null +++ b/lib/web/cookies/index.js @@ -0,0 +1,199 @@ +'use strict' + +const { parseSetCookie } = require('./parse') +const { stringify } = require('./util') +const { webidl } = require('../fetch/webidl') +const { Headers } = require('../fetch/headers') + +const brandChecks = webidl.brandCheckMultiple([Headers, globalThis.Headers].filter(Boolean)) + +/** + * @typedef {Object} Cookie + * @property {string} name + * @property {string} value + * @property {Date|number} [expires] + * @property {number} [maxAge] + * @property {string} [domain] + * @property {string} [path] + * @property {boolean} [secure] + * @property {boolean} [httpOnly] + * @property {'Strict'|'Lax'|'None'} [sameSite] + * @property {string[]} [unparsed] + */ + +/** + * @param {Headers} headers + * @returns {Record} + */ +function getCookies (headers) { + webidl.argumentLengthCheck(arguments, 1, 'getCookies') + + brandChecks(headers) + + const cookie = headers.get('cookie') + + /** @type {Record} */ + const out = {} + + if (!cookie) { + return out + } + + for (const piece of cookie.split(';')) { + const [name, ...value] = piece.split('=') + + out[name.trim()] = value.join('=') + } + + return out +} + +/** + * @param {Headers} headers + * @param {string} name + * @param {{ path?: string, domain?: string }|undefined} attributes + * @returns {void} + */ +function deleteCookie (headers, name, attributes) { + brandChecks(headers) + + const prefix = 'deleteCookie' + webidl.argumentLengthCheck(arguments, 2, prefix) + + name = webidl.converters.DOMString(name, prefix, 'name') + attributes = webidl.converters.DeleteCookieAttributes(attributes) + + // Matches behavior of + // https://github.com/denoland/deno_std/blob/63827b16330b82489a04614027c33b7904e08be5/http/cookie.ts#L278 + setCookie(headers, { + name, + value: '', + expires: new Date(0), + ...attributes + }) +} + +/** + * @param {Headers} headers + * @returns {Cookie[]} + */ +function getSetCookies (headers) { + webidl.argumentLengthCheck(arguments, 1, 'getSetCookies') + + brandChecks(headers) + + const cookies = headers.getSetCookie() + + if (!cookies) { + return [] + } + + return cookies.map((pair) => parseSetCookie(pair)) +} + +/** + * Parses a cookie string + * @param {string} cookie + */ +function parseCookie (cookie) { + cookie = webidl.converters.DOMString(cookie) + + return parseSetCookie(cookie) +} + +/** + * @param {Headers} headers + * @param {Cookie} cookie + * @returns {void} + */ +function setCookie (headers, cookie) { + webidl.argumentLengthCheck(arguments, 2, 'setCookie') + + brandChecks(headers) + + cookie = webidl.converters.Cookie(cookie) + + const str = stringify(cookie) + + if (str) { + headers.append('set-cookie', str, true) + } +} + +webidl.converters.DeleteCookieAttributes = webidl.dictionaryConverter([ + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'path', + defaultValue: () => null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'domain', + defaultValue: () => null + } +]) + +webidl.converters.Cookie = webidl.dictionaryConverter([ + { + converter: webidl.converters.DOMString, + key: 'name' + }, + { + converter: webidl.converters.DOMString, + key: 'value' + }, + { + converter: webidl.nullableConverter((value) => { + if (typeof value === 'number') { + return webidl.converters['unsigned long long'](value) + } + + return new Date(value) + }), + key: 'expires', + defaultValue: () => null + }, + { + converter: webidl.nullableConverter(webidl.converters['long long']), + key: 'maxAge', + defaultValue: () => null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'domain', + defaultValue: () => null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'path', + defaultValue: () => null + }, + { + converter: webidl.nullableConverter(webidl.converters.boolean), + key: 'secure', + defaultValue: () => null + }, + { + converter: webidl.nullableConverter(webidl.converters.boolean), + key: 'httpOnly', + defaultValue: () => null + }, + { + converter: webidl.converters.USVString, + key: 'sameSite', + allowedValues: ['Strict', 'Lax', 'None'] + }, + { + converter: webidl.sequenceConverter(webidl.converters.DOMString), + key: 'unparsed', + defaultValue: () => new Array(0) + } +]) + +module.exports = { + getCookies, + deleteCookie, + getSetCookies, + setCookie, + parseCookie +} diff --git a/lib/web/cookies/parse.js b/lib/web/cookies/parse.js new file mode 100644 index 0000000..4ac66dc --- /dev/null +++ b/lib/web/cookies/parse.js @@ -0,0 +1,322 @@ +'use strict' + +const { maxNameValuePairSize, maxAttributeValueSize } = require('./constants') +const { isCTLExcludingHtab } = require('./util') +const { collectASequenceOfCodePointsFast } = require('../fetch/data-url') +const assert = require('node:assert') +const { unescape } = require('node:querystring') + +/** + * @description Parses the field-value attributes of a set-cookie header string. + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4 + * @param {string} header + * @returns {import('./index').Cookie|null} if the header is invalid, null will be returned + */ +function parseSetCookie (header) { + // 1. If the set-cookie-string contains a %x00-08 / %x0A-1F / %x7F + // character (CTL characters excluding HTAB): Abort these steps and + // ignore the set-cookie-string entirely. + if (isCTLExcludingHtab(header)) { + return null + } + + let nameValuePair = '' + let unparsedAttributes = '' + let name = '' + let value = '' + + // 2. If the set-cookie-string contains a %x3B (";") character: + if (header.includes(';')) { + // 1. The name-value-pair string consists of the characters up to, + // but not including, the first %x3B (";"), and the unparsed- + // attributes consist of the remainder of the set-cookie-string + // (including the %x3B (";") in question). + const position = { position: 0 } + + nameValuePair = collectASequenceOfCodePointsFast(';', header, position) + unparsedAttributes = header.slice(position.position) + } else { + // Otherwise: + + // 1. The name-value-pair string consists of all the characters + // contained in the set-cookie-string, and the unparsed- + // attributes is the empty string. + nameValuePair = header + } + + // 3. If the name-value-pair string lacks a %x3D ("=") character, then + // the name string is empty, and the value string is the value of + // name-value-pair. + if (!nameValuePair.includes('=')) { + value = nameValuePair + } else { + // Otherwise, the name string consists of the characters up to, but + // not including, the first %x3D ("=") character, and the (possibly + // empty) value string consists of the characters after the first + // %x3D ("=") character. + const position = { position: 0 } + name = collectASequenceOfCodePointsFast( + '=', + nameValuePair, + position + ) + value = nameValuePair.slice(position.position + 1) + } + + // 4. Remove any leading or trailing WSP characters from the name + // string and the value string. + name = name.trim() + value = value.trim() + + // 5. If the sum of the lengths of the name string and the value string + // is more than 4096 octets, abort these steps and ignore the set- + // cookie-string entirely. + if (name.length + value.length > maxNameValuePairSize) { + return null + } + + // 6. The cookie-name is the name string, and the cookie-value is the + // value string. + // https://datatracker.ietf.org/doc/html/rfc6265 + // To maximize compatibility with user agents, servers that wish to + // store arbitrary data in a cookie-value SHOULD encode that data, for + // example, using Base64 [RFC4648]. + return { + name, value: unescape(value), ...parseUnparsedAttributes(unparsedAttributes) + } +} + +/** + * Parses the remaining attributes of a set-cookie header + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4 + * @param {string} unparsedAttributes + * @param {Object.} [cookieAttributeList={}] + */ +function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) { + // 1. If the unparsed-attributes string is empty, skip the rest of + // these steps. + if (unparsedAttributes.length === 0) { + return cookieAttributeList + } + + // 2. Discard the first character of the unparsed-attributes (which + // will be a %x3B (";") character). + assert(unparsedAttributes[0] === ';') + unparsedAttributes = unparsedAttributes.slice(1) + + let cookieAv = '' + + // 3. If the remaining unparsed-attributes contains a %x3B (";") + // character: + if (unparsedAttributes.includes(';')) { + // 1. Consume the characters of the unparsed-attributes up to, but + // not including, the first %x3B (";") character. + cookieAv = collectASequenceOfCodePointsFast( + ';', + unparsedAttributes, + { position: 0 } + ) + unparsedAttributes = unparsedAttributes.slice(cookieAv.length) + } else { + // Otherwise: + + // 1. Consume the remainder of the unparsed-attributes. + cookieAv = unparsedAttributes + unparsedAttributes = '' + } + + // Let the cookie-av string be the characters consumed in this step. + + let attributeName = '' + let attributeValue = '' + + // 4. If the cookie-av string contains a %x3D ("=") character: + if (cookieAv.includes('=')) { + // 1. The (possibly empty) attribute-name string consists of the + // characters up to, but not including, the first %x3D ("=") + // character, and the (possibly empty) attribute-value string + // consists of the characters after the first %x3D ("=") + // character. + const position = { position: 0 } + + attributeName = collectASequenceOfCodePointsFast( + '=', + cookieAv, + position + ) + attributeValue = cookieAv.slice(position.position + 1) + } else { + // Otherwise: + + // 1. The attribute-name string consists of the entire cookie-av + // string, and the attribute-value string is empty. + attributeName = cookieAv + } + + // 5. Remove any leading or trailing WSP characters from the attribute- + // name string and the attribute-value string. + attributeName = attributeName.trim() + attributeValue = attributeValue.trim() + + // 6. If the attribute-value is longer than 1024 octets, ignore the + // cookie-av string and return to Step 1 of this algorithm. + if (attributeValue.length > maxAttributeValueSize) { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 7. Process the attribute-name and attribute-value according to the + // requirements in the following subsections. (Notice that + // attributes with unrecognized attribute-names are ignored.) + const attributeNameLowercase = attributeName.toLowerCase() + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1 + // If the attribute-name case-insensitively matches the string + // "Expires", the user agent MUST process the cookie-av as follows. + if (attributeNameLowercase === 'expires') { + // 1. Let the expiry-time be the result of parsing the attribute-value + // as cookie-date (see Section 5.1.1). + const expiryTime = new Date(attributeValue) + + // 2. If the attribute-value failed to parse as a cookie date, ignore + // the cookie-av. + + cookieAttributeList.expires = expiryTime + } else if (attributeNameLowercase === 'max-age') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.2 + // If the attribute-name case-insensitively matches the string "Max- + // Age", the user agent MUST process the cookie-av as follows. + + // 1. If the first character of the attribute-value is not a DIGIT or a + // "-" character, ignore the cookie-av. + const charCode = attributeValue.charCodeAt(0) + + if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 2. If the remainder of attribute-value contains a non-DIGIT + // character, ignore the cookie-av. + if (!/^\d+$/.test(attributeValue)) { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 3. Let delta-seconds be the attribute-value converted to an integer. + const deltaSeconds = Number(attributeValue) + + // 4. Let cookie-age-limit be the maximum age of the cookie (which + // SHOULD be 400 days or less, see Section 4.1.2.2). + + // 5. Set delta-seconds to the smaller of its present value and cookie- + // age-limit. + // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs) + + // 6. If delta-seconds is less than or equal to zero (0), let expiry- + // time be the earliest representable date and time. Otherwise, let + // the expiry-time be the current date and time plus delta-seconds + // seconds. + // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds + + // 7. Append an attribute to the cookie-attribute-list with an + // attribute-name of Max-Age and an attribute-value of expiry-time. + cookieAttributeList.maxAge = deltaSeconds + } else if (attributeNameLowercase === 'domain') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.3 + // If the attribute-name case-insensitively matches the string "Domain", + // the user agent MUST process the cookie-av as follows. + + // 1. Let cookie-domain be the attribute-value. + let cookieDomain = attributeValue + + // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be + // cookie-domain without its leading %x2E ("."). + if (cookieDomain[0] === '.') { + cookieDomain = cookieDomain.slice(1) + } + + // 3. Convert the cookie-domain to lower case. + cookieDomain = cookieDomain.toLowerCase() + + // 4. Append an attribute to the cookie-attribute-list with an + // attribute-name of Domain and an attribute-value of cookie-domain. + cookieAttributeList.domain = cookieDomain + } else if (attributeNameLowercase === 'path') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.4 + // If the attribute-name case-insensitively matches the string "Path", + // the user agent MUST process the cookie-av as follows. + + // 1. If the attribute-value is empty or if the first character of the + // attribute-value is not %x2F ("/"): + let cookiePath = '' + if (attributeValue.length === 0 || attributeValue[0] !== '/') { + // 1. Let cookie-path be the default-path. + cookiePath = '/' + } else { + // Otherwise: + + // 1. Let cookie-path be the attribute-value. + cookiePath = attributeValue + } + + // 2. Append an attribute to the cookie-attribute-list with an + // attribute-name of Path and an attribute-value of cookie-path. + cookieAttributeList.path = cookiePath + } else if (attributeNameLowercase === 'secure') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.5 + // If the attribute-name case-insensitively matches the string "Secure", + // the user agent MUST append an attribute to the cookie-attribute-list + // with an attribute-name of Secure and an empty attribute-value. + + cookieAttributeList.secure = true + } else if (attributeNameLowercase === 'httponly') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.6 + // If the attribute-name case-insensitively matches the string + // "HttpOnly", the user agent MUST append an attribute to the cookie- + // attribute-list with an attribute-name of HttpOnly and an empty + // attribute-value. + + cookieAttributeList.httpOnly = true + } else if (attributeNameLowercase === 'samesite') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7 + // If the attribute-name case-insensitively matches the string + // "SameSite", the user agent MUST process the cookie-av as follows: + + // 1. Let enforcement be "Default". + let enforcement = 'Default' + + const attributeValueLowercase = attributeValue.toLowerCase() + // 2. If cookie-av's attribute-value is a case-insensitive match for + // "None", set enforcement to "None". + if (attributeValueLowercase.includes('none')) { + enforcement = 'None' + } + + // 3. If cookie-av's attribute-value is a case-insensitive match for + // "Strict", set enforcement to "Strict". + if (attributeValueLowercase.includes('strict')) { + enforcement = 'Strict' + } + + // 4. If cookie-av's attribute-value is a case-insensitive match for + // "Lax", set enforcement to "Lax". + if (attributeValueLowercase.includes('lax')) { + enforcement = 'Lax' + } + + // 5. Append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of + // enforcement. + cookieAttributeList.sameSite = enforcement + } else { + cookieAttributeList.unparsed ??= [] + + cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`) + } + + // 8. Return to Step 1 of this algorithm. + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) +} + +module.exports = { + parseSetCookie, + parseUnparsedAttributes +} diff --git a/lib/web/cookies/util.js b/lib/web/cookies/util.js new file mode 100644 index 0000000..254f541 --- /dev/null +++ b/lib/web/cookies/util.js @@ -0,0 +1,282 @@ +'use strict' + +/** + * @param {string} value + * @returns {boolean} + */ +function isCTLExcludingHtab (value) { + for (let i = 0; i < value.length; ++i) { + const code = value.charCodeAt(i) + + if ( + (code >= 0x00 && code <= 0x08) || + (code >= 0x0A && code <= 0x1F) || + code === 0x7F + ) { + return true + } + } + return false +} + +/** + CHAR = + token = 1* + separators = "(" | ")" | "<" | ">" | "@" + | "," | ";" | ":" | "\" | <"> + | "/" | "[" | "]" | "?" | "=" + | "{" | "}" | SP | HT + * @param {string} name + */ +function validateCookieName (name) { + for (let i = 0; i < name.length; ++i) { + const code = name.charCodeAt(i) + + if ( + code < 0x21 || // exclude CTLs (0-31), SP and HT + code > 0x7E || // exclude non-ascii and DEL + code === 0x22 || // " + code === 0x28 || // ( + code === 0x29 || // ) + code === 0x3C || // < + code === 0x3E || // > + code === 0x40 || // @ + code === 0x2C || // , + code === 0x3B || // ; + code === 0x3A || // : + code === 0x5C || // \ + code === 0x2F || // / + code === 0x5B || // [ + code === 0x5D || // ] + code === 0x3F || // ? + code === 0x3D || // = + code === 0x7B || // { + code === 0x7D // } + ) { + throw new Error('Invalid cookie name') + } + } +} + +/** + cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) + cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + ; US-ASCII characters excluding CTLs, + ; whitespace DQUOTE, comma, semicolon, + ; and backslash + * @param {string} value + */ +function validateCookieValue (value) { + let len = value.length + let i = 0 + + // if the value is wrapped in DQUOTE + if (value[0] === '"') { + if (len === 1 || value[len - 1] !== '"') { + throw new Error('Invalid cookie value') + } + --len + ++i + } + + while (i < len) { + const code = value.charCodeAt(i++) + + if ( + code < 0x21 || // exclude CTLs (0-31) + code > 0x7E || // non-ascii and DEL (127) + code === 0x22 || // " + code === 0x2C || // , + code === 0x3B || // ; + code === 0x5C // \ + ) { + throw new Error('Invalid cookie value') + } + } +} + +/** + * path-value = + * @param {string} path + */ +function validateCookiePath (path) { + for (let i = 0; i < path.length; ++i) { + const code = path.charCodeAt(i) + + if ( + code < 0x20 || // exclude CTLs (0-31) + code === 0x7F || // DEL + code === 0x3B // ; + ) { + throw new Error('Invalid cookie path') + } + } +} + +/** + * I have no idea why these values aren't allowed to be honest, + * but Deno tests these. - Khafra + * @param {string} domain + */ +function validateCookieDomain (domain) { + if ( + domain.startsWith('-') || + domain.endsWith('.') || + domain.endsWith('-') + ) { + throw new Error('Invalid cookie domain') + } +} + +const IMFDays = [ + 'Sun', 'Mon', 'Tue', 'Wed', + 'Thu', 'Fri', 'Sat' +] + +const IMFMonths = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' +] + +const IMFPaddedNumbers = Array(61).fill(0).map((_, i) => i.toString().padStart(2, '0')) + +/** + * @see https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1 + * @param {number|Date} date + IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT + ; fixed length/zone/capitalization subset of the format + ; see Section 3.3 of [RFC5322] + + day-name = %x4D.6F.6E ; "Mon", case-sensitive + / %x54.75.65 ; "Tue", case-sensitive + / %x57.65.64 ; "Wed", case-sensitive + / %x54.68.75 ; "Thu", case-sensitive + / %x46.72.69 ; "Fri", case-sensitive + / %x53.61.74 ; "Sat", case-sensitive + / %x53.75.6E ; "Sun", case-sensitive + date1 = day SP month SP year + ; e.g., 02 Jun 1982 + + day = 2DIGIT + month = %x4A.61.6E ; "Jan", case-sensitive + / %x46.65.62 ; "Feb", case-sensitive + / %x4D.61.72 ; "Mar", case-sensitive + / %x41.70.72 ; "Apr", case-sensitive + / %x4D.61.79 ; "May", case-sensitive + / %x4A.75.6E ; "Jun", case-sensitive + / %x4A.75.6C ; "Jul", case-sensitive + / %x41.75.67 ; "Aug", case-sensitive + / %x53.65.70 ; "Sep", case-sensitive + / %x4F.63.74 ; "Oct", case-sensitive + / %x4E.6F.76 ; "Nov", case-sensitive + / %x44.65.63 ; "Dec", case-sensitive + year = 4DIGIT + + GMT = %x47.4D.54 ; "GMT", case-sensitive + + time-of-day = hour ":" minute ":" second + ; 00:00:00 - 23:59:60 (leap second) + + hour = 2DIGIT + minute = 2DIGIT + second = 2DIGIT + */ +function toIMFDate (date) { + if (typeof date === 'number') { + date = new Date(date) + } + + return `${IMFDays[date.getUTCDay()]}, ${IMFPaddedNumbers[date.getUTCDate()]} ${IMFMonths[date.getUTCMonth()]} ${date.getUTCFullYear()} ${IMFPaddedNumbers[date.getUTCHours()]}:${IMFPaddedNumbers[date.getUTCMinutes()]}:${IMFPaddedNumbers[date.getUTCSeconds()]} GMT` +} + +/** + max-age-av = "Max-Age=" non-zero-digit *DIGIT + ; In practice, both expires-av and max-age-av + ; are limited to dates representable by the + ; user agent. + * @param {number} maxAge + */ +function validateCookieMaxAge (maxAge) { + if (maxAge < 0) { + throw new Error('Invalid cookie max-age') + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1 + * @param {import('./index').Cookie} cookie + */ +function stringify (cookie) { + if (cookie.name.length === 0) { + return null + } + + validateCookieName(cookie.name) + validateCookieValue(cookie.value) + + const out = [`${cookie.name}=${cookie.value}`] + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.1 + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.2 + if (cookie.name.startsWith('__Secure-')) { + cookie.secure = true + } + + if (cookie.name.startsWith('__Host-')) { + cookie.secure = true + cookie.domain = null + cookie.path = '/' + } + + if (cookie.secure) { + out.push('Secure') + } + + if (cookie.httpOnly) { + out.push('HttpOnly') + } + + if (typeof cookie.maxAge === 'number') { + validateCookieMaxAge(cookie.maxAge) + out.push(`Max-Age=${cookie.maxAge}`) + } + + if (cookie.domain) { + validateCookieDomain(cookie.domain) + out.push(`Domain=${cookie.domain}`) + } + + if (cookie.path) { + validateCookiePath(cookie.path) + out.push(`Path=${cookie.path}`) + } + + if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') { + out.push(`Expires=${toIMFDate(cookie.expires)}`) + } + + if (cookie.sameSite) { + out.push(`SameSite=${cookie.sameSite}`) + } + + for (const part of cookie.unparsed) { + if (!part.includes('=')) { + throw new Error('Invalid unparsed') + } + + const [key, ...value] = part.split('=') + + out.push(`${key.trim()}=${value.join('=')}`) + } + + return out.join('; ') +} + +module.exports = { + isCTLExcludingHtab, + validateCookieName, + validateCookiePath, + validateCookieValue, + toIMFDate, + stringify +} diff --git a/lib/web/eventsource/eventsource-stream.js b/lib/web/eventsource/eventsource-stream.js new file mode 100644 index 0000000..59cf746 --- /dev/null +++ b/lib/web/eventsource/eventsource-stream.js @@ -0,0 +1,399 @@ +'use strict' +const { Transform } = require('node:stream') +const { isASCIINumber, isValidLastEventId } = require('./util') + +/** + * @type {number[]} BOM + */ +const BOM = [0xEF, 0xBB, 0xBF] +/** + * @type {10} LF + */ +const LF = 0x0A +/** + * @type {13} CR + */ +const CR = 0x0D +/** + * @type {58} COLON + */ +const COLON = 0x3A +/** + * @type {32} SPACE + */ +const SPACE = 0x20 + +/** + * @typedef {object} EventSourceStreamEvent + * @type {object} + * @property {string} [event] The event type. + * @property {string} [data] The data of the message. + * @property {string} [id] A unique ID for the event. + * @property {string} [retry] The reconnection time, in milliseconds. + */ + +/** + * @typedef eventSourceSettings + * @type {object} + * @property {string} [lastEventId] The last event ID received from the server. + * @property {string} [origin] The origin of the event source. + * @property {number} [reconnectionTime] The reconnection time, in milliseconds. + */ + +class EventSourceStream extends Transform { + /** + * @type {eventSourceSettings} + */ + state + + /** + * Leading byte-order-mark check. + * @type {boolean} + */ + checkBOM = true + + /** + * @type {boolean} + */ + crlfCheck = false + + /** + * @type {boolean} + */ + eventEndCheck = false + + /** + * @type {Buffer|null} + */ + buffer = null + + pos = 0 + + event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined + } + + /** + * @param {object} options + * @param {boolean} [options.readableObjectMode] + * @param {eventSourceSettings} [options.eventSourceSettings] + * @param {(chunk: any, encoding?: BufferEncoding | undefined) => boolean} [options.push] + */ + constructor (options = {}) { + // Enable object mode as EventSourceStream emits objects of shape + // EventSourceStreamEvent + options.readableObjectMode = true + + super(options) + + this.state = options.eventSourceSettings || {} + if (options.push) { + this.push = options.push + } + } + + /** + * @param {Buffer} chunk + * @param {string} _encoding + * @param {Function} callback + * @returns {void} + */ + _transform (chunk, _encoding, callback) { + if (chunk.length === 0) { + callback() + return + } + + // Cache the chunk in the buffer, as the data might not be complete while + // processing it + // TODO: Investigate if there is a more performant way to handle + // incoming chunks + // see: https://github.com/nodejs/undici/issues/2630 + if (this.buffer) { + this.buffer = Buffer.concat([this.buffer, chunk]) + } else { + this.buffer = chunk + } + + // Strip leading byte-order-mark if we opened the stream and started + // the processing of the incoming data + if (this.checkBOM) { + switch (this.buffer.length) { + case 1: + // Check if the first byte is the same as the first byte of the BOM + if (this.buffer[0] === BOM[0]) { + // If it is, we need to wait for more data + callback() + return + } + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + + // The buffer only contains one byte so we need to wait for more data + callback() + return + case 2: + // Check if the first two bytes are the same as the first two bytes + // of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] + ) { + // If it is, we need to wait for more data, because the third byte + // is needed to determine if it is the BOM or not + callback() + return + } + + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + break + case 3: + // Check if the first three bytes are the same as the first three + // bytes of the BOM + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // If it is, we can drop the buffered data, as it is only the BOM + this.buffer = Buffer.alloc(0) + // Set the checkBOM flag to false as we don't need to check for the + // BOM anymore + this.checkBOM = false + + // Await more data + callback() + return + } + // If it is not the BOM, we can start processing the data + this.checkBOM = false + break + default: + // The buffer is longer than 3 bytes, so we can drop the BOM if it is + // present + if ( + this.buffer[0] === BOM[0] && + this.buffer[1] === BOM[1] && + this.buffer[2] === BOM[2] + ) { + // Remove the BOM from the buffer + this.buffer = this.buffer.subarray(3) + } + + // Set the checkBOM flag to false as we don't need to check for the + this.checkBOM = false + break + } + } + + while (this.pos < this.buffer.length) { + // If the previous line ended with an end-of-line, we need to check + // if the next character is also an end-of-line. + if (this.eventEndCheck) { + // If the the current character is an end-of-line, then the event + // is finished and we can process it + + // If the previous line ended with a carriage return, we need to + // check if the current character is a line feed and remove it + // from the buffer. + if (this.crlfCheck) { + // If the current character is a line feed, we can remove it + // from the buffer and reset the crlfCheck flag + if (this.buffer[this.pos] === LF) { + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 + this.crlfCheck = false + + // It is possible that the line feed is not the end of the + // event. We need to check if the next character is an + // end-of-line character to determine if the event is + // finished. We simply continue the loop to check the next + // character. + + // As we removed the line feed from the buffer and set the + // crlfCheck flag to false, we basically don't make any + // distinction between a line feed and a carriage return. + continue + } + this.crlfCheck = false + } + + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { + // If the current character is a carriage return, we need to + // set the crlfCheck flag to true, as we need to check if the + // next character is a line feed so we can remove it from the + // buffer + if (this.buffer[this.pos] === CR) { + this.crlfCheck = true + } + + this.buffer = this.buffer.subarray(this.pos + 1) + this.pos = 0 + if ( + this.event.data !== undefined || this.event.event || this.event.id || this.event.retry) { + this.processEvent(this.event) + } + this.clearEvent() + continue + } + // If the current character is not an end-of-line, then the event + // is not finished and we have to reset the eventEndCheck flag + this.eventEndCheck = false + continue + } + + // If the current character is an end-of-line, we can process the + // line + if (this.buffer[this.pos] === LF || this.buffer[this.pos] === CR) { + // If the current character is a carriage return, we need to + // set the crlfCheck flag to true, as we need to check if the + // next character is a line feed + if (this.buffer[this.pos] === CR) { + this.crlfCheck = true + } + + // In any case, we can process the line as we reached an + // end-of-line character + this.parseLine(this.buffer.subarray(0, this.pos), this.event) + + // Remove the processed line from the buffer + this.buffer = this.buffer.subarray(this.pos + 1) + // Reset the position as we removed the processed line from the buffer + this.pos = 0 + // A line was processed and this could be the end of the event. We need + // to check if the next line is empty to determine if the event is + // finished. + this.eventEndCheck = true + continue + } + + this.pos++ + } + + callback() + } + + /** + * @param {Buffer} line + * @param {EventSourceStreamEvent} event + */ + parseLine (line, event) { + // If the line is empty (a blank line) + // Dispatch the event, as defined below. + // This will be handled in the _transform method + if (line.length === 0) { + return + } + + // If the line starts with a U+003A COLON character (:) + // Ignore the line. + const colonPosition = line.indexOf(COLON) + if (colonPosition === 0) { + return + } + + let field = '' + let value = '' + + // If the line contains a U+003A COLON character (:) + if (colonPosition !== -1) { + // Collect the characters on the line before the first U+003A COLON + // character (:), and let field be that string. + // TODO: Investigate if there is a more performant way to extract the + // field + // see: https://github.com/nodejs/undici/issues/2630 + field = line.subarray(0, colonPosition).toString('utf8') + + // Collect the characters on the line after the first U+003A COLON + // character (:), and let value be that string. + // If value starts with a U+0020 SPACE character, remove it from value. + let valueStart = colonPosition + 1 + if (line[valueStart] === SPACE) { + ++valueStart + } + // TODO: Investigate if there is a more performant way to extract the + // value + // see: https://github.com/nodejs/undici/issues/2630 + value = line.subarray(valueStart).toString('utf8') + + // Otherwise, the string is not empty but does not contain a U+003A COLON + // character (:) + } else { + // Process the field using the steps described below, using the whole + // line as the field name, and the empty string as the field value. + field = line.toString('utf8') + value = '' + } + + // Modify the event with the field name and value. The value is also + // decoded as UTF-8 + switch (field) { + case 'data': + if (event[field] === undefined) { + event[field] = value + } else { + event[field] += `\n${value}` + } + break + case 'retry': + if (isASCIINumber(value)) { + event[field] = value + } + break + case 'id': + if (isValidLastEventId(value)) { + event[field] = value + } + break + case 'event': + if (value.length > 0) { + event[field] = value + } + break + } + } + + /** + * @param {EventSourceStreamEvent} event + */ + processEvent (event) { + if (event.retry && isASCIINumber(event.retry)) { + this.state.reconnectionTime = parseInt(event.retry, 10) + } + + if (event.id && isValidLastEventId(event.id)) { + this.state.lastEventId = event.id + } + + // only dispatch event, when data is provided + if (event.data !== undefined) { + this.push({ + type: event.event || 'message', + options: { + data: event.data, + lastEventId: this.state.lastEventId, + origin: this.state.origin + } + }) + } + } + + clearEvent () { + this.event = { + data: undefined, + event: undefined, + id: undefined, + retry: undefined + } + } +} + +module.exports = { + EventSourceStream +} diff --git a/lib/web/eventsource/eventsource.js b/lib/web/eventsource/eventsource.js new file mode 100644 index 0000000..02f4d47 --- /dev/null +++ b/lib/web/eventsource/eventsource.js @@ -0,0 +1,484 @@ +'use strict' + +const { pipeline } = require('node:stream') +const { fetching } = require('../fetch') +const { makeRequest } = require('../fetch/request') +const { webidl } = require('../fetch/webidl') +const { EventSourceStream } = require('./eventsource-stream') +const { parseMIMEType } = require('../fetch/data-url') +const { createFastMessageEvent } = require('../websocket/events') +const { isNetworkError } = require('../fetch/response') +const { delay } = require('./util') +const { kEnumerableProperty } = require('../../core/util') +const { environmentSettingsObject } = require('../fetch/util') + +let experimentalWarned = false + +/** + * A reconnection time, in milliseconds. This must initially be an implementation-defined value, + * probably in the region of a few seconds. + * + * In Comparison: + * - Chrome uses 3000ms. + * - Deno uses 5000ms. + * + * @type {3000} + */ +const defaultReconnectionTime = 3000 + +/** + * The readyState attribute represents the state of the connection. + * @typedef ReadyState + * @type {0|1|2} + * @readonly + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#dom-eventsource-readystate-dev + */ + +/** + * The connection has not yet been established, or it was closed and the user + * agent is reconnecting. + * @type {0} + */ +const CONNECTING = 0 + +/** + * The user agent has an open connection and is dispatching events as it + * receives them. + * @type {1} + */ +const OPEN = 1 + +/** + * The connection is not open, and the user agent is not trying to reconnect. + * @type {2} + */ +const CLOSED = 2 + +/** + * Requests for the element will have their mode set to "cors" and their credentials mode set to "same-origin". + * @type {'anonymous'} + */ +const ANONYMOUS = 'anonymous' + +/** + * Requests for the element will have their mode set to "cors" and their credentials mode set to "include". + * @type {'use-credentials'} + */ +const USE_CREDENTIALS = 'use-credentials' + +/** + * The EventSource interface is used to receive server-sent events. It + * connects to a server over HTTP and receives events in text/event-stream + * format without closing the connection. + * @extends {EventTarget} + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events + * @api public + */ +class EventSource extends EventTarget { + #events = { + open: null, + error: null, + message: null + } + + #url + #withCredentials = false + + /** + * @type {ReadyState} + */ + #readyState = CONNECTING + + #request = null + #controller = null + + #dispatcher + + /** + * @type {import('./eventsource-stream').eventSourceSettings} + */ + #state + + /** + * Creates a new EventSource object. + * @param {string} url + * @param {EventSourceInit} [eventSourceInitDict={}] + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#the-eventsource-interface + */ + constructor (url, eventSourceInitDict = {}) { + // 1. Let ev be a new EventSource object. + super() + + webidl.util.markAsUncloneable(this) + + const prefix = 'EventSource constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + if (!experimentalWarned) { + experimentalWarned = true + process.emitWarning('EventSource is experimental, expect them to change at any time.', { + code: 'UNDICI-ES' + }) + } + + url = webidl.converters.USVString(url) + eventSourceInitDict = webidl.converters.EventSourceInitDict(eventSourceInitDict, prefix, 'eventSourceInitDict') + + this.#dispatcher = eventSourceInitDict.dispatcher + this.#state = { + lastEventId: '', + reconnectionTime: defaultReconnectionTime + } + + // 2. Let settings be ev's relevant settings object. + // https://html.spec.whatwg.org/multipage/webappapis.html#environment-settings-object + const settings = environmentSettingsObject + + let urlRecord + + try { + // 3. Let urlRecord be the result of encoding-parsing a URL given url, relative to settings. + urlRecord = new URL(url, settings.settingsObject.baseUrl) + this.#state.origin = urlRecord.origin + } catch (e) { + // 4. If urlRecord is failure, then throw a "SyntaxError" DOMException. + throw new DOMException(e, 'SyntaxError') + } + + // 5. Set ev's url to urlRecord. + this.#url = urlRecord.href + + // 6. Let corsAttributeState be Anonymous. + let corsAttributeState = ANONYMOUS + + // 7. If the value of eventSourceInitDict's withCredentials member is true, + // then set corsAttributeState to Use Credentials and set ev's + // withCredentials attribute to true. + if (eventSourceInitDict.withCredentials === true) { + corsAttributeState = USE_CREDENTIALS + this.#withCredentials = true + } + + // 8. Let request be the result of creating a potential-CORS request given + // urlRecord, the empty string, and corsAttributeState. + const initRequest = { + redirect: 'follow', + keepalive: true, + // @see https://html.spec.whatwg.org/multipage/urls-and-fetching.html#cors-settings-attributes + mode: 'cors', + credentials: corsAttributeState === 'anonymous' + ? 'same-origin' + : 'omit', + referrer: 'no-referrer' + } + + // 9. Set request's client to settings. + initRequest.client = environmentSettingsObject.settingsObject + + // 10. User agents may set (`Accept`, `text/event-stream`) in request's header list. + initRequest.headersList = [['accept', { name: 'accept', value: 'text/event-stream' }]] + + // 11. Set request's cache mode to "no-store". + initRequest.cache = 'no-store' + + // 12. Set request's initiator type to "other". + initRequest.initiator = 'other' + + initRequest.urlList = [new URL(this.#url)] + + // 13. Set ev's request to request. + this.#request = makeRequest(initRequest) + + this.#connect() + } + + /** + * Returns the state of this EventSource object's connection. It can have the + * values described below. + * @returns {ReadyState} + * @readonly + */ + get readyState () { + return this.#readyState + } + + /** + * Returns the URL providing the event stream. + * @readonly + * @returns {string} + */ + get url () { + return this.#url + } + + /** + * Returns a boolean indicating whether the EventSource object was + * instantiated with CORS credentials set (true), or not (false, the default). + */ + get withCredentials () { + return this.#withCredentials + } + + #connect () { + if (this.#readyState === CLOSED) return + + this.#readyState = CONNECTING + + const fetchParams = { + request: this.#request, + dispatcher: this.#dispatcher + } + + // 14. Let processEventSourceEndOfBody given response res be the following step: if res is not a network error, then reestablish the connection. + const processEventSourceEndOfBody = (response) => { + if (isNetworkError(response)) { + this.dispatchEvent(new Event('error')) + this.close() + } + + this.#reconnect() + } + + // 15. Fetch request, with processResponseEndOfBody set to processEventSourceEndOfBody... + fetchParams.processResponseEndOfBody = processEventSourceEndOfBody + + // and processResponse set to the following steps given response res: + fetchParams.processResponse = (response) => { + // 1. If res is an aborted network error, then fail the connection. + + if (isNetworkError(response)) { + // 1. When a user agent is to fail the connection, the user agent + // must queue a task which, if the readyState attribute is set to a + // value other than CLOSED, sets the readyState attribute to CLOSED + // and fires an event named error at the EventSource object. Once the + // user agent has failed the connection, it does not attempt to + // reconnect. + if (response.aborted) { + this.close() + this.dispatchEvent(new Event('error')) + return + // 2. Otherwise, if res is a network error, then reestablish the + // connection, unless the user agent knows that to be futile, in + // which case the user agent may fail the connection. + } else { + this.#reconnect() + return + } + } + + // 3. Otherwise, if res's status is not 200, or if res's `Content-Type` + // is not `text/event-stream`, then fail the connection. + const contentType = response.headersList.get('content-type', true) + const mimeType = contentType !== null ? parseMIMEType(contentType) : 'failure' + const contentTypeValid = mimeType !== 'failure' && mimeType.essence === 'text/event-stream' + if ( + response.status !== 200 || + contentTypeValid === false + ) { + this.close() + this.dispatchEvent(new Event('error')) + return + } + + // 4. Otherwise, announce the connection and interpret res's body + // line by line. + + // When a user agent is to announce the connection, the user agent + // must queue a task which, if the readyState attribute is set to a + // value other than CLOSED, sets the readyState attribute to OPEN + // and fires an event named open at the EventSource object. + // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model + this.#readyState = OPEN + this.dispatchEvent(new Event('open')) + + // If redirected to a different origin, set the origin to the new origin. + this.#state.origin = response.urlList[response.urlList.length - 1].origin + + const eventSourceStream = new EventSourceStream({ + eventSourceSettings: this.#state, + push: (event) => { + this.dispatchEvent(createFastMessageEvent( + event.type, + event.options + )) + } + }) + + pipeline(response.body.stream, + eventSourceStream, + (error) => { + if ( + error?.aborted === false + ) { + this.close() + this.dispatchEvent(new Event('error')) + } + }) + } + + this.#controller = fetching(fetchParams) + } + + /** + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model + * @returns {Promise} + */ + async #reconnect () { + // When a user agent is to reestablish the connection, the user agent must + // run the following steps. These steps are run in parallel, not as part of + // a task. (The tasks that it queues, of course, are run like normal tasks + // and not themselves in parallel.) + + // 1. Queue a task to run the following steps: + + // 1. If the readyState attribute is set to CLOSED, abort the task. + if (this.#readyState === CLOSED) return + + // 2. Set the readyState attribute to CONNECTING. + this.#readyState = CONNECTING + + // 3. Fire an event named error at the EventSource object. + this.dispatchEvent(new Event('error')) + + // 2. Wait a delay equal to the reconnection time of the event source. + await delay(this.#state.reconnectionTime) + + // 5. Queue a task to run the following steps: + + // 1. If the EventSource object's readyState attribute is not set to + // CONNECTING, then return. + if (this.#readyState !== CONNECTING) return + + // 2. Let request be the EventSource object's request. + // 3. If the EventSource object's last event ID string is not the empty + // string, then: + // 1. Let lastEventIDValue be the EventSource object's last event ID + // string, encoded as UTF-8. + // 2. Set (`Last-Event-ID`, lastEventIDValue) in request's header + // list. + if (this.#state.lastEventId.length) { + this.#request.headersList.set('last-event-id', this.#state.lastEventId, true) + } + + // 4. Fetch request and process the response obtained in this fashion, if any, as described earlier in this section. + this.#connect() + } + + /** + * Closes the connection, if any, and sets the readyState attribute to + * CLOSED. + */ + close () { + webidl.brandCheck(this, EventSource) + + if (this.#readyState === CLOSED) return + this.#readyState = CLOSED + this.#controller.abort() + this.#request = null + } + + get onopen () { + return this.#events.open + } + + set onopen (fn) { + if (this.#events.open) { + this.removeEventListener('open', this.#events.open) + } + + if (typeof fn === 'function') { + this.#events.open = fn + this.addEventListener('open', fn) + } else { + this.#events.open = null + } + } + + get onmessage () { + return this.#events.message + } + + set onmessage (fn) { + if (this.#events.message) { + this.removeEventListener('message', this.#events.message) + } + + if (typeof fn === 'function') { + this.#events.message = fn + this.addEventListener('message', fn) + } else { + this.#events.message = null + } + } + + get onerror () { + return this.#events.error + } + + set onerror (fn) { + if (this.#events.error) { + this.removeEventListener('error', this.#events.error) + } + + if (typeof fn === 'function') { + this.#events.error = fn + this.addEventListener('error', fn) + } else { + this.#events.error = null + } + } +} + +const constantsPropertyDescriptors = { + CONNECTING: { + __proto__: null, + configurable: false, + enumerable: true, + value: CONNECTING, + writable: false + }, + OPEN: { + __proto__: null, + configurable: false, + enumerable: true, + value: OPEN, + writable: false + }, + CLOSED: { + __proto__: null, + configurable: false, + enumerable: true, + value: CLOSED, + writable: false + } +} + +Object.defineProperties(EventSource, constantsPropertyDescriptors) +Object.defineProperties(EventSource.prototype, constantsPropertyDescriptors) + +Object.defineProperties(EventSource.prototype, { + close: kEnumerableProperty, + onerror: kEnumerableProperty, + onmessage: kEnumerableProperty, + onopen: kEnumerableProperty, + readyState: kEnumerableProperty, + url: kEnumerableProperty, + withCredentials: kEnumerableProperty +}) + +webidl.converters.EventSourceInitDict = webidl.dictionaryConverter([ + { + key: 'withCredentials', + converter: webidl.converters.boolean, + defaultValue: () => false + }, + { + key: 'dispatcher', // undici only + converter: webidl.converters.any + } +]) + +module.exports = { + EventSource, + defaultReconnectionTime +} diff --git a/lib/web/eventsource/util.js b/lib/web/eventsource/util.js new file mode 100644 index 0000000..727d866 --- /dev/null +++ b/lib/web/eventsource/util.js @@ -0,0 +1,37 @@ +'use strict' + +/** + * Checks if the given value is a valid LastEventId. + * @param {string} value + * @returns {boolean} + */ +function isValidLastEventId (value) { + // LastEventId should not contain U+0000 NULL + return value.indexOf('\u0000') === -1 +} + +/** + * Checks if the given value is a base 10 digit. + * @param {string} value + * @returns {boolean} + */ +function isASCIINumber (value) { + if (value.length === 0) return false + for (let i = 0; i < value.length; i++) { + if (value.charCodeAt(i) < 0x30 || value.charCodeAt(i) > 0x39) return false + } + return true +} + +// https://github.com/nodejs/undici/issues/2664 +function delay (ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms).unref() + }) +} + +module.exports = { + isValidLastEventId, + isASCIINumber, + delay +} diff --git a/lib/web/fetch/LICENSE b/lib/web/fetch/LICENSE new file mode 100644 index 0000000..2943500 --- /dev/null +++ b/lib/web/fetch/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Ethan Arrowood + +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/lib/web/fetch/body.js b/lib/web/fetch/body.js new file mode 100644 index 0000000..850a37f --- /dev/null +++ b/lib/web/fetch/body.js @@ -0,0 +1,532 @@ +'use strict' + +const util = require('../../core/util') +const { + ReadableStreamFrom, + readableStreamClose, + createDeferredPromise, + fullyReadBody, + extractMimeType, + utf8DecodeBytes +} = require('./util') +const { FormData, setFormDataState } = require('./formdata') +const { webidl } = require('./webidl') +const { Blob } = require('node:buffer') +const assert = require('node:assert') +const { isErrored, isDisturbed } = require('node:stream') +const { isArrayBuffer } = require('node:util/types') +const { serializeAMimeType } = require('./data-url') +const { multipartFormDataParser } = require('./formdata-parser') +let random + +try { + const crypto = require('node:crypto') + random = (max) => crypto.randomInt(0, max) +} catch { + random = (max) => Math.floor(Math.random(max)) +} + +const textEncoder = new TextEncoder() +function noop () {} + +const hasFinalizationRegistry = globalThis.FinalizationRegistry && process.version.indexOf('v18') !== 0 +let streamRegistry + +if (hasFinalizationRegistry) { + streamRegistry = new FinalizationRegistry((weakRef) => { + const stream = weakRef.deref() + if (stream && !stream.locked && !isDisturbed(stream) && !isErrored(stream)) { + stream.cancel('Response object has been garbage collected').catch(noop) + } + }) +} + +// https://fetch.spec.whatwg.org/#concept-bodyinit-extract +function extractBody (object, keepalive = false) { + // 1. Let stream be null. + let stream = null + + // 2. If object is a ReadableStream object, then set stream to object. + if (webidl.is.ReadableStream(object)) { + stream = object + } else if (webidl.is.Blob(object)) { + // 3. Otherwise, if object is a Blob object, set stream to the + // result of running object’s get stream. + stream = object.stream() + } else { + // 4. Otherwise, set stream to a new ReadableStream object, and set + // up stream with byte reading support. + stream = new ReadableStream({ + async pull (controller) { + const buffer = typeof source === 'string' ? textEncoder.encode(source) : source + + if (buffer.byteLength) { + controller.enqueue(buffer) + } + + queueMicrotask(() => readableStreamClose(controller)) + }, + start () {}, + type: 'bytes' + }) + } + + // 5. Assert: stream is a ReadableStream object. + assert(webidl.is.ReadableStream(stream)) + + // 6. Let action be null. + let action = null + + // 7. Let source be null. + let source = null + + // 8. Let length be null. + let length = null + + // 9. Let type be null. + let type = null + + // 10. Switch on object: + if (typeof object === 'string') { + // Set source to the UTF-8 encoding of object. + // Note: setting source to a Uint8Array here breaks some mocking assumptions. + source = object + + // Set type to `text/plain;charset=UTF-8`. + type = 'text/plain;charset=UTF-8' + } else if (webidl.is.URLSearchParams(object)) { + // URLSearchParams + + // spec says to run application/x-www-form-urlencoded on body.list + // this is implemented in Node.js as apart of an URLSearchParams instance toString method + // See: https://github.com/nodejs/node/blob/e46c680bf2b211bbd52cf959ca17ee98c7f657f5/lib/internal/url.js#L490 + // and https://github.com/nodejs/node/blob/e46c680bf2b211bbd52cf959ca17ee98c7f657f5/lib/internal/url.js#L1100 + + // Set source to the result of running the application/x-www-form-urlencoded serializer with object’s list. + source = object.toString() + + // Set type to `application/x-www-form-urlencoded;charset=UTF-8`. + type = 'application/x-www-form-urlencoded;charset=UTF-8' + } else if (isArrayBuffer(object)) { + // BufferSource/ArrayBuffer + + // Set source to a copy of the bytes held by object. + source = new Uint8Array(object.slice()) + } else if (ArrayBuffer.isView(object)) { + // BufferSource/ArrayBufferView + + // Set source to a copy of the bytes held by object. + source = new Uint8Array(object.buffer.slice(object.byteOffset, object.byteOffset + object.byteLength)) + } else if (webidl.is.FormData(object)) { + const boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}` + const prefix = `--${boundary}\r\nContent-Disposition: form-data` + + /*! formdata-polyfill. MIT License. Jimmy Wärting */ + const escape = (str) => + str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22') + const normalizeLinefeeds = (value) => value.replace(/\r?\n|\r/g, '\r\n') + + // Set action to this step: run the multipart/form-data + // encoding algorithm, with object’s entry list and UTF-8. + // - This ensures that the body is immutable and can't be changed afterwords + // - That the content-length is calculated in advance. + // - And that all parts are pre-encoded and ready to be sent. + + const blobParts = [] + const rn = new Uint8Array([13, 10]) // '\r\n' + length = 0 + let hasUnknownSizeValue = false + + for (const [name, value] of object) { + if (typeof value === 'string') { + const chunk = textEncoder.encode(prefix + + `; name="${escape(normalizeLinefeeds(name))}"` + + `\r\n\r\n${normalizeLinefeeds(value)}\r\n`) + blobParts.push(chunk) + length += chunk.byteLength + } else { + const chunk = textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"` + + (value.name ? `; filename="${escape(value.name)}"` : '') + '\r\n' + + `Content-Type: ${ + value.type || 'application/octet-stream' + }\r\n\r\n`) + blobParts.push(chunk, value, rn) + if (typeof value.size === 'number') { + length += chunk.byteLength + value.size + rn.byteLength + } else { + hasUnknownSizeValue = true + } + } + } + + // CRLF is appended to the body to function with legacy servers and match other implementations. + // https://github.com/curl/curl/blob/3434c6b46e682452973972e8313613dfa58cd690/lib/mime.c#L1029-L1030 + // https://github.com/form-data/form-data/issues/63 + const chunk = textEncoder.encode(`--${boundary}--\r\n`) + blobParts.push(chunk) + length += chunk.byteLength + if (hasUnknownSizeValue) { + length = null + } + + // Set source to object. + source = object + + action = async function * () { + for (const part of blobParts) { + if (part.stream) { + yield * part.stream() + } else { + yield part + } + } + } + + // Set type to `multipart/form-data; boundary=`, + // followed by the multipart/form-data boundary string generated + // by the multipart/form-data encoding algorithm. + type = `multipart/form-data; boundary=${boundary}` + } else if (webidl.is.Blob(object)) { + // Blob + + // Set source to object. + source = object + + // Set length to object’s size. + length = object.size + + // If object’s type attribute is not the empty byte sequence, set + // type to its value. + if (object.type) { + type = object.type + } + } else if (typeof object[Symbol.asyncIterator] === 'function') { + // If keepalive is true, then throw a TypeError. + if (keepalive) { + throw new TypeError('keepalive') + } + + // If object is disturbed or locked, then throw a TypeError. + if (util.isDisturbed(object) || object.locked) { + throw new TypeError( + 'Response body object should not be disturbed or locked' + ) + } + + stream = + webidl.is.ReadableStream(object) ? object : ReadableStreamFrom(object) + } + + // 11. If source is a byte sequence, then set action to a + // step that returns source and length to source’s length. + if (typeof source === 'string' || util.isBuffer(source)) { + length = Buffer.byteLength(source) + } + + // 12. If action is non-null, then run these steps in in parallel: + if (action != null) { + // Run action. + let iterator + stream = new ReadableStream({ + async start () { + iterator = action(object)[Symbol.asyncIterator]() + }, + async pull (controller) { + const { value, done } = await iterator.next() + if (done) { + // When running action is done, close stream. + queueMicrotask(() => { + controller.close() + controller.byobRequest?.respond(0) + }) + } else { + // Whenever one or more bytes are available and stream is not errored, + // enqueue a Uint8Array wrapping an ArrayBuffer containing the available + // bytes into stream. + if (!isErrored(stream)) { + const buffer = new Uint8Array(value) + if (buffer.byteLength) { + controller.enqueue(buffer) + } + } + } + return controller.desiredSize > 0 + }, + async cancel (reason) { + await iterator.return() + }, + type: 'bytes' + }) + } + + // 13. Let body be a body whose stream is stream, source is source, + // and length is length. + const body = { stream, source, length } + + // 14. Return (body, type). + return [body, type] +} + +// https://fetch.spec.whatwg.org/#bodyinit-safely-extract +function safelyExtractBody (object, keepalive = false) { + // To safely extract a body and a `Content-Type` value from + // a byte sequence or BodyInit object object, run these steps: + + // 1. If object is a ReadableStream object, then: + if (webidl.is.ReadableStream(object)) { + // Assert: object is neither disturbed nor locked. + // istanbul ignore next + assert(!util.isDisturbed(object), 'The body has already been consumed.') + // istanbul ignore next + assert(!object.locked, 'The stream is locked.') + } + + // 2. Return the results of extracting object. + return extractBody(object, keepalive) +} + +function cloneBody (instance, body) { + // To clone a body body, run these steps: + + // https://fetch.spec.whatwg.org/#concept-body-clone + + // 1. Let « out1, out2 » be the result of teeing body’s stream. + const [out1, out2] = body.stream.tee() + + if (hasFinalizationRegistry) { + streamRegistry.register(instance, new WeakRef(out1)) + } + + // 2. Set body’s stream to out1. + body.stream = out1 + + // 3. Return a body whose stream is out2 and other members are copied from body. + return { + stream: out2, + length: body.length, + source: body.source + } +} + +function throwIfAborted (state) { + if (state.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError') + } +} + +function bodyMixinMethods (instance, getInternalState) { + const methods = { + blob () { + // The blob() method steps are to return the result of + // running consume body with this and the following step + // given a byte sequence bytes: return a Blob whose + // contents are bytes and whose type attribute is this’s + // MIME type. + return consumeBody(this, (bytes) => { + let mimeType = bodyMimeType(getInternalState(this)) + + if (mimeType === null) { + mimeType = '' + } else if (mimeType) { + mimeType = serializeAMimeType(mimeType) + } + + // Return a Blob whose contents are bytes and type attribute + // is mimeType. + return new Blob([bytes], { type: mimeType }) + }, instance, getInternalState) + }, + + arrayBuffer () { + // The arrayBuffer() method steps are to return the result + // of running consume body with this and the following step + // given a byte sequence bytes: return a new ArrayBuffer + // whose contents are bytes. + return consumeBody(this, (bytes) => { + return new Uint8Array(bytes).buffer + }, instance, getInternalState) + }, + + text () { + // The text() method steps are to return the result of running + // consume body with this and UTF-8 decode. + return consumeBody(this, utf8DecodeBytes, instance, getInternalState) + }, + + json () { + // The json() method steps are to return the result of running + // consume body with this and parse JSON from bytes. + return consumeBody(this, parseJSONFromBytes, instance, getInternalState) + }, + + formData () { + // The formData() method steps are to return the result of running + // consume body with this and the following step given a byte sequence bytes: + return consumeBody(this, (value) => { + // 1. Let mimeType be the result of get the MIME type with this. + const mimeType = bodyMimeType(getInternalState(this)) + + // 2. If mimeType is non-null, then switch on mimeType’s essence and run + // the corresponding steps: + if (mimeType !== null) { + switch (mimeType.essence) { + case 'multipart/form-data': { + // 1. ... [long step] + // 2. If that fails for some reason, then throw a TypeError. + const parsed = multipartFormDataParser(value, mimeType) + + // 3. Return a new FormData object, appending each entry, + // resulting from the parsing operation, to its entry list. + const fd = new FormData() + setFormDataState(fd, parsed) + + return fd + } + case 'application/x-www-form-urlencoded': { + // 1. Let entries be the result of parsing bytes. + const entries = new URLSearchParams(value.toString()) + + // 2. If entries is failure, then throw a TypeError. + + // 3. Return a new FormData object whose entry list is entries. + const fd = new FormData() + + for (const [name, value] of entries) { + fd.append(name, value) + } + + return fd + } + } + } + + // 3. Throw a TypeError. + throw new TypeError( + 'Content-Type was not one of "multipart/form-data" or "application/x-www-form-urlencoded".' + ) + }, instance, getInternalState) + }, + + bytes () { + // The bytes() method steps are to return the result of running consume body + // with this and the following step given a byte sequence bytes: return the + // result of creating a Uint8Array from bytes in this’s relevant realm. + return consumeBody(this, (bytes) => { + return new Uint8Array(bytes) + }, instance, getInternalState) + } + } + + return methods +} + +function mixinBody (prototype, getInternalState) { + Object.assign(prototype.prototype, bodyMixinMethods(prototype, getInternalState)) +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-body-consume-body + * @param {any} object internal state + * @param {(value: unknown) => unknown} convertBytesToJSValue + * @param {any} instance + * @param {(target: any) => any} getInternalState + */ +async function consumeBody (object, convertBytesToJSValue, instance, getInternalState) { + webidl.brandCheck(object, instance) + + const state = getInternalState(object) + + // 1. If object is unusable, then return a promise rejected + // with a TypeError. + if (bodyUnusable(state)) { + throw new TypeError('Body is unusable: Body has already been read') + } + + throwIfAborted(state) + + // 2. Let promise be a new promise. + const promise = createDeferredPromise() + + // 3. Let errorSteps given error be to reject promise with error. + const errorSteps = (error) => promise.reject(error) + + // 4. Let successSteps given a byte sequence data be to resolve + // promise with the result of running convertBytesToJSValue + // with data. If that threw an exception, then run errorSteps + // with that exception. + const successSteps = (data) => { + try { + promise.resolve(convertBytesToJSValue(data)) + } catch (e) { + errorSteps(e) + } + } + + // 5. If object’s body is null, then run successSteps with an + // empty byte sequence. + if (state.body == null) { + successSteps(Buffer.allocUnsafe(0)) + return promise.promise + } + + // 6. Otherwise, fully read object’s body given successSteps, + // errorSteps, and object’s relevant global object. + fullyReadBody(state.body, successSteps, errorSteps) + + // 7. Return promise. + return promise.promise +} + +/** + * @see https://fetch.spec.whatwg.org/#body-unusable + * @param {any} object internal state + */ +function bodyUnusable (object) { + const body = object.body + + // An object including the Body interface mixin is + // said to be unusable if its body is non-null and + // its body’s stream is disturbed or locked. + return body != null && (body.stream.locked || util.isDisturbed(body.stream)) +} + +/** + * @see https://infra.spec.whatwg.org/#parse-json-bytes-to-a-javascript-value + * @param {Uint8Array} bytes + */ +function parseJSONFromBytes (bytes) { + return JSON.parse(utf8DecodeBytes(bytes)) +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-body-mime-type + * @param {any} requestOrResponse internal state + */ +function bodyMimeType (requestOrResponse) { + // 1. Let headers be null. + // 2. If requestOrResponse is a Request object, then set headers to requestOrResponse’s request’s header list. + // 3. Otherwise, set headers to requestOrResponse’s response’s header list. + /** @type {import('./headers').HeadersList} */ + const headers = requestOrResponse.headersList + + // 4. Let mimeType be the result of extracting a MIME type from headers. + const mimeType = extractMimeType(headers) + + // 5. If mimeType is failure, then return null. + if (mimeType === 'failure') { + return null + } + + // 6. Return mimeType. + return mimeType +} + +module.exports = { + extractBody, + safelyExtractBody, + cloneBody, + mixinBody, + streamRegistry, + hasFinalizationRegistry, + bodyUnusable +} diff --git a/lib/web/fetch/constants.js b/lib/web/fetch/constants.js new file mode 100644 index 0000000..ef63b0c --- /dev/null +++ b/lib/web/fetch/constants.js @@ -0,0 +1,131 @@ +'use strict' + +const corsSafeListedMethods = /** @type {const} */ (['GET', 'HEAD', 'POST']) +const corsSafeListedMethodsSet = new Set(corsSafeListedMethods) + +const nullBodyStatus = /** @type {const} */ ([101, 204, 205, 304]) + +const redirectStatus = /** @type {const} */ ([301, 302, 303, 307, 308]) +const redirectStatusSet = new Set(redirectStatus) + +/** + * @see https://fetch.spec.whatwg.org/#block-bad-port + */ +const badPorts = /** @type {const} */ ([ + '1', '7', '9', '11', '13', '15', '17', '19', '20', '21', '22', '23', '25', '37', '42', '43', '53', '69', '77', '79', + '87', '95', '101', '102', '103', '104', '109', '110', '111', '113', '115', '117', '119', '123', '135', '137', + '139', '143', '161', '179', '389', '427', '465', '512', '513', '514', '515', '526', '530', '531', '532', + '540', '548', '554', '556', '563', '587', '601', '636', '989', '990', '993', '995', '1719', '1720', '1723', + '2049', '3659', '4045', '4190', '5060', '5061', '6000', '6566', '6665', '6666', '6667', '6668', '6669', '6679', + '6697', '10080' +]) +const badPortsSet = new Set(badPorts) + +/** + * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policy-header + */ +const referrerPolicyTokens = /** @type {const} */ ([ + 'no-referrer', + 'no-referrer-when-downgrade', + 'same-origin', + 'origin', + 'strict-origin', + 'origin-when-cross-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url' +]) + +/** + * @see https://w3c.github.io/webappsec-referrer-policy/#referrer-policies + */ +const referrerPolicy = /** @type {const} */ ([ + '', + ...referrerPolicyTokens +]) +const referrerPolicyTokensSet = new Set(referrerPolicyTokens) + +const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error']) + +const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE']) +const safeMethodsSet = new Set(safeMethods) + +const requestMode = /** @type {const} */ (['navigate', 'same-origin', 'no-cors', 'cors']) + +const requestCredentials = /** @type {const} */ (['omit', 'same-origin', 'include']) + +const requestCache = /** @type {const} */ ([ + 'default', + 'no-store', + 'reload', + 'no-cache', + 'force-cache', + 'only-if-cached' +]) + +/** + * @see https://fetch.spec.whatwg.org/#request-body-header-name + */ +const requestBodyHeader = /** @type {const} */ ([ + 'content-encoding', + 'content-language', + 'content-location', + 'content-type', + // See https://github.com/nodejs/undici/issues/2021 + // 'Content-Length' is a forbidden header name, which is typically + // removed in the Headers implementation. However, undici doesn't + // filter out headers, so we add it here. + 'content-length' +]) + +/** + * @see https://fetch.spec.whatwg.org/#enumdef-requestduplex + */ +const requestDuplex = /** @type {const} */ ([ + 'half' +]) + +/** + * @see http://fetch.spec.whatwg.org/#forbidden-method + */ +const forbiddenMethods = /** @type {const} */ (['CONNECT', 'TRACE', 'TRACK']) +const forbiddenMethodsSet = new Set(forbiddenMethods) + +const subresource = /** @type {const} */ ([ + 'audio', + 'audioworklet', + 'font', + 'image', + 'manifest', + 'paintworklet', + 'script', + 'style', + 'track', + 'video', + 'xslt', + '' +]) +const subresourceSet = new Set(subresource) + +module.exports = { + subresource, + forbiddenMethods, + requestBodyHeader, + referrerPolicy, + requestRedirect, + requestMode, + requestCredentials, + requestCache, + redirectStatus, + corsSafeListedMethods, + nullBodyStatus, + safeMethods, + badPorts, + requestDuplex, + subresourceSet, + badPortsSet, + redirectStatusSet, + corsSafeListedMethodsSet, + safeMethodsSet, + forbiddenMethodsSet, + referrerPolicyTokens: referrerPolicyTokensSet +} diff --git a/lib/web/fetch/data-url.js b/lib/web/fetch/data-url.js new file mode 100644 index 0000000..bc7a692 --- /dev/null +++ b/lib/web/fetch/data-url.js @@ -0,0 +1,744 @@ +'use strict' + +const assert = require('node:assert') + +const encoder = new TextEncoder() + +/** + * @see https://mimesniff.spec.whatwg.org/#http-token-code-point + */ +const HTTP_TOKEN_CODEPOINTS = /^[!#$%&'*+\-.^_|~A-Za-z0-9]+$/ +const HTTP_WHITESPACE_REGEX = /[\u000A\u000D\u0009\u0020]/ // eslint-disable-line +const ASCII_WHITESPACE_REPLACE_REGEX = /[\u0009\u000A\u000C\u000D\u0020]/g // eslint-disable-line +/** + * @see https://mimesniff.spec.whatwg.org/#http-quoted-string-token-code-point + */ +const HTTP_QUOTED_STRING_TOKENS = /^[\u0009\u0020-\u007E\u0080-\u00FF]+$/ // eslint-disable-line + +// https://fetch.spec.whatwg.org/#data-url-processor +/** @param {URL} dataURL */ +function dataURLProcessor (dataURL) { + // 1. Assert: dataURL’s scheme is "data". + assert(dataURL.protocol === 'data:') + + // 2. Let input be the result of running the URL + // serializer on dataURL with exclude fragment + // set to true. + let input = URLSerializer(dataURL, true) + + // 3. Remove the leading "data:" string from input. + input = input.slice(5) + + // 4. Let position point at the start of input. + const position = { position: 0 } + + // 5. Let mimeType be the result of collecting a + // sequence of code points that are not equal + // to U+002C (,), given position. + let mimeType = collectASequenceOfCodePointsFast( + ',', + input, + position + ) + + // 6. Strip leading and trailing ASCII whitespace + // from mimeType. + // Undici implementation note: we need to store the + // length because if the mimetype has spaces removed, + // the wrong amount will be sliced from the input in + // step #9 + const mimeTypeLength = mimeType.length + mimeType = removeASCIIWhitespace(mimeType, true, true) + + // 7. If position is past the end of input, then + // return failure + if (position.position >= input.length) { + return 'failure' + } + + // 8. Advance position by 1. + position.position++ + + // 9. Let encodedBody be the remainder of input. + const encodedBody = input.slice(mimeTypeLength + 1) + + // 10. Let body be the percent-decoding of encodedBody. + let body = stringPercentDecode(encodedBody) + + // 11. If mimeType ends with U+003B (;), followed by + // zero or more U+0020 SPACE, followed by an ASCII + // case-insensitive match for "base64", then: + if (/;(\u0020){0,}base64$/i.test(mimeType)) { + // 1. Let stringBody be the isomorphic decode of body. + const stringBody = isomorphicDecode(body) + + // 2. Set body to the forgiving-base64 decode of + // stringBody. + body = forgivingBase64(stringBody) + + // 3. If body is failure, then return failure. + if (body === 'failure') { + return 'failure' + } + + // 4. Remove the last 6 code points from mimeType. + mimeType = mimeType.slice(0, -6) + + // 5. Remove trailing U+0020 SPACE code points from mimeType, + // if any. + mimeType = mimeType.replace(/(\u0020)+$/, '') + + // 6. Remove the last U+003B (;) code point from mimeType. + mimeType = mimeType.slice(0, -1) + } + + // 12. If mimeType starts with U+003B (;), then prepend + // "text/plain" to mimeType. + if (mimeType.startsWith(';')) { + mimeType = 'text/plain' + mimeType + } + + // 13. Let mimeTypeRecord be the result of parsing + // mimeType. + let mimeTypeRecord = parseMIMEType(mimeType) + + // 14. If mimeTypeRecord is failure, then set + // mimeTypeRecord to text/plain;charset=US-ASCII. + if (mimeTypeRecord === 'failure') { + mimeTypeRecord = parseMIMEType('text/plain;charset=US-ASCII') + } + + // 15. Return a new data: URL struct whose MIME + // type is mimeTypeRecord and body is body. + // https://fetch.spec.whatwg.org/#data-url-struct + return { mimeType: mimeTypeRecord, body } +} + +// https://url.spec.whatwg.org/#concept-url-serializer +/** + * @param {URL} url + * @param {boolean} excludeFragment + */ +function URLSerializer (url, excludeFragment = false) { + if (!excludeFragment) { + return url.href + } + + const href = url.href + const hashLength = url.hash.length + + const serialized = hashLength === 0 ? href : href.substring(0, href.length - hashLength) + + if (!hashLength && href.endsWith('#')) { + return serialized.slice(0, -1) + } + + return serialized +} + +// https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points +/** + * @param {(char: string) => boolean} condition + * @param {string} input + * @param {{ position: number }} position + */ +function collectASequenceOfCodePoints (condition, input, position) { + // 1. Let result be the empty string. + let result = '' + + // 2. While position doesn’t point past the end of input and the + // code point at position within input meets the condition condition: + while (position.position < input.length && condition(input[position.position])) { + // 1. Append that code point to the end of result. + result += input[position.position] + + // 2. Advance position by 1. + position.position++ + } + + // 3. Return result. + return result +} + +/** + * A faster collectASequenceOfCodePoints that only works when comparing a single character. + * @param {string} char + * @param {string} input + * @param {{ position: number }} position + */ +function collectASequenceOfCodePointsFast (char, input, position) { + const idx = input.indexOf(char, position.position) + const start = position.position + + if (idx === -1) { + position.position = input.length + return input.slice(start) + } + + position.position = idx + return input.slice(start, position.position) +} + +// https://url.spec.whatwg.org/#string-percent-decode +/** @param {string} input */ +function stringPercentDecode (input) { + // 1. Let bytes be the UTF-8 encoding of input. + const bytes = encoder.encode(input) + + // 2. Return the percent-decoding of bytes. + return percentDecode(bytes) +} + +/** + * @param {number} byte + */ +function isHexCharByte (byte) { + // 0-9 A-F a-f + return (byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x46) || (byte >= 0x61 && byte <= 0x66) +} + +/** + * @param {number} byte + */ +function hexByteToNumber (byte) { + return ( + // 0-9 + byte >= 0x30 && byte <= 0x39 + ? (byte - 48) + // Convert to uppercase + // ((byte & 0xDF) - 65) + 10 + : ((byte & 0xDF) - 55) + ) +} + +// https://url.spec.whatwg.org/#percent-decode +/** @param {Uint8Array} input */ +function percentDecode (input) { + const length = input.length + // 1. Let output be an empty byte sequence. + /** @type {Uint8Array} */ + const output = new Uint8Array(length) + let j = 0 + // 2. For each byte byte in input: + for (let i = 0; i < length; ++i) { + const byte = input[i] + + // 1. If byte is not 0x25 (%), then append byte to output. + if (byte !== 0x25) { + output[j++] = byte + + // 2. Otherwise, if byte is 0x25 (%) and the next two bytes + // after byte in input are not in the ranges + // 0x30 (0) to 0x39 (9), 0x41 (A) to 0x46 (F), + // and 0x61 (a) to 0x66 (f), all inclusive, append byte + // to output. + } else if ( + byte === 0x25 && + !(isHexCharByte(input[i + 1]) && isHexCharByte(input[i + 2])) + ) { + output[j++] = 0x25 + + // 3. Otherwise: + } else { + // 1. Let bytePoint be the two bytes after byte in input, + // decoded, and then interpreted as hexadecimal number. + // 2. Append a byte whose value is bytePoint to output. + output[j++] = (hexByteToNumber(input[i + 1]) << 4) | hexByteToNumber(input[i + 2]) + + // 3. Skip the next two bytes in input. + i += 2 + } + } + + // 3. Return output. + return length === j ? output : output.subarray(0, j) +} + +// https://mimesniff.spec.whatwg.org/#parse-a-mime-type +/** @param {string} input */ +function parseMIMEType (input) { + // 1. Remove any leading and trailing HTTP whitespace + // from input. + input = removeHTTPWhitespace(input, true, true) + + // 2. Let position be a position variable for input, + // initially pointing at the start of input. + const position = { position: 0 } + + // 3. Let type be the result of collecting a sequence + // of code points that are not U+002F (/) from + // input, given position. + const type = collectASequenceOfCodePointsFast( + '/', + input, + position + ) + + // 4. If type is the empty string or does not solely + // contain HTTP token code points, then return failure. + // https://mimesniff.spec.whatwg.org/#http-token-code-point + if (type.length === 0 || !HTTP_TOKEN_CODEPOINTS.test(type)) { + return 'failure' + } + + // 5. If position is past the end of input, then return + // failure + if (position.position >= input.length) { + return 'failure' + } + + // 6. Advance position by 1. (This skips past U+002F (/).) + position.position++ + + // 7. Let subtype be the result of collecting a sequence of + // code points that are not U+003B (;) from input, given + // position. + let subtype = collectASequenceOfCodePointsFast( + ';', + input, + position + ) + + // 8. Remove any trailing HTTP whitespace from subtype. + subtype = removeHTTPWhitespace(subtype, false, true) + + // 9. If subtype is the empty string or does not solely + // contain HTTP token code points, then return failure. + if (subtype.length === 0 || !HTTP_TOKEN_CODEPOINTS.test(subtype)) { + return 'failure' + } + + const typeLowercase = type.toLowerCase() + const subtypeLowercase = subtype.toLowerCase() + + // 10. Let mimeType be a new MIME type record whose type + // is type, in ASCII lowercase, and subtype is subtype, + // in ASCII lowercase. + // https://mimesniff.spec.whatwg.org/#mime-type + const mimeType = { + type: typeLowercase, + subtype: subtypeLowercase, + /** @type {Map} */ + parameters: new Map(), + // https://mimesniff.spec.whatwg.org/#mime-type-essence + essence: `${typeLowercase}/${subtypeLowercase}` + } + + // 11. While position is not past the end of input: + while (position.position < input.length) { + // 1. Advance position by 1. (This skips past U+003B (;).) + position.position++ + + // 2. Collect a sequence of code points that are HTTP + // whitespace from input given position. + collectASequenceOfCodePoints( + // https://fetch.spec.whatwg.org/#http-whitespace + char => HTTP_WHITESPACE_REGEX.test(char), + input, + position + ) + + // 3. Let parameterName be the result of collecting a + // sequence of code points that are not U+003B (;) + // or U+003D (=) from input, given position. + let parameterName = collectASequenceOfCodePoints( + (char) => char !== ';' && char !== '=', + input, + position + ) + + // 4. Set parameterName to parameterName, in ASCII + // lowercase. + parameterName = parameterName.toLowerCase() + + // 5. If position is not past the end of input, then: + if (position.position < input.length) { + // 1. If the code point at position within input is + // U+003B (;), then continue. + if (input[position.position] === ';') { + continue + } + + // 2. Advance position by 1. (This skips past U+003D (=).) + position.position++ + } + + // 6. If position is past the end of input, then break. + if (position.position >= input.length) { + break + } + + // 7. Let parameterValue be null. + let parameterValue = null + + // 8. If the code point at position within input is + // U+0022 ("), then: + if (input[position.position] === '"') { + // 1. Set parameterValue to the result of collecting + // an HTTP quoted string from input, given position + // and the extract-value flag. + parameterValue = collectAnHTTPQuotedString(input, position, true) + + // 2. Collect a sequence of code points that are not + // U+003B (;) from input, given position. + collectASequenceOfCodePointsFast( + ';', + input, + position + ) + + // 9. Otherwise: + } else { + // 1. Set parameterValue to the result of collecting + // a sequence of code points that are not U+003B (;) + // from input, given position. + parameterValue = collectASequenceOfCodePointsFast( + ';', + input, + position + ) + + // 2. Remove any trailing HTTP whitespace from parameterValue. + parameterValue = removeHTTPWhitespace(parameterValue, false, true) + + // 3. If parameterValue is the empty string, then continue. + if (parameterValue.length === 0) { + continue + } + } + + // 10. If all of the following are true + // - parameterName is not the empty string + // - parameterName solely contains HTTP token code points + // - parameterValue solely contains HTTP quoted-string token code points + // - mimeType’s parameters[parameterName] does not exist + // then set mimeType’s parameters[parameterName] to parameterValue. + if ( + parameterName.length !== 0 && + HTTP_TOKEN_CODEPOINTS.test(parameterName) && + (parameterValue.length === 0 || HTTP_QUOTED_STRING_TOKENS.test(parameterValue)) && + !mimeType.parameters.has(parameterName) + ) { + mimeType.parameters.set(parameterName, parameterValue) + } + } + + // 12. Return mimeType. + return mimeType +} + +// https://infra.spec.whatwg.org/#forgiving-base64-decode +/** @param {string} data */ +function forgivingBase64 (data) { + // 1. Remove all ASCII whitespace from data. + data = data.replace(ASCII_WHITESPACE_REPLACE_REGEX, '') + + let dataLength = data.length + // 2. If data’s code point length divides by 4 leaving + // no remainder, then: + if (dataLength % 4 === 0) { + // 1. If data ends with one or two U+003D (=) code points, + // then remove them from data. + if (data.charCodeAt(dataLength - 1) === 0x003D) { + --dataLength + if (data.charCodeAt(dataLength - 1) === 0x003D) { + --dataLength + } + } + } + + // 3. If data’s code point length divides by 4 leaving + // a remainder of 1, then return failure. + if (dataLength % 4 === 1) { + return 'failure' + } + + // 4. If data contains a code point that is not one of + // U+002B (+) + // U+002F (/) + // ASCII alphanumeric + // then return failure. + if (/[^+/0-9A-Za-z]/.test(data.length === dataLength ? data : data.substring(0, dataLength))) { + return 'failure' + } + + const buffer = Buffer.from(data, 'base64') + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) +} + +// https://fetch.spec.whatwg.org/#collect-an-http-quoted-string +// tests: https://fetch.spec.whatwg.org/#example-http-quoted-string +/** + * @param {string} input + * @param {{ position: number }} position + * @param {boolean} [extractValue=false] + */ +function collectAnHTTPQuotedString (input, position, extractValue = false) { + // 1. Let positionStart be position. + const positionStart = position.position + + // 2. Let value be the empty string. + let value = '' + + // 3. Assert: the code point at position within input + // is U+0022 ("). + assert(input[position.position] === '"') + + // 4. Advance position by 1. + position.position++ + + // 5. While true: + while (true) { + // 1. Append the result of collecting a sequence of code points + // that are not U+0022 (") or U+005C (\) from input, given + // position, to value. + value += collectASequenceOfCodePoints( + (char) => char !== '"' && char !== '\\', + input, + position + ) + + // 2. If position is past the end of input, then break. + if (position.position >= input.length) { + break + } + + // 3. Let quoteOrBackslash be the code point at position within + // input. + const quoteOrBackslash = input[position.position] + + // 4. Advance position by 1. + position.position++ + + // 5. If quoteOrBackslash is U+005C (\), then: + if (quoteOrBackslash === '\\') { + // 1. If position is past the end of input, then append + // U+005C (\) to value and break. + if (position.position >= input.length) { + value += '\\' + break + } + + // 2. Append the code point at position within input to value. + value += input[position.position] + + // 3. Advance position by 1. + position.position++ + + // 6. Otherwise: + } else { + // 1. Assert: quoteOrBackslash is U+0022 ("). + assert(quoteOrBackslash === '"') + + // 2. Break. + break + } + } + + // 6. If the extract-value flag is set, then return value. + if (extractValue) { + return value + } + + // 7. Return the code points from positionStart to position, + // inclusive, within input. + return input.slice(positionStart, position.position) +} + +/** + * @see https://mimesniff.spec.whatwg.org/#serialize-a-mime-type + */ +function serializeAMimeType (mimeType) { + assert(mimeType !== 'failure') + const { parameters, essence } = mimeType + + // 1. Let serialization be the concatenation of mimeType’s + // type, U+002F (/), and mimeType’s subtype. + let serialization = essence + + // 2. For each name → value of mimeType’s parameters: + for (let [name, value] of parameters.entries()) { + // 1. Append U+003B (;) to serialization. + serialization += ';' + + // 2. Append name to serialization. + serialization += name + + // 3. Append U+003D (=) to serialization. + serialization += '=' + + // 4. If value does not solely contain HTTP token code + // points or value is the empty string, then: + if (!HTTP_TOKEN_CODEPOINTS.test(value)) { + // 1. Precede each occurrence of U+0022 (") or + // U+005C (\) in value with U+005C (\). + value = value.replace(/(\\|")/g, '\\$1') + + // 2. Prepend U+0022 (") to value. + value = '"' + value + + // 3. Append U+0022 (") to value. + value += '"' + } + + // 5. Append value to serialization. + serialization += value + } + + // 3. Return serialization. + return serialization +} + +/** + * @see https://fetch.spec.whatwg.org/#http-whitespace + * @param {number} char + */ +function isHTTPWhiteSpace (char) { + // "\r\n\t " + return char === 0x00d || char === 0x00a || char === 0x009 || char === 0x020 +} + +/** + * @see https://fetch.spec.whatwg.org/#http-whitespace + * @param {string} str + * @param {boolean} [leading=true] + * @param {boolean} [trailing=true] + */ +function removeHTTPWhitespace (str, leading = true, trailing = true) { + return removeChars(str, leading, trailing, isHTTPWhiteSpace) +} + +/** + * @see https://infra.spec.whatwg.org/#ascii-whitespace + * @param {number} char + */ +function isASCIIWhitespace (char) { + // "\r\n\t\f " + return char === 0x00d || char === 0x00a || char === 0x009 || char === 0x00c || char === 0x020 +} + +/** + * @see https://infra.spec.whatwg.org/#strip-leading-and-trailing-ascii-whitespace + * @param {string} str + * @param {boolean} [leading=true] + * @param {boolean} [trailing=true] + */ +function removeASCIIWhitespace (str, leading = true, trailing = true) { + return removeChars(str, leading, trailing, isASCIIWhitespace) +} + +/** + * @param {string} str + * @param {boolean} leading + * @param {boolean} trailing + * @param {(charCode: number) => boolean} predicate + * @returns + */ +function removeChars (str, leading, trailing, predicate) { + let lead = 0 + let trail = str.length - 1 + + if (leading) { + while (lead < str.length && predicate(str.charCodeAt(lead))) lead++ + } + + if (trailing) { + while (trail > 0 && predicate(str.charCodeAt(trail))) trail-- + } + + return lead === 0 && trail === str.length - 1 ? str : str.slice(lead, trail + 1) +} + +/** + * @see https://infra.spec.whatwg.org/#isomorphic-decode + * @param {Uint8Array} input + * @returns {string} + */ +function isomorphicDecode (input) { + // 1. To isomorphic decode a byte sequence input, return a string whose code point + // length is equal to input’s length and whose code points have the same values + // as the values of input’s bytes, in the same order. + const length = input.length + if ((2 << 15) - 1 > length) { + return String.fromCharCode.apply(null, input) + } + let result = ''; let i = 0 + let addition = (2 << 15) - 1 + while (i < length) { + if (i + addition > length) { + addition = length - i + } + result += String.fromCharCode.apply(null, input.subarray(i, i += addition)) + } + return result +} + +/** + * @see https://mimesniff.spec.whatwg.org/#minimize-a-supported-mime-type + * @param {Exclude, 'failure'>} mimeType + */ +function minimizeSupportedMimeType (mimeType) { + switch (mimeType.essence) { + case 'application/ecmascript': + case 'application/javascript': + case 'application/x-ecmascript': + case 'application/x-javascript': + case 'text/ecmascript': + case 'text/javascript': + case 'text/javascript1.0': + case 'text/javascript1.1': + case 'text/javascript1.2': + case 'text/javascript1.3': + case 'text/javascript1.4': + case 'text/javascript1.5': + case 'text/jscript': + case 'text/livescript': + case 'text/x-ecmascript': + case 'text/x-javascript': + // 1. If mimeType is a JavaScript MIME type, then return "text/javascript". + return 'text/javascript' + case 'application/json': + case 'text/json': + // 2. If mimeType is a JSON MIME type, then return "application/json". + return 'application/json' + case 'image/svg+xml': + // 3. If mimeType’s essence is "image/svg+xml", then return "image/svg+xml". + return 'image/svg+xml' + case 'text/xml': + case 'application/xml': + // 4. If mimeType is an XML MIME type, then return "application/xml". + return 'application/xml' + } + + // 2. If mimeType is a JSON MIME type, then return "application/json". + if (mimeType.subtype.endsWith('+json')) { + return 'application/json' + } + + // 4. If mimeType is an XML MIME type, then return "application/xml". + if (mimeType.subtype.endsWith('+xml')) { + return 'application/xml' + } + + // 5. If mimeType is supported by the user agent, then return mimeType’s essence. + // Technically, node doesn't support any mimetypes. + + // 6. Return the empty string. + return '' +} + +module.exports = { + dataURLProcessor, + URLSerializer, + collectASequenceOfCodePoints, + collectASequenceOfCodePointsFast, + stringPercentDecode, + parseMIMEType, + collectAnHTTPQuotedString, + serializeAMimeType, + removeChars, + removeHTTPWhitespace, + minimizeSupportedMimeType, + HTTP_TOKEN_CODEPOINTS, + isomorphicDecode +} diff --git a/lib/web/fetch/dispatcher-weakref.js b/lib/web/fetch/dispatcher-weakref.js new file mode 100644 index 0000000..6ac5f37 --- /dev/null +++ b/lib/web/fetch/dispatcher-weakref.js @@ -0,0 +1,46 @@ +'use strict' + +const { kConnected, kSize } = require('../../core/symbols') + +class CompatWeakRef { + constructor (value) { + this.value = value + } + + deref () { + return this.value[kConnected] === 0 && this.value[kSize] === 0 + ? undefined + : this.value + } +} + +class CompatFinalizer { + constructor (finalizer) { + this.finalizer = finalizer + } + + register (dispatcher, key) { + if (dispatcher.on) { + dispatcher.on('disconnect', () => { + if (dispatcher[kConnected] === 0 && dispatcher[kSize] === 0) { + this.finalizer(key) + } + }) + } + } + + unregister (key) {} +} + +module.exports = function () { + // FIXME: remove workaround when the Node bug is backported to v18 + // https://github.com/nodejs/node/issues/49344#issuecomment-1741776308 + if (process.env.NODE_V8_COVERAGE && process.version.startsWith('v18')) { + process._rawDebug('Using compatibility WeakRef and FinalizationRegistry') + return { + WeakRef: CompatWeakRef, + FinalizationRegistry: CompatFinalizer + } + } + return { WeakRef, FinalizationRegistry } +} diff --git a/lib/web/fetch/formdata-parser.js b/lib/web/fetch/formdata-parser.js new file mode 100644 index 0000000..f43b5fd --- /dev/null +++ b/lib/web/fetch/formdata-parser.js @@ -0,0 +1,501 @@ +'use strict' + +const { isUSVString, bufferToLowerCasedHeaderName } = require('../../core/util') +const { utf8DecodeBytes } = require('./util') +const { HTTP_TOKEN_CODEPOINTS, isomorphicDecode } = require('./data-url') +const { makeEntry } = require('./formdata') +const { webidl } = require('./webidl') +const assert = require('node:assert') +const { File: NodeFile } = require('node:buffer') + +const File = globalThis.File ?? NodeFile + +const formDataNameBuffer = Buffer.from('form-data; name="') +const filenameBuffer = Buffer.from('filename') +const dd = Buffer.from('--') +const ddcrlf = Buffer.from('--\r\n') + +/** + * @param {string} chars + */ +function isAsciiString (chars) { + for (let i = 0; i < chars.length; ++i) { + if ((chars.charCodeAt(i) & ~0x7F) !== 0) { + return false + } + } + return true +} + +/** + * @see https://andreubotella.github.io/multipart-form-data/#multipart-form-data-boundary + * @param {string} boundary + */ +function validateBoundary (boundary) { + const length = boundary.length + + // - its length is greater or equal to 27 and lesser or equal to 70, and + if (length < 27 || length > 70) { + return false + } + + // - it is composed by bytes in the ranges 0x30 to 0x39, 0x41 to 0x5A, or + // 0x61 to 0x7A, inclusive (ASCII alphanumeric), or which are 0x27 ('), + // 0x2D (-) or 0x5F (_). + for (let i = 0; i < length; ++i) { + const cp = boundary.charCodeAt(i) + + if (!( + (cp >= 0x30 && cp <= 0x39) || + (cp >= 0x41 && cp <= 0x5a) || + (cp >= 0x61 && cp <= 0x7a) || + cp === 0x27 || + cp === 0x2d || + cp === 0x5f + )) { + return false + } + } + + return true +} + +/** + * @see https://andreubotella.github.io/multipart-form-data/#multipart-form-data-parser + * @param {Buffer} input + * @param {ReturnType} mimeType + */ +function multipartFormDataParser (input, mimeType) { + // 1. Assert: mimeType’s essence is "multipart/form-data". + assert(mimeType !== 'failure' && mimeType.essence === 'multipart/form-data') + + const boundaryString = mimeType.parameters.get('boundary') + + // 2. If mimeType’s parameters["boundary"] does not exist, return failure. + // Otherwise, let boundary be the result of UTF-8 decoding mimeType’s + // parameters["boundary"]. + if (boundaryString === undefined) { + throw parsingError('missing boundary in content-type header') + } + + const boundary = Buffer.from(`--${boundaryString}`, 'utf8') + + // 3. Let entry list be an empty entry list. + const entryList = [] + + // 4. Let position be a pointer to a byte in input, initially pointing at + // the first byte. + const position = { position: 0 } + + // Note: undici addition, allows leading and trailing CRLFs. + while (input[position.position] === 0x0d && input[position.position + 1] === 0x0a) { + position.position += 2 + } + + let trailing = input.length + + while (input[trailing - 1] === 0x0a && input[trailing - 2] === 0x0d) { + trailing -= 2 + } + + if (trailing !== input.length) { + input = input.subarray(0, trailing) + } + + // 5. While true: + while (true) { + // 5.1. If position points to a sequence of bytes starting with 0x2D 0x2D + // (`--`) followed by boundary, advance position by 2 + the length of + // boundary. Otherwise, return failure. + // Note: boundary is padded with 2 dashes already, no need to add 2. + if (input.subarray(position.position, position.position + boundary.length).equals(boundary)) { + position.position += boundary.length + } else { + throw parsingError('expected a value starting with -- and the boundary') + } + + // 5.2. If position points to the sequence of bytes 0x2D 0x2D 0x0D 0x0A + // (`--` followed by CR LF) followed by the end of input, return entry list. + // Note: a body does NOT need to end with CRLF. It can end with --. + if ( + (position.position === input.length - 2 && bufferStartsWith(input, dd, position)) || + (position.position === input.length - 4 && bufferStartsWith(input, ddcrlf, position)) + ) { + return entryList + } + + // 5.3. If position does not point to a sequence of bytes starting with 0x0D + // 0x0A (CR LF), return failure. + if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) { + throw parsingError('expected CRLF') + } + + // 5.4. Advance position by 2. (This skips past the newline.) + position.position += 2 + + // 5.5. Let name, filename and contentType be the result of parsing + // multipart/form-data headers on input and position, if the result + // is not failure. Otherwise, return failure. + const result = parseMultipartFormDataHeaders(input, position) + + let { name, filename, contentType, encoding } = result + + // 5.6. Advance position by 2. (This skips past the empty line that marks + // the end of the headers.) + position.position += 2 + + // 5.7. Let body be the empty byte sequence. + let body + + // 5.8. Body loop: While position is not past the end of input: + // TODO: the steps here are completely wrong + { + const boundaryIndex = input.indexOf(boundary.subarray(2), position.position) + + if (boundaryIndex === -1) { + throw parsingError('expected boundary after body') + } + + body = input.subarray(position.position, boundaryIndex - 4) + + position.position += body.length + + // Note: position must be advanced by the body's length before being + // decoded, otherwise the parsing will fail. + if (encoding === 'base64') { + body = Buffer.from(body.toString(), 'base64') + } + } + + // 5.9. If position does not point to a sequence of bytes starting with + // 0x0D 0x0A (CR LF), return failure. Otherwise, advance position by 2. + if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) { + throw parsingError('expected CRLF') + } else { + position.position += 2 + } + + // 5.10. If filename is not null: + let value + + if (filename !== null) { + // 5.10.1. If contentType is null, set contentType to "text/plain". + contentType ??= 'text/plain' + + // 5.10.2. If contentType is not an ASCII string, set contentType to the empty string. + + // Note: `buffer.isAscii` can be used at zero-cost, but converting a string to a buffer is a high overhead. + // Content-Type is a relatively small string, so it is faster to use `String#charCodeAt`. + if (!isAsciiString(contentType)) { + contentType = '' + } + + // 5.10.3. Let value be a new File object with name filename, type contentType, and body body. + value = new File([body], filename, { type: contentType }) + } else { + // 5.11. Otherwise: + + // 5.11.1. Let value be the UTF-8 decoding without BOM of body. + value = utf8DecodeBytes(Buffer.from(body)) + } + + // 5.12. Assert: name is a scalar value string and value is either a scalar value string or a File object. + assert(isUSVString(name)) + assert((typeof value === 'string' && isUSVString(value)) || webidl.is.File(value)) + + // 5.13. Create an entry with name and value, and append it to entry list. + entryList.push(makeEntry(name, value, filename)) + } +} + +/** + * @see https://andreubotella.github.io/multipart-form-data/#parse-multipart-form-data-headers + * @param {Buffer} input + * @param {{ position: number }} position + */ +function parseMultipartFormDataHeaders (input, position) { + // 1. Let name, filename and contentType be null. + let name = null + let filename = null + let contentType = null + let encoding = null + + // 2. While true: + while (true) { + // 2.1. If position points to a sequence of bytes starting with 0x0D 0x0A (CR LF): + if (input[position.position] === 0x0d && input[position.position + 1] === 0x0a) { + // 2.1.1. If name is null, return failure. + if (name === null) { + throw parsingError('header name is null') + } + + // 2.1.2. Return name, filename and contentType. + return { name, filename, contentType, encoding } + } + + // 2.2. Let header name be the result of collecting a sequence of bytes that are + // not 0x0A (LF), 0x0D (CR) or 0x3A (:), given position. + let headerName = collectASequenceOfBytes( + (char) => char !== 0x0a && char !== 0x0d && char !== 0x3a, + input, + position + ) + + // 2.3. Remove any HTTP tab or space bytes from the start or end of header name. + headerName = removeChars(headerName, true, true, (char) => char === 0x9 || char === 0x20) + + // 2.4. If header name does not match the field-name token production, return failure. + if (!HTTP_TOKEN_CODEPOINTS.test(headerName.toString())) { + throw parsingError('header name does not match the field-name token production') + } + + // 2.5. If the byte at position is not 0x3A (:), return failure. + if (input[position.position] !== 0x3a) { + throw parsingError('expected :') + } + + // 2.6. Advance position by 1. + position.position++ + + // 2.7. Collect a sequence of bytes that are HTTP tab or space bytes given position. + // (Do nothing with those bytes.) + collectASequenceOfBytes( + (char) => char === 0x20 || char === 0x09, + input, + position + ) + + // 2.8. Byte-lowercase header name and switch on the result: + switch (bufferToLowerCasedHeaderName(headerName)) { + case 'content-disposition': { + // 1. Set name and filename to null. + name = filename = null + + // 2. If position does not point to a sequence of bytes starting with + // `form-data; name="`, return failure. + if (!bufferStartsWith(input, formDataNameBuffer, position)) { + throw parsingError('expected form-data; name=" for content-disposition header') + } + + // 3. Advance position so it points at the byte after the next 0x22 (") + // byte (the one in the sequence of bytes matched above). + position.position += 17 + + // 4. Set name to the result of parsing a multipart/form-data name given + // input and position, if the result is not failure. Otherwise, return + // failure. + name = parseMultipartFormDataName(input, position) + + // 5. If position points to a sequence of bytes starting with `; filename="`: + if (input[position.position] === 0x3b /* ; */ && input[position.position + 1] === 0x20 /* ' ' */) { + const at = { position: position.position + 2 } + + if (bufferStartsWith(input, filenameBuffer, at)) { + if (input[at.position + 8] === 0x2a /* '*' */) { + at.position += 10 // skip past filename*= + + // Remove leading http tab and spaces. See RFC for examples. + // https://datatracker.ietf.org/doc/html/rfc6266#section-5 + collectASequenceOfBytes( + (char) => char === 0x20 || char === 0x09, + input, + at + ) + + const headerValue = collectASequenceOfBytes( + (char) => char !== 0x20 && char !== 0x0d && char !== 0x0a, // ' ' or CRLF + input, + at + ) + + if ( + (headerValue[0] !== 0x75 && headerValue[0] !== 0x55) || // u or U + (headerValue[1] !== 0x74 && headerValue[1] !== 0x54) || // t or T + (headerValue[2] !== 0x66 && headerValue[2] !== 0x46) || // f or F + headerValue[3] !== 0x2d || // - + headerValue[4] !== 0x38 // 8 + ) { + throw parsingError('unknown encoding, expected utf-8\'\'') + } + + // skip utf-8'' + filename = decodeURIComponent(new TextDecoder().decode(headerValue.subarray(7))) + + position.position = at.position + } else { + // 1. Advance position so it points at the byte after the next 0x22 (") byte + // (the one in the sequence of bytes matched above). + position.position += 11 + + // Remove leading http tab and spaces. See RFC for examples. + // https://datatracker.ietf.org/doc/html/rfc6266#section-5 + collectASequenceOfBytes( + (char) => char === 0x20 || char === 0x09, + input, + position + ) + + position.position++ // skip past " after removing whitespace + + // 2. Set filename to the result of parsing a multipart/form-data name given + // input and position, if the result is not failure. Otherwise, return failure. + filename = parseMultipartFormDataName(input, position) + } + } + } + + break + } + case 'content-type': { + // 1. Let header value be the result of collecting a sequence of bytes that are + // not 0x0A (LF) or 0x0D (CR), given position. + let headerValue = collectASequenceOfBytes( + (char) => char !== 0x0a && char !== 0x0d, + input, + position + ) + + // 2. Remove any HTTP tab or space bytes from the end of header value. + headerValue = removeChars(headerValue, false, true, (char) => char === 0x9 || char === 0x20) + + // 3. Set contentType to the isomorphic decoding of header value. + contentType = isomorphicDecode(headerValue) + + break + } + case 'content-transfer-encoding': { + let headerValue = collectASequenceOfBytes( + (char) => char !== 0x0a && char !== 0x0d, + input, + position + ) + + headerValue = removeChars(headerValue, false, true, (char) => char === 0x9 || char === 0x20) + + encoding = isomorphicDecode(headerValue) + + break + } + default: { + // Collect a sequence of bytes that are not 0x0A (LF) or 0x0D (CR), given position. + // (Do nothing with those bytes.) + collectASequenceOfBytes( + (char) => char !== 0x0a && char !== 0x0d, + input, + position + ) + } + } + + // 2.9. If position does not point to a sequence of bytes starting with 0x0D 0x0A + // (CR LF), return failure. Otherwise, advance position by 2 (past the newline). + if (input[position.position] !== 0x0d && input[position.position + 1] !== 0x0a) { + throw parsingError('expected CRLF') + } else { + position.position += 2 + } + } +} + +/** + * @see https://andreubotella.github.io/multipart-form-data/#parse-a-multipart-form-data-name + * @param {Buffer} input + * @param {{ position: number }} position + */ +function parseMultipartFormDataName (input, position) { + // 1. Assert: The byte at (position - 1) is 0x22 ("). + assert(input[position.position - 1] === 0x22) + + // 2. Let name be the result of collecting a sequence of bytes that are not 0x0A (LF), 0x0D (CR) or 0x22 ("), given position. + /** @type {string | Buffer} */ + let name = collectASequenceOfBytes( + (char) => char !== 0x0a && char !== 0x0d && char !== 0x22, + input, + position + ) + + // 3. If the byte at position is not 0x22 ("), return failure. Otherwise, advance position by 1. + if (input[position.position] !== 0x22) { + throw parsingError('expected "') + } else { + position.position++ + } + + // 4. Replace any occurrence of the following subsequences in name with the given byte: + // - `%0A`: 0x0A (LF) + // - `%0D`: 0x0D (CR) + // - `%22`: 0x22 (") + name = new TextDecoder().decode(name) + .replace(/%0A/ig, '\n') + .replace(/%0D/ig, '\r') + .replace(/%22/g, '"') + + // 5. Return the UTF-8 decoding without BOM of name. + return name +} + +/** + * @param {(char: number) => boolean} condition + * @param {Buffer} input + * @param {{ position: number }} position + */ +function collectASequenceOfBytes (condition, input, position) { + let start = position.position + + while (start < input.length && condition(input[start])) { + ++start + } + + return input.subarray(position.position, (position.position = start)) +} + +/** + * @param {Buffer} buf + * @param {boolean} leading + * @param {boolean} trailing + * @param {(charCode: number) => boolean} predicate + * @returns {Buffer} + */ +function removeChars (buf, leading, trailing, predicate) { + let lead = 0 + let trail = buf.length - 1 + + if (leading) { + while (lead < buf.length && predicate(buf[lead])) lead++ + } + + if (trailing) { + while (trail > 0 && predicate(buf[trail])) trail-- + } + + return lead === 0 && trail === buf.length - 1 ? buf : buf.subarray(lead, trail + 1) +} + +/** + * Checks if {@param buffer} starts with {@param start} + * @param {Buffer} buffer + * @param {Buffer} start + * @param {{ position: number }} position + */ +function bufferStartsWith (buffer, start, position) { + if (buffer.length < start.length) { + return false + } + + for (let i = 0; i < start.length; i++) { + if (start[i] !== buffer[position.position + i]) { + return false + } + } + + return true +} + +function parsingError (cause) { + return new TypeError('Failed to parse body as FormData.', { cause: new TypeError(cause) }) +} + +module.exports = { + multipartFormDataParser, + validateBoundary +} diff --git a/lib/web/fetch/formdata.js b/lib/web/fetch/formdata.js new file mode 100644 index 0000000..8020a26 --- /dev/null +++ b/lib/web/fetch/formdata.js @@ -0,0 +1,263 @@ +'use strict' + +const { iteratorMixin } = require('./util') +const { kEnumerableProperty } = require('../../core/util') +const { webidl } = require('./webidl') +const { File: NativeFile } = require('node:buffer') +const nodeUtil = require('node:util') + +/** @type {globalThis['File']} */ +const File = globalThis.File ?? NativeFile + +// https://xhr.spec.whatwg.org/#formdata +class FormData { + #state = [] + + constructor (form) { + webidl.util.markAsUncloneable(this) + + if (form !== undefined) { + throw webidl.errors.conversionFailed({ + prefix: 'FormData constructor', + argument: 'Argument 1', + types: ['undefined'] + }) + } + } + + append (name, value, filename = undefined) { + webidl.brandCheck(this, FormData) + + const prefix = 'FormData.append' + webidl.argumentLengthCheck(arguments, 2, prefix) + + name = webidl.converters.USVString(name) + + if (arguments.length === 3 || webidl.is.Blob(value)) { + value = webidl.converters.Blob(value, prefix, 'value') + + if (filename !== undefined) { + filename = webidl.converters.USVString(filename) + } + } else { + value = webidl.converters.USVString(value) + } + + // 1. Let value be value if given; otherwise blobValue. + + // 2. Let entry be the result of creating an entry with + // name, value, and filename if given. + const entry = makeEntry(name, value, filename) + + // 3. Append entry to this’s entry list. + this.#state.push(entry) + } + + delete (name) { + webidl.brandCheck(this, FormData) + + const prefix = 'FormData.delete' + webidl.argumentLengthCheck(arguments, 1, prefix) + + name = webidl.converters.USVString(name) + + // The delete(name) method steps are to remove all entries whose name + // is name from this’s entry list. + this.#state = this.#state.filter(entry => entry.name !== name) + } + + get (name) { + webidl.brandCheck(this, FormData) + + const prefix = 'FormData.get' + webidl.argumentLengthCheck(arguments, 1, prefix) + + name = webidl.converters.USVString(name) + + // 1. If there is no entry whose name is name in this’s entry list, + // then return null. + const idx = this.#state.findIndex((entry) => entry.name === name) + if (idx === -1) { + return null + } + + // 2. Return the value of the first entry whose name is name from + // this’s entry list. + return this.#state[idx].value + } + + getAll (name) { + webidl.brandCheck(this, FormData) + + const prefix = 'FormData.getAll' + webidl.argumentLengthCheck(arguments, 1, prefix) + + name = webidl.converters.USVString(name) + + // 1. If there is no entry whose name is name in this’s entry list, + // then return the empty list. + // 2. Return the values of all entries whose name is name, in order, + // from this’s entry list. + return this.#state + .filter((entry) => entry.name === name) + .map((entry) => entry.value) + } + + has (name) { + webidl.brandCheck(this, FormData) + + const prefix = 'FormData.has' + webidl.argumentLengthCheck(arguments, 1, prefix) + + name = webidl.converters.USVString(name) + + // The has(name) method steps are to return true if there is an entry + // whose name is name in this’s entry list; otherwise false. + return this.#state.findIndex((entry) => entry.name === name) !== -1 + } + + set (name, value, filename = undefined) { + webidl.brandCheck(this, FormData) + + const prefix = 'FormData.set' + webidl.argumentLengthCheck(arguments, 2, prefix) + + name = webidl.converters.USVString(name) + + if (arguments.length === 3 || webidl.is.Blob(value)) { + value = webidl.converters.Blob(value, prefix, 'value') + + if (filename !== undefined) { + filename = webidl.converters.USVString(filename) + } + } else { + value = webidl.converters.USVString(value) + } + + // The set(name, value) and set(name, blobValue, filename) method steps + // are: + + // 1. Let value be value if given; otherwise blobValue. + + // 2. Let entry be the result of creating an entry with name, value, and + // filename if given. + const entry = makeEntry(name, value, filename) + + // 3. If there are entries in this’s entry list whose name is name, then + // replace the first such entry with entry and remove the others. + const idx = this.#state.findIndex((entry) => entry.name === name) + if (idx !== -1) { + this.#state = [ + ...this.#state.slice(0, idx), + entry, + ...this.#state.slice(idx + 1).filter((entry) => entry.name !== name) + ] + } else { + // 4. Otherwise, append entry to this’s entry list. + this.#state.push(entry) + } + } + + [nodeUtil.inspect.custom] (depth, options) { + const state = this.#state.reduce((a, b) => { + if (a[b.name]) { + if (Array.isArray(a[b.name])) { + a[b.name].push(b.value) + } else { + a[b.name] = [a[b.name], b.value] + } + } else { + a[b.name] = b.value + } + + return a + }, { __proto__: null }) + + options.depth ??= depth + options.colors ??= true + + const output = nodeUtil.formatWithOptions(options, state) + + // remove [Object null prototype] + return `FormData ${output.slice(output.indexOf(']') + 2)}` + } + + /** + * @param {FormData} formData + */ + static getFormDataState (formData) { + return formData.#state + } + + /** + * @param {FormData} formData + * @param {any[]} newState + */ + static setFormDataState (formData, newState) { + formData.#state = newState + } +} + +const { getFormDataState, setFormDataState } = FormData +Reflect.deleteProperty(FormData, 'getFormDataState') +Reflect.deleteProperty(FormData, 'setFormDataState') + +iteratorMixin('FormData', FormData, getFormDataState, 'name', 'value') + +Object.defineProperties(FormData.prototype, { + append: kEnumerableProperty, + delete: kEnumerableProperty, + get: kEnumerableProperty, + getAll: kEnumerableProperty, + has: kEnumerableProperty, + set: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'FormData', + configurable: true + } +}) + +/** + * @see https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#create-an-entry + * @param {string} name + * @param {string|Blob} value + * @param {?string} filename + * @returns + */ +function makeEntry (name, value, filename) { + // 1. Set name to the result of converting name into a scalar value string. + // Note: This operation was done by the webidl converter USVString. + + // 2. If value is a string, then set value to the result of converting + // value into a scalar value string. + if (typeof value === 'string') { + // Note: This operation was done by the webidl converter USVString. + } else { + // 3. Otherwise: + + // 1. If value is not a File object, then set value to a new File object, + // representing the same bytes, whose name attribute value is "blob" + if (!webidl.is.File(value)) { + value = new File([value], 'blob', { type: value.type }) + } + + // 2. If filename is given, then set value to a new File object, + // representing the same bytes, whose name attribute is filename. + if (filename !== undefined) { + /** @type {FilePropertyBag} */ + const options = { + type: value.type, + lastModified: value.lastModified + } + + value = new File([value], filename, options) + } + } + + // 4. Return an entry whose name is name and whose value is value. + return { name, value } +} + +webidl.is.FormData = webidl.util.MakeTypeAssertion(FormData) + +module.exports = { FormData, makeEntry, setFormDataState } diff --git a/lib/web/fetch/global.js b/lib/web/fetch/global.js new file mode 100644 index 0000000..1df6f12 --- /dev/null +++ b/lib/web/fetch/global.js @@ -0,0 +1,40 @@ +'use strict' + +// In case of breaking changes, increase the version +// number to avoid conflicts. +const globalOrigin = Symbol.for('undici.globalOrigin.1') + +function getGlobalOrigin () { + return globalThis[globalOrigin] +} + +function setGlobalOrigin (newOrigin) { + if (newOrigin === undefined) { + Object.defineProperty(globalThis, globalOrigin, { + value: undefined, + writable: true, + enumerable: false, + configurable: false + }) + + return + } + + const parsedURL = new URL(newOrigin) + + if (parsedURL.protocol !== 'http:' && parsedURL.protocol !== 'https:') { + throw new TypeError(`Only http & https urls are allowed, received ${parsedURL.protocol}`) + } + + Object.defineProperty(globalThis, globalOrigin, { + value: parsedURL, + writable: true, + enumerable: false, + configurable: false + }) +} + +module.exports = { + getGlobalOrigin, + setGlobalOrigin +} diff --git a/lib/web/fetch/headers.js b/lib/web/fetch/headers.js new file mode 100644 index 0000000..d782d2a --- /dev/null +++ b/lib/web/fetch/headers.js @@ -0,0 +1,719 @@ +// https://github.com/Ethan-Arrowood/undici-fetch + +'use strict' + +const { kConstruct } = require('../../core/symbols') +const { kEnumerableProperty } = require('../../core/util') +const { + iteratorMixin, + isValidHeaderName, + isValidHeaderValue +} = require('./util') +const { webidl } = require('./webidl') +const assert = require('node:assert') +const util = require('node:util') + +/** + * @param {number} code + * @returns {code is (0x0a | 0x0d | 0x09 | 0x20)} + */ +function isHTTPWhiteSpaceCharCode (code) { + return code === 0x0a || code === 0x0d || code === 0x09 || code === 0x20 +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-header-value-normalize + * @param {string} potentialValue + * @returns {string} + */ +function headerValueNormalize (potentialValue) { + // To normalize a byte sequence potentialValue, remove + // any leading and trailing HTTP whitespace bytes from + // potentialValue. + let i = 0; let j = potentialValue.length + + while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(j - 1))) --j + while (j > i && isHTTPWhiteSpaceCharCode(potentialValue.charCodeAt(i))) ++i + + return i === 0 && j === potentialValue.length ? potentialValue : potentialValue.substring(i, j) +} + +/** + * @param {Headers} headers + * @param {Array|Object} object + */ +function fill (headers, object) { + // To fill a Headers object headers with a given object object, run these steps: + + // 1. If object is a sequence, then for each header in object: + // Note: webidl conversion to array has already been done. + if (Array.isArray(object)) { + for (let i = 0; i < object.length; ++i) { + const header = object[i] + // 1. If header does not contain exactly two items, then throw a TypeError. + if (header.length !== 2) { + throw webidl.errors.exception({ + header: 'Headers constructor', + message: `expected name/value pair to be length 2, found ${header.length}.` + }) + } + + // 2. Append (header’s first item, header’s second item) to headers. + appendHeader(headers, header[0], header[1]) + } + } else if (typeof object === 'object' && object !== null) { + // Note: null should throw + + // 2. Otherwise, object is a record, then for each key → value in object, + // append (key, value) to headers + const keys = Object.keys(object) + for (let i = 0; i < keys.length; ++i) { + appendHeader(headers, keys[i], object[keys[i]]) + } + } else { + throw webidl.errors.conversionFailed({ + prefix: 'Headers constructor', + argument: 'Argument 1', + types: ['sequence>', 'record'] + }) + } +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-headers-append + * @param {Headers} headers + * @param {string} name + * @param {string} value + */ +function appendHeader (headers, name, value) { + // 1. Normalize value. + value = headerValueNormalize(value) + + // 2. If name is not a header name or value is not a + // header value, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.append', + value: name, + type: 'header name' + }) + } else if (!isValidHeaderValue(value)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.append', + value, + type: 'header value' + }) + } + + // 3. If headers’s guard is "immutable", then throw a TypeError. + // 4. Otherwise, if headers’s guard is "request" and name is a + // forbidden header name, return. + // 5. Otherwise, if headers’s guard is "request-no-cors": + // TODO + // Note: undici does not implement forbidden header names + if (getHeadersGuard(headers) === 'immutable') { + throw new TypeError('immutable') + } + + // 6. Otherwise, if headers’s guard is "response" and name is a + // forbidden response-header name, return. + + // 7. Append (name, value) to headers’s header list. + return getHeadersList(headers).append(name, value, false) + + // 8. If headers’s guard is "request-no-cors", then remove + // privileged no-CORS request headers from headers +} + +// https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine +/** + * @param {Headers} target + */ +function headersListSortAndCombine (target) { + const headersList = getHeadersList(target) + + if (!headersList) { + return [] + } + + if (headersList.sortedMap) { + return headersList.sortedMap + } + + // 1. Let headers be an empty list of headers with the key being the name + // and value the value. + const headers = [] + + // 2. Let names be the result of convert header names to a sorted-lowercase + // set with all the names of the headers in list. + const names = headersList.toSortedArray() + + const cookies = headersList.cookies + + // fast-path + if (cookies === null || cookies.length === 1) { + // Note: The non-null assertion of value has already been done by `HeadersList#toSortedArray` + return (headersList.sortedMap = names) + } + + // 3. For each name of names: + for (let i = 0; i < names.length; ++i) { + const { 0: name, 1: value } = names[i] + // 1. If name is `set-cookie`, then: + if (name === 'set-cookie') { + // 1. Let values be a list of all values of headers in list whose name + // is a byte-case-insensitive match for name, in order. + + // 2. For each value of values: + // 1. Append (name, value) to headers. + for (let j = 0; j < cookies.length; ++j) { + headers.push([name, cookies[j]]) + } + } else { + // 2. Otherwise: + + // 1. Let value be the result of getting name from list. + + // 2. Assert: value is non-null. + // Note: This operation was done by `HeadersList#toSortedArray`. + + // 3. Append (name, value) to headers. + headers.push([name, value]) + } + } + + // 4. Return headers. + return (headersList.sortedMap = headers) +} + +function compareHeaderName (a, b) { + return a[0] < b[0] ? -1 : 1 +} + +class HeadersList { + /** @type {[string, string][]|null} */ + cookies = null + + sortedMap + headersMap + + constructor (init) { + if (init instanceof HeadersList) { + this.headersMap = new Map(init.headersMap) + this.sortedMap = init.sortedMap + this.cookies = init.cookies === null ? null : [...init.cookies] + } else { + this.headersMap = new Map(init) + this.sortedMap = null + } + } + + /** + * @see https://fetch.spec.whatwg.org/#header-list-contains + * @param {string} name + * @param {boolean} isLowerCase + */ + contains (name, isLowerCase) { + // A header list list contains a header name name if list + // contains a header whose name is a byte-case-insensitive + // match for name. + + return this.headersMap.has(isLowerCase ? name : name.toLowerCase()) + } + + clear () { + this.headersMap.clear() + this.sortedMap = null + this.cookies = null + } + + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-append + * @param {string} name + * @param {string} value + * @param {boolean} isLowerCase + */ + append (name, value, isLowerCase) { + this.sortedMap = null + + // 1. If list contains name, then set name to the first such + // header’s name. + const lowercaseName = isLowerCase ? name : name.toLowerCase() + const exists = this.headersMap.get(lowercaseName) + + // 2. Append (name, value) to list. + if (exists) { + const delimiter = lowercaseName === 'cookie' ? '; ' : ', ' + this.headersMap.set(lowercaseName, { + name: exists.name, + value: `${exists.value}${delimiter}${value}` + }) + } else { + this.headersMap.set(lowercaseName, { name, value }) + } + + if (lowercaseName === 'set-cookie') { + (this.cookies ??= []).push(value) + } + } + + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-set + * @param {string} name + * @param {string} value + * @param {boolean} isLowerCase + */ + set (name, value, isLowerCase) { + this.sortedMap = null + const lowercaseName = isLowerCase ? name : name.toLowerCase() + + if (lowercaseName === 'set-cookie') { + this.cookies = [value] + } + + // 1. If list contains name, then set the value of + // the first such header to value and remove the + // others. + // 2. Otherwise, append header (name, value) to list. + this.headersMap.set(lowercaseName, { name, value }) + } + + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-delete + * @param {string} name + * @param {boolean} isLowerCase + */ + delete (name, isLowerCase) { + this.sortedMap = null + if (!isLowerCase) name = name.toLowerCase() + + if (name === 'set-cookie') { + this.cookies = null + } + + this.headersMap.delete(name) + } + + /** + * @see https://fetch.spec.whatwg.org/#concept-header-list-get + * @param {string} name + * @param {boolean} isLowerCase + * @returns {string | null} + */ + get (name, isLowerCase) { + // 1. If list does not contain name, then return null. + // 2. Return the values of all headers in list whose name + // is a byte-case-insensitive match for name, + // separated from each other by 0x2C 0x20, in order. + return this.headersMap.get(isLowerCase ? name : name.toLowerCase())?.value ?? null + } + + * [Symbol.iterator] () { + // use the lowercased name + for (const { 0: name, 1: { value } } of this.headersMap) { + yield [name, value] + } + } + + get entries () { + const headers = {} + + if (this.headersMap.size !== 0) { + for (const { name, value } of this.headersMap.values()) { + headers[name] = value + } + } + + return headers + } + + rawValues () { + return this.headersMap.values() + } + + get entriesList () { + const headers = [] + + if (this.headersMap.size !== 0) { + for (const { 0: lowerName, 1: { name, value } } of this.headersMap) { + if (lowerName === 'set-cookie') { + for (const cookie of this.cookies) { + headers.push([name, cookie]) + } + } else { + headers.push([name, value]) + } + } + } + + return headers + } + + // https://fetch.spec.whatwg.org/#convert-header-names-to-a-sorted-lowercase-set + toSortedArray () { + const size = this.headersMap.size + const array = new Array(size) + // In most cases, you will use the fast-path. + // fast-path: Use binary insertion sort for small arrays. + if (size <= 32) { + if (size === 0) { + // If empty, it is an empty array. To avoid the first index assignment. + return array + } + // Improve performance by unrolling loop and avoiding double-loop. + // Double-loop-less version of the binary insertion sort. + const iterator = this.headersMap[Symbol.iterator]() + const firstValue = iterator.next().value + // set [name, value] to first index. + array[0] = [firstValue[0], firstValue[1].value] + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + // 3.2.2. Assert: value is non-null. + assert(firstValue[1].value !== null) + for ( + let i = 1, j = 0, right = 0, left = 0, pivot = 0, x, value; + i < size; + ++i + ) { + // get next value + value = iterator.next().value + // set [name, value] to current index. + x = array[i] = [value[0], value[1].value] + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + // 3.2.2. Assert: value is non-null. + assert(x[1] !== null) + left = 0 + right = i + // binary search + while (left < right) { + // middle index + pivot = left + ((right - left) >> 1) + // compare header name + if (array[pivot][0] <= x[0]) { + left = pivot + 1 + } else { + right = pivot + } + } + if (i !== pivot) { + j = i + while (j > left) { + array[j] = array[--j] + } + array[left] = x + } + } + /* c8 ignore next 4 */ + if (!iterator.next().done) { + // This is for debugging and will never be called. + throw new TypeError('Unreachable') + } + return array + } else { + // This case would be a rare occurrence. + // slow-path: fallback + let i = 0 + for (const { 0: name, 1: { value } } of this.headersMap) { + array[i++] = [name, value] + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + // 3.2.2. Assert: value is non-null. + assert(value !== null) + } + return array.sort(compareHeaderName) + } + } +} + +// https://fetch.spec.whatwg.org/#headers-class +class Headers { + #guard + /** + * @type {HeadersList} + */ + #headersList + + /** + * @param {HeadersInit|Symbol} [init] + * @returns + */ + constructor (init = undefined) { + webidl.util.markAsUncloneable(this) + + if (init === kConstruct) { + return + } + + this.#headersList = new HeadersList() + + // The new Headers(init) constructor steps are: + + // 1. Set this’s guard to "none". + this.#guard = 'none' + + // 2. If init is given, then fill this with init. + if (init !== undefined) { + init = webidl.converters.HeadersInit(init, 'Headers constructor', 'init') + fill(this, init) + } + } + + // https://fetch.spec.whatwg.org/#dom-headers-append + append (name, value) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 2, 'Headers.append') + + const prefix = 'Headers.append' + name = webidl.converters.ByteString(name, prefix, 'name') + value = webidl.converters.ByteString(value, prefix, 'value') + + return appendHeader(this, name, value) + } + + // https://fetch.spec.whatwg.org/#dom-headers-delete + delete (name) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, 'Headers.delete') + + const prefix = 'Headers.delete' + name = webidl.converters.ByteString(name, prefix, 'name') + + // 1. If name is not a header name, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix: 'Headers.delete', + value: name, + type: 'header name' + }) + } + + // 2. If this’s guard is "immutable", then throw a TypeError. + // 3. Otherwise, if this’s guard is "request" and name is a + // forbidden header name, return. + // 4. Otherwise, if this’s guard is "request-no-cors", name + // is not a no-CORS-safelisted request-header name, and + // name is not a privileged no-CORS request-header name, + // return. + // 5. Otherwise, if this’s guard is "response" and name is + // a forbidden response-header name, return. + // Note: undici does not implement forbidden header names + if (this.#guard === 'immutable') { + throw new TypeError('immutable') + } + + // 6. If this’s header list does not contain name, then + // return. + if (!this.#headersList.contains(name, false)) { + return + } + + // 7. Delete name from this’s header list. + // 8. If this’s guard is "request-no-cors", then remove + // privileged no-CORS request headers from this. + this.#headersList.delete(name, false) + } + + // https://fetch.spec.whatwg.org/#dom-headers-get + get (name) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, 'Headers.get') + + const prefix = 'Headers.get' + name = webidl.converters.ByteString(name, prefix, 'name') + + // 1. If name is not a header name, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix, + value: name, + type: 'header name' + }) + } + + // 2. Return the result of getting name from this’s header + // list. + return this.#headersList.get(name, false) + } + + // https://fetch.spec.whatwg.org/#dom-headers-has + has (name) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 1, 'Headers.has') + + const prefix = 'Headers.has' + name = webidl.converters.ByteString(name, prefix, 'name') + + // 1. If name is not a header name, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix, + value: name, + type: 'header name' + }) + } + + // 2. Return true if this’s header list contains name; + // otherwise false. + return this.#headersList.contains(name, false) + } + + // https://fetch.spec.whatwg.org/#dom-headers-set + set (name, value) { + webidl.brandCheck(this, Headers) + + webidl.argumentLengthCheck(arguments, 2, 'Headers.set') + + const prefix = 'Headers.set' + name = webidl.converters.ByteString(name, prefix, 'name') + value = webidl.converters.ByteString(value, prefix, 'value') + + // 1. Normalize value. + value = headerValueNormalize(value) + + // 2. If name is not a header name or value is not a + // header value, then throw a TypeError. + if (!isValidHeaderName(name)) { + throw webidl.errors.invalidArgument({ + prefix, + value: name, + type: 'header name' + }) + } else if (!isValidHeaderValue(value)) { + throw webidl.errors.invalidArgument({ + prefix, + value, + type: 'header value' + }) + } + + // 3. If this’s guard is "immutable", then throw a TypeError. + // 4. Otherwise, if this’s guard is "request" and name is a + // forbidden header name, return. + // 5. Otherwise, if this’s guard is "request-no-cors" and + // name/value is not a no-CORS-safelisted request-header, + // return. + // 6. Otherwise, if this’s guard is "response" and name is a + // forbidden response-header name, return. + // Note: undici does not implement forbidden header names + if (this.#guard === 'immutable') { + throw new TypeError('immutable') + } + + // 7. Set (name, value) in this’s header list. + // 8. If this’s guard is "request-no-cors", then remove + // privileged no-CORS request headers from this + this.#headersList.set(name, value, false) + } + + // https://fetch.spec.whatwg.org/#dom-headers-getsetcookie + getSetCookie () { + webidl.brandCheck(this, Headers) + + // 1. If this’s header list does not contain `Set-Cookie`, then return « ». + // 2. Return the values of all headers in this’s header list whose name is + // a byte-case-insensitive match for `Set-Cookie`, in order. + + const list = this.#headersList.cookies + + if (list) { + return [...list] + } + + return [] + } + + [util.inspect.custom] (depth, options) { + options.depth ??= depth + + return `Headers ${util.formatWithOptions(options, this.#headersList.entries)}` + } + + static getHeadersGuard (o) { + return o.#guard + } + + static setHeadersGuard (o, guard) { + o.#guard = guard + } + + /** + * @param {Headers} o + */ + static getHeadersList (o) { + return o.#headersList + } + + /** + * @param {Headers} target + * @param {HeadersList} list + */ + static setHeadersList (target, list) { + target.#headersList = list + } +} + +const { getHeadersGuard, setHeadersGuard, getHeadersList, setHeadersList } = Headers +Reflect.deleteProperty(Headers, 'getHeadersGuard') +Reflect.deleteProperty(Headers, 'setHeadersGuard') +Reflect.deleteProperty(Headers, 'getHeadersList') +Reflect.deleteProperty(Headers, 'setHeadersList') + +iteratorMixin('Headers', Headers, headersListSortAndCombine, 0, 1) + +Object.defineProperties(Headers.prototype, { + append: kEnumerableProperty, + delete: kEnumerableProperty, + get: kEnumerableProperty, + has: kEnumerableProperty, + set: kEnumerableProperty, + getSetCookie: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'Headers', + configurable: true + }, + [util.inspect.custom]: { + enumerable: false + } +}) + +webidl.converters.HeadersInit = function (V, prefix, argument) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT) { + const iterator = Reflect.get(V, Symbol.iterator) + + // A work-around to ensure we send the properly-cased Headers when V is a Headers object. + // Read https://github.com/nodejs/undici/pull/3159#issuecomment-2075537226 before touching, please. + if (!util.types.isProxy(V) && iterator === Headers.prototype.entries) { // Headers object + try { + return getHeadersList(V).entriesList + } catch { + // fall-through + } + } + + if (typeof iterator === 'function') { + return webidl.converters['sequence>'](V, prefix, argument, iterator.bind(V)) + } + + return webidl.converters['record'](V, prefix, argument) + } + + throw webidl.errors.conversionFailed({ + prefix: 'Headers constructor', + argument: 'Argument 1', + types: ['sequence>', 'record'] + }) +} + +module.exports = { + fill, + // for test. + compareHeaderName, + Headers, + HeadersList, + getHeadersGuard, + setHeadersGuard, + setHeadersList, + getHeadersList +} diff --git a/lib/web/fetch/index.js b/lib/web/fetch/index.js new file mode 100644 index 0000000..38bcfd3 --- /dev/null +++ b/lib/web/fetch/index.js @@ -0,0 +1,2257 @@ +// https://github.com/Ethan-Arrowood/undici-fetch + +'use strict' + +const { + makeNetworkError, + makeAppropriateNetworkError, + filterResponse, + makeResponse, + fromInnerResponse, + getResponseState +} = require('./response') +const { HeadersList } = require('./headers') +const { Request, cloneRequest, getRequestDispatcher, getRequestState } = require('./request') +const zlib = require('node:zlib') +const { + bytesMatch, + makePolicyContainer, + clonePolicyContainer, + requestBadPort, + TAOCheck, + appendRequestOriginHeader, + responseLocationURL, + requestCurrentURL, + setRequestReferrerPolicyOnRedirect, + tryUpgradeRequestToAPotentiallyTrustworthyURL, + createOpaqueTimingInfo, + appendFetchMetadata, + corsCheck, + crossOriginResourcePolicyCheck, + determineRequestsReferrer, + coarsenedSharedCurrentTime, + createDeferredPromise, + sameOrigin, + isCancelled, + isAborted, + isErrorLike, + fullyReadBody, + readableStreamClose, + isomorphicEncode, + urlIsLocal, + urlIsHttpHttpsScheme, + urlHasHttpsScheme, + clampAndCoarsenConnectionTimingInfo, + simpleRangeHeaderValue, + buildContentRange, + createInflate, + extractMimeType +} = require('./util') +const assert = require('node:assert') +const { safelyExtractBody, extractBody } = require('./body') +const { + redirectStatusSet, + nullBodyStatus, + safeMethodsSet, + requestBodyHeader, + subresourceSet +} = require('./constants') +const EE = require('node:events') +const { Readable, pipeline, finished, isErrored, isReadable } = require('node:stream') +const { addAbortListener, bufferToLowerCasedHeaderName } = require('../../core/util') +const { dataURLProcessor, serializeAMimeType, minimizeSupportedMimeType } = require('./data-url') +const { getGlobalDispatcher } = require('../../global') +const { webidl } = require('./webidl') +const { STATUS_CODES } = require('node:http') +const GET_OR_HEAD = ['GET', 'HEAD'] + +const defaultUserAgent = typeof __UNDICI_IS_NODE__ !== 'undefined' || typeof esbuildDetection !== 'undefined' + ? 'node' + : 'undici' + +/** @type {import('buffer').resolveObjectURL} */ +let resolveObjectURL + +class Fetch extends EE { + constructor (dispatcher) { + super() + + this.dispatcher = dispatcher + this.connection = null + this.dump = false + this.state = 'ongoing' + } + + terminate (reason) { + if (this.state !== 'ongoing') { + return + } + + this.state = 'terminated' + this.connection?.destroy(reason) + this.emit('terminated', reason) + } + + // https://fetch.spec.whatwg.org/#fetch-controller-abort + abort (error) { + if (this.state !== 'ongoing') { + return + } + + // 1. Set controller’s state to "aborted". + this.state = 'aborted' + + // 2. Let fallbackError be an "AbortError" DOMException. + // 3. Set error to fallbackError if it is not given. + if (!error) { + error = new DOMException('The operation was aborted.', 'AbortError') + } + + // 4. Let serializedError be StructuredSerialize(error). + // If that threw an exception, catch it, and let + // serializedError be StructuredSerialize(fallbackError). + + // 5. Set controller’s serialized abort reason to serializedError. + this.serializedAbortReason = error + + this.connection?.destroy(error) + this.emit('terminated', error) + } +} + +function handleFetchDone (response) { + finalizeAndReportTiming(response, 'fetch') +} + +// https://fetch.spec.whatwg.org/#fetch-method +function fetch (input, init = undefined) { + webidl.argumentLengthCheck(arguments, 1, 'globalThis.fetch') + + // 1. Let p be a new promise. + let p = createDeferredPromise() + + // 2. Let requestObject be the result of invoking the initial value of + // Request as constructor with input and init as arguments. If this throws + // an exception, reject p with it and return p. + let requestObject + + try { + requestObject = new Request(input, init) + } catch (e) { + p.reject(e) + return p.promise + } + + // 3. Let request be requestObject’s request. + const request = getRequestState(requestObject) + + // 4. If requestObject’s signal’s aborted flag is set, then: + if (requestObject.signal.aborted) { + // 1. Abort the fetch() call with p, request, null, and + // requestObject’s signal’s abort reason. + abortFetch(p, request, null, requestObject.signal.reason) + + // 2. Return p. + return p.promise + } + + // 5. Let globalObject be request’s client’s global object. + const globalObject = request.client.globalObject + + // 6. If globalObject is a ServiceWorkerGlobalScope object, then set + // request’s service-workers mode to "none". + if (globalObject?.constructor?.name === 'ServiceWorkerGlobalScope') { + request.serviceWorkers = 'none' + } + + // 7. Let responseObject be null. + let responseObject = null + + // 8. Let relevantRealm be this’s relevant Realm. + + // 9. Let locallyAborted be false. + let locallyAborted = false + + // 10. Let controller be null. + let controller = null + + // 11. Add the following abort steps to requestObject’s signal: + addAbortListener( + requestObject.signal, + () => { + // 1. Set locallyAborted to true. + locallyAborted = true + + // 2. Assert: controller is non-null. + assert(controller != null) + + // 3. Abort controller with requestObject’s signal’s abort reason. + controller.abort(requestObject.signal.reason) + + const realResponse = responseObject?.deref() + + // 4. Abort the fetch() call with p, request, responseObject, + // and requestObject’s signal’s abort reason. + abortFetch(p, request, realResponse, requestObject.signal.reason) + } + ) + + // 12. Let handleFetchDone given response response be to finalize and + // report timing with response, globalObject, and "fetch". + // see function handleFetchDone + + // 13. Set controller to the result of calling fetch given request, + // with processResponseEndOfBody set to handleFetchDone, and processResponse + // given response being these substeps: + + const processResponse = (response) => { + // 1. If locallyAborted is true, terminate these substeps. + if (locallyAborted) { + return + } + + // 2. If response’s aborted flag is set, then: + if (response.aborted) { + // 1. Let deserializedError be the result of deserialize a serialized + // abort reason given controller’s serialized abort reason and + // relevantRealm. + + // 2. Abort the fetch() call with p, request, responseObject, and + // deserializedError. + + abortFetch(p, request, responseObject, controller.serializedAbortReason) + return + } + + // 3. If response is a network error, then reject p with a TypeError + // and terminate these substeps. + if (response.type === 'error') { + p.reject(new TypeError('fetch failed', { cause: response.error })) + return + } + + // 4. Set responseObject to the result of creating a Response object, + // given response, "immutable", and relevantRealm. + responseObject = new WeakRef(fromInnerResponse(response, 'immutable')) + + // 5. Resolve p with responseObject. + p.resolve(responseObject.deref()) + p = null + } + + controller = fetching({ + request, + processResponseEndOfBody: handleFetchDone, + processResponse, + dispatcher: getRequestDispatcher(requestObject) // undici + }) + + // 14. Return p. + return p.promise +} + +// https://fetch.spec.whatwg.org/#finalize-and-report-timing +function finalizeAndReportTiming (response, initiatorType = 'other') { + // 1. If response is an aborted network error, then return. + if (response.type === 'error' && response.aborted) { + return + } + + // 2. If response’s URL list is null or empty, then return. + if (!response.urlList?.length) { + return + } + + // 3. Let originalURL be response’s URL list[0]. + const originalURL = response.urlList[0] + + // 4. Let timingInfo be response’s timing info. + let timingInfo = response.timingInfo + + // 5. Let cacheState be response’s cache state. + let cacheState = response.cacheState + + // 6. If originalURL’s scheme is not an HTTP(S) scheme, then return. + if (!urlIsHttpHttpsScheme(originalURL)) { + return + } + + // 7. If timingInfo is null, then return. + if (timingInfo === null) { + return + } + + // 8. If response’s timing allow passed flag is not set, then: + if (!response.timingAllowPassed) { + // 1. Set timingInfo to a the result of creating an opaque timing info for timingInfo. + timingInfo = createOpaqueTimingInfo({ + startTime: timingInfo.startTime + }) + + // 2. Set cacheState to the empty string. + cacheState = '' + } + + // 9. Set timingInfo’s end time to the coarsened shared current time + // given global’s relevant settings object’s cross-origin isolated + // capability. + // TODO: given global’s relevant settings object’s cross-origin isolated + // capability? + timingInfo.endTime = coarsenedSharedCurrentTime() + + // 10. Set response’s timing info to timingInfo. + response.timingInfo = timingInfo + + // 11. Mark resource timing for timingInfo, originalURL, initiatorType, + // global, and cacheState. + markResourceTiming( + timingInfo, + originalURL.href, + initiatorType, + globalThis, + cacheState + ) +} + +// https://w3c.github.io/resource-timing/#dfn-mark-resource-timing +const markResourceTiming = performance.markResourceTiming + +// https://fetch.spec.whatwg.org/#abort-fetch +function abortFetch (p, request, responseObject, error) { + // 1. Reject promise with error. + if (p) { + // We might have already resolved the promise at this stage + p.reject(error) + } + + // 2. If request’s body is not null and is readable, then cancel request’s + // body with error. + if (request.body?.stream != null && isReadable(request.body.stream)) { + request.body.stream.cancel(error).catch((err) => { + if (err.code === 'ERR_INVALID_STATE') { + // Node bug? + return + } + throw err + }) + } + + // 3. If responseObject is null, then return. + if (responseObject == null) { + return + } + + // 4. Let response be responseObject’s response. + const response = getResponseState(responseObject) + + // 5. If response’s body is not null and is readable, then error response’s + // body with error. + if (response.body?.stream != null && isReadable(response.body.stream)) { + response.body.stream.cancel(error).catch((err) => { + if (err.code === 'ERR_INVALID_STATE') { + // Node bug? + return + } + throw err + }) + } +} + +// https://fetch.spec.whatwg.org/#fetching +function fetching ({ + request, + processRequestBodyChunkLength, + processRequestEndOfBody, + processResponse, + processResponseEndOfBody, + processResponseConsumeBody, + useParallelQueue = false, + dispatcher = getGlobalDispatcher() // undici +}) { + // Ensure that the dispatcher is set accordingly + assert(dispatcher) + + // 1. Let taskDestination be null. + let taskDestination = null + + // 2. Let crossOriginIsolatedCapability be false. + let crossOriginIsolatedCapability = false + + // 3. If request’s client is non-null, then: + if (request.client != null) { + // 1. Set taskDestination to request’s client’s global object. + taskDestination = request.client.globalObject + + // 2. Set crossOriginIsolatedCapability to request’s client’s cross-origin + // isolated capability. + crossOriginIsolatedCapability = + request.client.crossOriginIsolatedCapability + } + + // 4. If useParallelQueue is true, then set taskDestination to the result of + // starting a new parallel queue. + // TODO + + // 5. Let timingInfo be a new fetch timing info whose start time and + // post-redirect start time are the coarsened shared current time given + // crossOriginIsolatedCapability. + const currentTime = coarsenedSharedCurrentTime(crossOriginIsolatedCapability) + const timingInfo = createOpaqueTimingInfo({ + startTime: currentTime + }) + + // 6. Let fetchParams be a new fetch params whose + // request is request, + // timing info is timingInfo, + // process request body chunk length is processRequestBodyChunkLength, + // process request end-of-body is processRequestEndOfBody, + // process response is processResponse, + // process response consume body is processResponseConsumeBody, + // process response end-of-body is processResponseEndOfBody, + // task destination is taskDestination, + // and cross-origin isolated capability is crossOriginIsolatedCapability. + const fetchParams = { + controller: new Fetch(dispatcher), + request, + timingInfo, + processRequestBodyChunkLength, + processRequestEndOfBody, + processResponse, + processResponseConsumeBody, + processResponseEndOfBody, + taskDestination, + crossOriginIsolatedCapability + } + + // 7. If request’s body is a byte sequence, then set request’s body to + // request’s body as a body. + // NOTE: Since fetching is only called from fetch, body should already be + // extracted. + assert(!request.body || request.body.stream) + + // 8. If request’s window is "client", then set request’s window to request’s + // client, if request’s client’s global object is a Window object; otherwise + // "no-window". + if (request.window === 'client') { + // TODO: What if request.client is null? + request.window = + request.client?.globalObject?.constructor?.name === 'Window' + ? request.client + : 'no-window' + } + + // 9. If request’s origin is "client", then set request’s origin to request’s + // client’s origin. + if (request.origin === 'client') { + request.origin = request.client.origin + } + + // 10. If all of the following conditions are true: + // TODO + + // 11. If request’s policy container is "client", then: + if (request.policyContainer === 'client') { + // 1. If request’s client is non-null, then set request’s policy + // container to a clone of request’s client’s policy container. [HTML] + if (request.client != null) { + request.policyContainer = clonePolicyContainer( + request.client.policyContainer + ) + } else { + // 2. Otherwise, set request’s policy container to a new policy + // container. + request.policyContainer = makePolicyContainer() + } + } + + // 12. If request’s header list does not contain `Accept`, then: + if (!request.headersList.contains('accept', true)) { + // 1. Let value be `*/*`. + const value = '*/*' + + // 2. A user agent should set value to the first matching statement, if + // any, switching on request’s destination: + // "document" + // "frame" + // "iframe" + // `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8` + // "image" + // `image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5` + // "style" + // `text/css,*/*;q=0.1` + // TODO + + // 3. Append `Accept`/value to request’s header list. + request.headersList.append('accept', value, true) + } + + // 13. If request’s header list does not contain `Accept-Language`, then + // user agents should append `Accept-Language`/an appropriate value to + // request’s header list. + if (!request.headersList.contains('accept-language', true)) { + request.headersList.append('accept-language', '*', true) + } + + // 14. If request’s priority is null, then use request’s initiator and + // destination appropriately in setting request’s priority to a + // user-agent-defined object. + if (request.priority === null) { + // TODO + } + + // 15. If request is a subresource request, then: + if (subresourceSet.has(request.destination)) { + // TODO + } + + // 16. Run main fetch given fetchParams. + mainFetch(fetchParams) + .catch(err => { + fetchParams.controller.terminate(err) + }) + + // 17. Return fetchParam's controller + return fetchParams.controller +} + +// https://fetch.spec.whatwg.org/#concept-main-fetch +async function mainFetch (fetchParams, recursive = false) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let response be null. + let response = null + + // 3. If request’s local-URLs-only flag is set and request’s current URL is + // not local, then set response to a network error. + if (request.localURLsOnly && !urlIsLocal(requestCurrentURL(request))) { + response = makeNetworkError('local URLs only') + } + + // 4. Run report Content Security Policy violations for request. + // TODO + + // 5. Upgrade request to a potentially trustworthy URL, if appropriate. + tryUpgradeRequestToAPotentiallyTrustworthyURL(request) + + // 6. If should request be blocked due to a bad port, should fetching request + // be blocked as mixed content, or should request be blocked by Content + // Security Policy returns blocked, then set response to a network error. + if (requestBadPort(request) === 'blocked') { + response = makeNetworkError('bad port') + } + // TODO: should fetching request be blocked as mixed content? + // TODO: should request be blocked by Content Security Policy? + + // 7. If request’s referrer policy is the empty string, then set request’s + // referrer policy to request’s policy container’s referrer policy. + if (request.referrerPolicy === '') { + request.referrerPolicy = request.policyContainer.referrerPolicy + } + + // 8. If request’s referrer is not "no-referrer", then set request’s + // referrer to the result of invoking determine request’s referrer. + if (request.referrer !== 'no-referrer') { + request.referrer = determineRequestsReferrer(request) + } + + // 9. Set request’s current URL’s scheme to "https" if all of the following + // conditions are true: + // - request’s current URL’s scheme is "http" + // - request’s current URL’s host is a domain + // - Matching request’s current URL’s host per Known HSTS Host Domain Name + // Matching results in either a superdomain match with an asserted + // includeSubDomains directive or a congruent match (with or without an + // asserted includeSubDomains directive). [HSTS] + // TODO + + // 10. If recursive is false, then run the remaining steps in parallel. + // TODO + + // 11. If response is null, then set response to the result of running + // the steps corresponding to the first matching statement: + if (response === null) { + const currentURL = requestCurrentURL(request) + if ( + // - request’s current URL’s origin is same origin with request’s origin, + // and request’s response tainting is "basic" + (sameOrigin(currentURL, request.url) && request.responseTainting === 'basic') || + // request’s current URL’s scheme is "data" + (currentURL.protocol === 'data:') || + // - request’s mode is "navigate" or "websocket" + (request.mode === 'navigate' || request.mode === 'websocket') + ) { + // 1. Set request’s response tainting to "basic". + request.responseTainting = 'basic' + + // 2. Return the result of running scheme fetch given fetchParams. + response = await schemeFetch(fetchParams) + + // request’s mode is "same-origin" + } else if (request.mode === 'same-origin') { + // 1. Return a network error. + response = makeNetworkError('request mode cannot be "same-origin"') + + // request’s mode is "no-cors" + } else if (request.mode === 'no-cors') { + // 1. If request’s redirect mode is not "follow", then return a network + // error. + if (request.redirect !== 'follow') { + response = makeNetworkError( + 'redirect mode cannot be "follow" for "no-cors" request' + ) + } else { + // 2. Set request’s response tainting to "opaque". + request.responseTainting = 'opaque' + + // 3. Return the result of running scheme fetch given fetchParams. + response = await schemeFetch(fetchParams) + } + // request’s current URL’s scheme is not an HTTP(S) scheme + } else if (!urlIsHttpHttpsScheme(requestCurrentURL(request))) { + // Return a network error. + response = makeNetworkError('URL scheme must be a HTTP(S) scheme') + + // - request’s use-CORS-preflight flag is set + // - request’s unsafe-request flag is set and either request’s method is + // not a CORS-safelisted method or CORS-unsafe request-header names with + // request’s header list is not empty + // 1. Set request’s response tainting to "cors". + // 2. Let corsWithPreflightResponse be the result of running HTTP fetch + // given fetchParams and true. + // 3. If corsWithPreflightResponse is a network error, then clear cache + // entries using request. + // 4. Return corsWithPreflightResponse. + // TODO + + // Otherwise + } else { + // 1. Set request’s response tainting to "cors". + request.responseTainting = 'cors' + + // 2. Return the result of running HTTP fetch given fetchParams. + response = await httpFetch(fetchParams) + } + } + + // 12. If recursive is true, then return response. + if (recursive) { + return response + } + + // 13. If response is not a network error and response is not a filtered + // response, then: + if (response.status !== 0 && !response.internalResponse) { + // If request’s response tainting is "cors", then: + if (request.responseTainting === 'cors') { + // 1. Let headerNames be the result of extracting header list values + // given `Access-Control-Expose-Headers` and response’s header list. + // TODO + // 2. If request’s credentials mode is not "include" and headerNames + // contains `*`, then set response’s CORS-exposed header-name list to + // all unique header names in response’s header list. + // TODO + // 3. Otherwise, if headerNames is not null or failure, then set + // response’s CORS-exposed header-name list to headerNames. + // TODO + } + + // Set response to the following filtered response with response as its + // internal response, depending on request’s response tainting: + if (request.responseTainting === 'basic') { + response = filterResponse(response, 'basic') + } else if (request.responseTainting === 'cors') { + response = filterResponse(response, 'cors') + } else if (request.responseTainting === 'opaque') { + response = filterResponse(response, 'opaque') + } else { + assert(false) + } + } + + // 14. Let internalResponse be response, if response is a network error, + // and response’s internal response otherwise. + let internalResponse = + response.status === 0 ? response : response.internalResponse + + // 15. If internalResponse’s URL list is empty, then set it to a clone of + // request’s URL list. + if (internalResponse.urlList.length === 0) { + internalResponse.urlList.push(...request.urlList) + } + + // 16. If request’s timing allow failed flag is unset, then set + // internalResponse’s timing allow passed flag. + if (!request.timingAllowFailed) { + response.timingAllowPassed = true + } + + // 17. If response is not a network error and any of the following returns + // blocked + // - should internalResponse to request be blocked as mixed content + // - should internalResponse to request be blocked by Content Security Policy + // - should internalResponse to request be blocked due to its MIME type + // - should internalResponse to request be blocked due to nosniff + // TODO + + // 18. If response’s type is "opaque", internalResponse’s status is 206, + // internalResponse’s range-requested flag is set, and request’s header + // list does not contain `Range`, then set response and internalResponse + // to a network error. + if ( + response.type === 'opaque' && + internalResponse.status === 206 && + internalResponse.rangeRequested && + !request.headers.contains('range', true) + ) { + response = internalResponse = makeNetworkError() + } + + // 19. If response is not a network error and either request’s method is + // `HEAD` or `CONNECT`, or internalResponse’s status is a null body status, + // set internalResponse’s body to null and disregard any enqueuing toward + // it (if any). + if ( + response.status !== 0 && + (request.method === 'HEAD' || + request.method === 'CONNECT' || + nullBodyStatus.includes(internalResponse.status)) + ) { + internalResponse.body = null + fetchParams.controller.dump = true + } + + // 20. If request’s integrity metadata is not the empty string, then: + if (request.integrity) { + // 1. Let processBodyError be this step: run fetch finale given fetchParams + // and a network error. + const processBodyError = (reason) => + fetchFinale(fetchParams, makeNetworkError(reason)) + + // 2. If request’s response tainting is "opaque", or response’s body is null, + // then run processBodyError and abort these steps. + if (request.responseTainting === 'opaque' || response.body == null) { + processBodyError(response.error) + return + } + + // 3. Let processBody given bytes be these steps: + const processBody = (bytes) => { + // 1. If bytes do not match request’s integrity metadata, + // then run processBodyError and abort these steps. [SRI] + if (!bytesMatch(bytes, request.integrity)) { + processBodyError('integrity mismatch') + return + } + + // 2. Set response’s body to bytes as a body. + response.body = safelyExtractBody(bytes)[0] + + // 3. Run fetch finale given fetchParams and response. + fetchFinale(fetchParams, response) + } + + // 4. Fully read response’s body given processBody and processBodyError. + await fullyReadBody(response.body, processBody, processBodyError) + } else { + // 21. Otherwise, run fetch finale given fetchParams and response. + fetchFinale(fetchParams, response) + } +} + +// https://fetch.spec.whatwg.org/#concept-scheme-fetch +// given a fetch params fetchParams +function schemeFetch (fetchParams) { + // Note: since the connection is destroyed on redirect, which sets fetchParams to a + // cancelled state, we do not want this condition to trigger *unless* there have been + // no redirects. See https://github.com/nodejs/undici/issues/1776 + // 1. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams) && fetchParams.request.redirectCount === 0) { + return Promise.resolve(makeAppropriateNetworkError(fetchParams)) + } + + // 2. Let request be fetchParams’s request. + const { request } = fetchParams + + const { protocol: scheme } = requestCurrentURL(request) + + // 3. Switch on request’s current URL’s scheme and run the associated steps: + switch (scheme) { + case 'about:': { + // If request’s current URL’s path is the string "blank", then return a new response + // whose status message is `OK`, header list is « (`Content-Type`, `text/html;charset=utf-8`) », + // and body is the empty byte sequence as a body. + + // Otherwise, return a network error. + return Promise.resolve(makeNetworkError('about scheme is not supported')) + } + case 'blob:': { + if (!resolveObjectURL) { + resolveObjectURL = require('node:buffer').resolveObjectURL + } + + // 1. Let blobURLEntry be request’s current URL’s blob URL entry. + const blobURLEntry = requestCurrentURL(request) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56 + // Buffer.resolveObjectURL does not ignore URL queries. + if (blobURLEntry.search.length !== 0) { + return Promise.resolve(makeNetworkError('NetworkError when attempting to fetch resource.')) + } + + const blob = resolveObjectURL(blobURLEntry.toString()) + + // 2. If request’s method is not `GET`, blobURLEntry is null, or blobURLEntry’s + // object is not a Blob object, then return a network error. + if (request.method !== 'GET' || !webidl.is.Blob(blob)) { + return Promise.resolve(makeNetworkError('invalid method')) + } + + // 3. Let blob be blobURLEntry’s object. + // Note: done above + + // 4. Let response be a new response. + const response = makeResponse() + + // 5. Let fullLength be blob’s size. + const fullLength = blob.size + + // 6. Let serializedFullLength be fullLength, serialized and isomorphic encoded. + const serializedFullLength = isomorphicEncode(`${fullLength}`) + + // 7. Let type be blob’s type. + const type = blob.type + + // 8. If request’s header list does not contain `Range`: + // 9. Otherwise: + if (!request.headersList.contains('range', true)) { + // 1. Let bodyWithType be the result of safely extracting blob. + // Note: in the FileAPI a blob "object" is a Blob *or* a MediaSource. + // In node, this can only ever be a Blob. Therefore we can safely + // use extractBody directly. + const bodyWithType = extractBody(blob) + + // 2. Set response’s status message to `OK`. + response.statusText = 'OK' + + // 3. Set response’s body to bodyWithType’s body. + response.body = bodyWithType[0] + + // 4. Set response’s header list to « (`Content-Length`, serializedFullLength), (`Content-Type`, type) ». + response.headersList.set('content-length', serializedFullLength, true) + response.headersList.set('content-type', type, true) + } else { + // 1. Set response’s range-requested flag. + response.rangeRequested = true + + // 2. Let rangeHeader be the result of getting `Range` from request’s header list. + const rangeHeader = request.headersList.get('range', true) + + // 3. Let rangeValue be the result of parsing a single range header value given rangeHeader and true. + const rangeValue = simpleRangeHeaderValue(rangeHeader, true) + + // 4. If rangeValue is failure, then return a network error. + if (rangeValue === 'failure') { + return Promise.resolve(makeNetworkError('failed to fetch the data URL')) + } + + // 5. Let (rangeStart, rangeEnd) be rangeValue. + let { rangeStartValue: rangeStart, rangeEndValue: rangeEnd } = rangeValue + + // 6. If rangeStart is null: + // 7. Otherwise: + if (rangeStart === null) { + // 1. Set rangeStart to fullLength − rangeEnd. + rangeStart = fullLength - rangeEnd + + // 2. Set rangeEnd to rangeStart + rangeEnd − 1. + rangeEnd = rangeStart + rangeEnd - 1 + } else { + // 1. If rangeStart is greater than or equal to fullLength, then return a network error. + if (rangeStart >= fullLength) { + return Promise.resolve(makeNetworkError('Range start is greater than the blob\'s size.')) + } + + // 2. If rangeEnd is null or rangeEnd is greater than or equal to fullLength, then set + // rangeEnd to fullLength − 1. + if (rangeEnd === null || rangeEnd >= fullLength) { + rangeEnd = fullLength - 1 + } + } + + // 8. Let slicedBlob be the result of invoking slice blob given blob, rangeStart, + // rangeEnd + 1, and type. + const slicedBlob = blob.slice(rangeStart, rangeEnd, type) + + // 9. Let slicedBodyWithType be the result of safely extracting slicedBlob. + // Note: same reason as mentioned above as to why we use extractBody + const slicedBodyWithType = extractBody(slicedBlob) + + // 10. Set response’s body to slicedBodyWithType’s body. + response.body = slicedBodyWithType[0] + + // 11. Let serializedSlicedLength be slicedBlob’s size, serialized and isomorphic encoded. + const serializedSlicedLength = isomorphicEncode(`${slicedBlob.size}`) + + // 12. Let contentRange be the result of invoking build a content range given rangeStart, + // rangeEnd, and fullLength. + const contentRange = buildContentRange(rangeStart, rangeEnd, fullLength) + + // 13. Set response’s status to 206. + response.status = 206 + + // 14. Set response’s status message to `Partial Content`. + response.statusText = 'Partial Content' + + // 15. Set response’s header list to « (`Content-Length`, serializedSlicedLength), + // (`Content-Type`, type), (`Content-Range`, contentRange) ». + response.headersList.set('content-length', serializedSlicedLength, true) + response.headersList.set('content-type', type, true) + response.headersList.set('content-range', contentRange, true) + } + + // 10. Return response. + return Promise.resolve(response) + } + case 'data:': { + // 1. Let dataURLStruct be the result of running the + // data: URL processor on request’s current URL. + const currentURL = requestCurrentURL(request) + const dataURLStruct = dataURLProcessor(currentURL) + + // 2. If dataURLStruct is failure, then return a + // network error. + if (dataURLStruct === 'failure') { + return Promise.resolve(makeNetworkError('failed to fetch the data URL')) + } + + // 3. Let mimeType be dataURLStruct’s MIME type, serialized. + const mimeType = serializeAMimeType(dataURLStruct.mimeType) + + // 4. Return a response whose status message is `OK`, + // header list is « (`Content-Type`, mimeType) », + // and body is dataURLStruct’s body as a body. + return Promise.resolve(makeResponse({ + statusText: 'OK', + headersList: [ + ['content-type', { name: 'Content-Type', value: mimeType }] + ], + body: safelyExtractBody(dataURLStruct.body)[0] + })) + } + case 'file:': { + // For now, unfortunate as it is, file URLs are left as an exercise for the reader. + // When in doubt, return a network error. + return Promise.resolve(makeNetworkError('not implemented... yet...')) + } + case 'http:': + case 'https:': { + // Return the result of running HTTP fetch given fetchParams. + + return httpFetch(fetchParams) + .catch((err) => makeNetworkError(err)) + } + default: { + return Promise.resolve(makeNetworkError('unknown scheme')) + } + } +} + +// https://fetch.spec.whatwg.org/#finalize-response +function finalizeResponse (fetchParams, response) { + // 1. Set fetchParams’s request’s done flag. + fetchParams.request.done = true + + // 2, If fetchParams’s process response done is not null, then queue a fetch + // task to run fetchParams’s process response done given response, with + // fetchParams’s task destination. + if (fetchParams.processResponseDone != null) { + queueMicrotask(() => fetchParams.processResponseDone(response)) + } +} + +// https://fetch.spec.whatwg.org/#fetch-finale +function fetchFinale (fetchParams, response) { + // 1. Let timingInfo be fetchParams’s timing info. + let timingInfo = fetchParams.timingInfo + + // 2. If response is not a network error and fetchParams’s request’s client is a secure context, + // then set timingInfo’s server-timing headers to the result of getting, decoding, and splitting + // `Server-Timing` from response’s internal response’s header list. + // TODO + + // 3. Let processResponseEndOfBody be the following steps: + const processResponseEndOfBody = () => { + // 1. Let unsafeEndTime be the unsafe shared current time. + const unsafeEndTime = Date.now() // ? + + // 2. If fetchParams’s request’s destination is "document", then set fetchParams’s controller’s + // full timing info to fetchParams’s timing info. + if (fetchParams.request.destination === 'document') { + fetchParams.controller.fullTimingInfo = timingInfo + } + + // 3. Set fetchParams’s controller’s report timing steps to the following steps given a global object global: + fetchParams.controller.reportTimingSteps = () => { + // 1. If fetchParams’s request’s URL’s scheme is not an HTTP(S) scheme, then return. + if (fetchParams.request.url.protocol !== 'https:') { + return + } + + // 2. Set timingInfo’s end time to the relative high resolution time given unsafeEndTime and global. + timingInfo.endTime = unsafeEndTime + + // 3. Let cacheState be response’s cache state. + let cacheState = response.cacheState + + // 4. Let bodyInfo be response’s body info. + const bodyInfo = response.bodyInfo + + // 5. If response’s timing allow passed flag is not set, then set timingInfo to the result of creating an + // opaque timing info for timingInfo and set cacheState to the empty string. + if (!response.timingAllowPassed) { + timingInfo = createOpaqueTimingInfo(timingInfo) + + cacheState = '' + } + + // 6. Let responseStatus be 0. + let responseStatus = 0 + + // 7. If fetchParams’s request’s mode is not "navigate" or response’s has-cross-origin-redirects is false: + if (fetchParams.request.mode !== 'navigator' || !response.hasCrossOriginRedirects) { + // 1. Set responseStatus to response’s status. + responseStatus = response.status + + // 2. Let mimeType be the result of extracting a MIME type from response’s header list. + const mimeType = extractMimeType(response.headersList) + + // 3. If mimeType is not failure, then set bodyInfo’s content type to the result of minimizing a supported MIME type given mimeType. + if (mimeType !== 'failure') { + bodyInfo.contentType = minimizeSupportedMimeType(mimeType) + } + } + + // 8. If fetchParams’s request’s initiator type is non-null, then mark resource timing given timingInfo, + // fetchParams’s request’s URL, fetchParams’s request’s initiator type, global, cacheState, bodyInfo, + // and responseStatus. + if (fetchParams.request.initiatorType != null) { + // TODO: update markresourcetiming + markResourceTiming(timingInfo, fetchParams.request.url.href, fetchParams.request.initiatorType, globalThis, cacheState, bodyInfo, responseStatus) + } + } + + // 4. Let processResponseEndOfBodyTask be the following steps: + const processResponseEndOfBodyTask = () => { + // 1. Set fetchParams’s request’s done flag. + fetchParams.request.done = true + + // 2. If fetchParams’s process response end-of-body is non-null, then run fetchParams’s process + // response end-of-body given response. + if (fetchParams.processResponseEndOfBody != null) { + queueMicrotask(() => fetchParams.processResponseEndOfBody(response)) + } + + // 3. If fetchParams’s request’s initiator type is non-null and fetchParams’s request’s client’s + // global object is fetchParams’s task destination, then run fetchParams’s controller’s report + // timing steps given fetchParams’s request’s client’s global object. + if (fetchParams.request.initiatorType != null) { + fetchParams.controller.reportTimingSteps() + } + } + + // 5. Queue a fetch task to run processResponseEndOfBodyTask with fetchParams’s task destination + queueMicrotask(() => processResponseEndOfBodyTask()) + } + + // 4. If fetchParams’s process response is non-null, then queue a fetch task to run fetchParams’s + // process response given response, with fetchParams’s task destination. + if (fetchParams.processResponse != null) { + queueMicrotask(() => { + fetchParams.processResponse(response) + fetchParams.processResponse = null + }) + } + + // 5. Let internalResponse be response, if response is a network error; otherwise response’s internal response. + const internalResponse = response.type === 'error' ? response : (response.internalResponse ?? response) + + // 6. If internalResponse’s body is null, then run processResponseEndOfBody. + // 7. Otherwise: + if (internalResponse.body == null) { + processResponseEndOfBody() + } else { + // mcollina: all the following steps of the specs are skipped. + // The internal transform stream is not needed. + // See https://github.com/nodejs/undici/pull/3093#issuecomment-2050198541 + + // 1. Let transformStream be a new TransformStream. + // 2. Let identityTransformAlgorithm be an algorithm which, given chunk, enqueues chunk in transformStream. + // 3. Set up transformStream with transformAlgorithm set to identityTransformAlgorithm and flushAlgorithm + // set to processResponseEndOfBody. + // 4. Set internalResponse’s body’s stream to the result of internalResponse’s body’s stream piped through transformStream. + + finished(internalResponse.body.stream, () => { + processResponseEndOfBody() + }) + } +} + +// https://fetch.spec.whatwg.org/#http-fetch +async function httpFetch (fetchParams) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let response be null. + let response = null + + // 3. Let actualResponse be null. + let actualResponse = null + + // 4. Let timingInfo be fetchParams’s timing info. + const timingInfo = fetchParams.timingInfo + + // 5. If request’s service-workers mode is "all", then: + if (request.serviceWorkers === 'all') { + // TODO + } + + // 6. If response is null, then: + if (response === null) { + // 1. If makeCORSPreflight is true and one of these conditions is true: + // TODO + + // 2. If request’s redirect mode is "follow", then set request’s + // service-workers mode to "none". + if (request.redirect === 'follow') { + request.serviceWorkers = 'none' + } + + // 3. Set response and actualResponse to the result of running + // HTTP-network-or-cache fetch given fetchParams. + actualResponse = response = await httpNetworkOrCacheFetch(fetchParams) + + // 4. If request’s response tainting is "cors" and a CORS check + // for request and response returns failure, then return a network error. + if ( + request.responseTainting === 'cors' && + corsCheck(request, response) === 'failure' + ) { + return makeNetworkError('cors failure') + } + + // 5. If the TAO check for request and response returns failure, then set + // request’s timing allow failed flag. + if (TAOCheck(request, response) === 'failure') { + request.timingAllowFailed = true + } + } + + // 7. If either request’s response tainting or response’s type + // is "opaque", and the cross-origin resource policy check with + // request’s origin, request’s client, request’s destination, + // and actualResponse returns blocked, then return a network error. + if ( + (request.responseTainting === 'opaque' || response.type === 'opaque') && + crossOriginResourcePolicyCheck( + request.origin, + request.client, + request.destination, + actualResponse + ) === 'blocked' + ) { + return makeNetworkError('blocked') + } + + // 8. If actualResponse’s status is a redirect status, then: + if (redirectStatusSet.has(actualResponse.status)) { + // 1. If actualResponse’s status is not 303, request’s body is not null, + // and the connection uses HTTP/2, then user agents may, and are even + // encouraged to, transmit an RST_STREAM frame. + // See, https://github.com/whatwg/fetch/issues/1288 + if (request.redirect !== 'manual') { + fetchParams.controller.connection.destroy(undefined, false) + } + + // 2. Switch on request’s redirect mode: + if (request.redirect === 'error') { + // Set response to a network error. + response = makeNetworkError('unexpected redirect') + } else if (request.redirect === 'manual') { + // Set response to an opaque-redirect filtered response whose internal + // response is actualResponse. + // NOTE(spec): On the web this would return an `opaqueredirect` response, + // but that doesn't make sense server side. + // See https://github.com/nodejs/undici/issues/1193. + response = actualResponse + } else if (request.redirect === 'follow') { + // Set response to the result of running HTTP-redirect fetch given + // fetchParams and response. + response = await httpRedirectFetch(fetchParams, response) + } else { + assert(false) + } + } + + // 9. Set response’s timing info to timingInfo. + response.timingInfo = timingInfo + + // 10. Return response. + return response +} + +// https://fetch.spec.whatwg.org/#http-redirect-fetch +function httpRedirectFetch (fetchParams, response) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let actualResponse be response, if response is not a filtered response, + // and response’s internal response otherwise. + const actualResponse = response.internalResponse + ? response.internalResponse + : response + + // 3. Let locationURL be actualResponse’s location URL given request’s current + // URL’s fragment. + let locationURL + + try { + locationURL = responseLocationURL( + actualResponse, + requestCurrentURL(request).hash + ) + + // 4. If locationURL is null, then return response. + if (locationURL == null) { + return response + } + } catch (err) { + // 5. If locationURL is failure, then return a network error. + return Promise.resolve(makeNetworkError(err)) + } + + // 6. If locationURL’s scheme is not an HTTP(S) scheme, then return a network + // error. + if (!urlIsHttpHttpsScheme(locationURL)) { + return Promise.resolve(makeNetworkError('URL scheme must be a HTTP(S) scheme')) + } + + // 7. If request’s redirect count is 20, then return a network error. + if (request.redirectCount === 20) { + return Promise.resolve(makeNetworkError('redirect count exceeded')) + } + + // 8. Increase request’s redirect count by 1. + request.redirectCount += 1 + + // 9. If request’s mode is "cors", locationURL includes credentials, and + // request’s origin is not same origin with locationURL’s origin, then return + // a network error. + if ( + request.mode === 'cors' && + (locationURL.username || locationURL.password) && + !sameOrigin(request, locationURL) + ) { + return Promise.resolve(makeNetworkError('cross origin not allowed for request mode "cors"')) + } + + // 10. If request’s response tainting is "cors" and locationURL includes + // credentials, then return a network error. + if ( + request.responseTainting === 'cors' && + (locationURL.username || locationURL.password) + ) { + return Promise.resolve(makeNetworkError( + 'URL cannot contain credentials for request mode "cors"' + )) + } + + // 11. If actualResponse’s status is not 303, request’s body is non-null, + // and request’s body’s source is null, then return a network error. + if ( + actualResponse.status !== 303 && + request.body != null && + request.body.source == null + ) { + return Promise.resolve(makeNetworkError()) + } + + // 12. If one of the following is true + // - actualResponse’s status is 301 or 302 and request’s method is `POST` + // - actualResponse’s status is 303 and request’s method is not `GET` or `HEAD` + if ( + ([301, 302].includes(actualResponse.status) && request.method === 'POST') || + (actualResponse.status === 303 && + !GET_OR_HEAD.includes(request.method)) + ) { + // then: + // 1. Set request’s method to `GET` and request’s body to null. + request.method = 'GET' + request.body = null + + // 2. For each headerName of request-body-header name, delete headerName from + // request’s header list. + for (const headerName of requestBodyHeader) { + request.headersList.delete(headerName) + } + } + + // 13. If request’s current URL’s origin is not same origin with locationURL’s + // origin, then for each headerName of CORS non-wildcard request-header name, + // delete headerName from request’s header list. + if (!sameOrigin(requestCurrentURL(request), locationURL)) { + // https://fetch.spec.whatwg.org/#cors-non-wildcard-request-header-name + request.headersList.delete('authorization', true) + + // https://fetch.spec.whatwg.org/#authentication-entries + request.headersList.delete('proxy-authorization', true) + + // "Cookie" and "Host" are forbidden request-headers, which undici doesn't implement. + request.headersList.delete('cookie', true) + request.headersList.delete('host', true) + } + + // 14. If request’s body is non-null, then set request’s body to the first return + // value of safely extracting request’s body’s source. + if (request.body != null) { + assert(request.body.source != null) + request.body = safelyExtractBody(request.body.source)[0] + } + + // 15. Let timingInfo be fetchParams’s timing info. + const timingInfo = fetchParams.timingInfo + + // 16. Set timingInfo’s redirect end time and post-redirect start time to the + // coarsened shared current time given fetchParams’s cross-origin isolated + // capability. + timingInfo.redirectEndTime = timingInfo.postRedirectStartTime = + coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability) + + // 17. If timingInfo’s redirect start time is 0, then set timingInfo’s + // redirect start time to timingInfo’s start time. + if (timingInfo.redirectStartTime === 0) { + timingInfo.redirectStartTime = timingInfo.startTime + } + + // 18. Append locationURL to request’s URL list. + request.urlList.push(locationURL) + + // 19. Invoke set request’s referrer policy on redirect on request and + // actualResponse. + setRequestReferrerPolicyOnRedirect(request, actualResponse) + + // 20. Return the result of running main fetch given fetchParams and true. + return mainFetch(fetchParams, true) +} + +// https://fetch.spec.whatwg.org/#http-network-or-cache-fetch +async function httpNetworkOrCacheFetch ( + fetchParams, + isAuthenticationFetch = false, + isNewConnectionFetch = false +) { + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let httpFetchParams be null. + let httpFetchParams = null + + // 3. Let httpRequest be null. + let httpRequest = null + + // 4. Let response be null. + let response = null + + // 5. Let storedResponse be null. + // TODO: cache + + // 6. Let httpCache be null. + const httpCache = null + + // 7. Let the revalidatingFlag be unset. + const revalidatingFlag = false + + // 8. Run these steps, but abort when the ongoing fetch is terminated: + + // 1. If request’s window is "no-window" and request’s redirect mode is + // "error", then set httpFetchParams to fetchParams and httpRequest to + // request. + if (request.window === 'no-window' && request.redirect === 'error') { + httpFetchParams = fetchParams + httpRequest = request + } else { + // Otherwise: + + // 1. Set httpRequest to a clone of request. + httpRequest = cloneRequest(request) + + // 2. Set httpFetchParams to a copy of fetchParams. + httpFetchParams = { ...fetchParams } + + // 3. Set httpFetchParams’s request to httpRequest. + httpFetchParams.request = httpRequest + } + + // 3. Let includeCredentials be true if one of + const includeCredentials = + request.credentials === 'include' || + (request.credentials === 'same-origin' && + request.responseTainting === 'basic') + + // 4. Let contentLength be httpRequest’s body’s length, if httpRequest’s + // body is non-null; otherwise null. + const contentLength = httpRequest.body ? httpRequest.body.length : null + + // 5. Let contentLengthHeaderValue be null. + let contentLengthHeaderValue = null + + // 6. If httpRequest’s body is null and httpRequest’s method is `POST` or + // `PUT`, then set contentLengthHeaderValue to `0`. + if ( + httpRequest.body == null && + ['POST', 'PUT'].includes(httpRequest.method) + ) { + contentLengthHeaderValue = '0' + } + + // 7. If contentLength is non-null, then set contentLengthHeaderValue to + // contentLength, serialized and isomorphic encoded. + if (contentLength != null) { + contentLengthHeaderValue = isomorphicEncode(`${contentLength}`) + } + + // 8. If contentLengthHeaderValue is non-null, then append + // `Content-Length`/contentLengthHeaderValue to httpRequest’s header + // list. + if (contentLengthHeaderValue != null) { + httpRequest.headersList.append('content-length', contentLengthHeaderValue, true) + } + + // 9. If contentLengthHeaderValue is non-null, then append (`Content-Length`, + // contentLengthHeaderValue) to httpRequest’s header list. + + // 10. If contentLength is non-null and httpRequest’s keepalive is true, + // then: + if (contentLength != null && httpRequest.keepalive) { + // NOTE: keepalive is a noop outside of browser context. + } + + // 11. If httpRequest’s referrer is a URL, then append + // `Referer`/httpRequest’s referrer, serialized and isomorphic encoded, + // to httpRequest’s header list. + if (webidl.is.URL(httpRequest.referrer)) { + httpRequest.headersList.append('referer', isomorphicEncode(httpRequest.referrer.href), true) + } + + // 12. Append a request `Origin` header for httpRequest. + appendRequestOriginHeader(httpRequest) + + // 13. Append the Fetch metadata headers for httpRequest. [FETCH-METADATA] + appendFetchMetadata(httpRequest) + + // 14. If httpRequest’s header list does not contain `User-Agent`, then + // user agents should append `User-Agent`/default `User-Agent` value to + // httpRequest’s header list. + if (!httpRequest.headersList.contains('user-agent', true)) { + httpRequest.headersList.append('user-agent', defaultUserAgent, true) + } + + // 15. If httpRequest’s cache mode is "default" and httpRequest’s header + // list contains `If-Modified-Since`, `If-None-Match`, + // `If-Unmodified-Since`, `If-Match`, or `If-Range`, then set + // httpRequest’s cache mode to "no-store". + if ( + httpRequest.cache === 'default' && + (httpRequest.headersList.contains('if-modified-since', true) || + httpRequest.headersList.contains('if-none-match', true) || + httpRequest.headersList.contains('if-unmodified-since', true) || + httpRequest.headersList.contains('if-match', true) || + httpRequest.headersList.contains('if-range', true)) + ) { + httpRequest.cache = 'no-store' + } + + // 16. If httpRequest’s cache mode is "no-cache", httpRequest’s prevent + // no-cache cache-control header modification flag is unset, and + // httpRequest’s header list does not contain `Cache-Control`, then append + // `Cache-Control`/`max-age=0` to httpRequest’s header list. + if ( + httpRequest.cache === 'no-cache' && + !httpRequest.preventNoCacheCacheControlHeaderModification && + !httpRequest.headersList.contains('cache-control', true) + ) { + httpRequest.headersList.append('cache-control', 'max-age=0', true) + } + + // 17. If httpRequest’s cache mode is "no-store" or "reload", then: + if (httpRequest.cache === 'no-store' || httpRequest.cache === 'reload') { + // 1. If httpRequest’s header list does not contain `Pragma`, then append + // `Pragma`/`no-cache` to httpRequest’s header list. + if (!httpRequest.headersList.contains('pragma', true)) { + httpRequest.headersList.append('pragma', 'no-cache', true) + } + + // 2. If httpRequest’s header list does not contain `Cache-Control`, + // then append `Cache-Control`/`no-cache` to httpRequest’s header list. + if (!httpRequest.headersList.contains('cache-control', true)) { + httpRequest.headersList.append('cache-control', 'no-cache', true) + } + } + + // 18. If httpRequest’s header list contains `Range`, then append + // `Accept-Encoding`/`identity` to httpRequest’s header list. + if (httpRequest.headersList.contains('range', true)) { + httpRequest.headersList.append('accept-encoding', 'identity', true) + } + + // 19. Modify httpRequest’s header list per HTTP. Do not append a given + // header if httpRequest’s header list contains that header’s name. + // TODO: https://github.com/whatwg/fetch/issues/1285#issuecomment-896560129 + if (!httpRequest.headersList.contains('accept-encoding', true)) { + if (urlHasHttpsScheme(requestCurrentURL(httpRequest))) { + httpRequest.headersList.append('accept-encoding', 'br, gzip, deflate', true) + } else { + httpRequest.headersList.append('accept-encoding', 'gzip, deflate', true) + } + } + + httpRequest.headersList.delete('host', true) + + // 20. If includeCredentials is true, then: + if (includeCredentials) { + // 1. If the user agent is not configured to block cookies for httpRequest + // (see section 7 of [COOKIES]), then: + // TODO: credentials + // 2. If httpRequest’s header list does not contain `Authorization`, then: + // TODO: credentials + } + + // 21. If there’s a proxy-authentication entry, use it as appropriate. + // TODO: proxy-authentication + + // 22. Set httpCache to the result of determining the HTTP cache + // partition, given httpRequest. + // TODO: cache + + // 23. If httpCache is null, then set httpRequest’s cache mode to + // "no-store". + if (httpCache == null) { + httpRequest.cache = 'no-store' + } + + // 24. If httpRequest’s cache mode is neither "no-store" nor "reload", + // then: + if (httpRequest.cache !== 'no-store' && httpRequest.cache !== 'reload') { + // TODO: cache + } + + // 9. If aborted, then return the appropriate network error for fetchParams. + // TODO + + // 10. If response is null, then: + if (response == null) { + // 1. If httpRequest’s cache mode is "only-if-cached", then return a + // network error. + if (httpRequest.cache === 'only-if-cached') { + return makeNetworkError('only if cached') + } + + // 2. Let forwardResponse be the result of running HTTP-network fetch + // given httpFetchParams, includeCredentials, and isNewConnectionFetch. + const forwardResponse = await httpNetworkFetch( + httpFetchParams, + includeCredentials, + isNewConnectionFetch + ) + + // 3. If httpRequest’s method is unsafe and forwardResponse’s status is + // in the range 200 to 399, inclusive, invalidate appropriate stored + // responses in httpCache, as per the "Invalidation" chapter of HTTP + // Caching, and set storedResponse to null. [HTTP-CACHING] + if ( + !safeMethodsSet.has(httpRequest.method) && + forwardResponse.status >= 200 && + forwardResponse.status <= 399 + ) { + // TODO: cache + } + + // 4. If the revalidatingFlag is set and forwardResponse’s status is 304, + // then: + if (revalidatingFlag && forwardResponse.status === 304) { + // TODO: cache + } + + // 5. If response is null, then: + if (response == null) { + // 1. Set response to forwardResponse. + response = forwardResponse + + // 2. Store httpRequest and forwardResponse in httpCache, as per the + // "Storing Responses in Caches" chapter of HTTP Caching. [HTTP-CACHING] + // TODO: cache + } + } + + // 11. Set response’s URL list to a clone of httpRequest’s URL list. + response.urlList = [...httpRequest.urlList] + + // 12. If httpRequest’s header list contains `Range`, then set response’s + // range-requested flag. + if (httpRequest.headersList.contains('range', true)) { + response.rangeRequested = true + } + + // 13. Set response’s request-includes-credentials to includeCredentials. + response.requestIncludesCredentials = includeCredentials + + // 14. If response’s status is 401, httpRequest’s response tainting is not + // "cors", includeCredentials is true, and request’s window is an environment + // settings object, then: + // TODO + + // 15. If response’s status is 407, then: + if (response.status === 407) { + // 1. If request’s window is "no-window", then return a network error. + if (request.window === 'no-window') { + return makeNetworkError() + } + + // 2. ??? + + // 3. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams)) { + return makeAppropriateNetworkError(fetchParams) + } + + // 4. Prompt the end user as appropriate in request’s window and store + // the result as a proxy-authentication entry. [HTTP-AUTH] + // TODO: Invoke some kind of callback? + + // 5. Set response to the result of running HTTP-network-or-cache fetch given + // fetchParams. + // TODO + return makeNetworkError('proxy authentication required') + } + + // 16. If all of the following are true + if ( + // response’s status is 421 + response.status === 421 && + // isNewConnectionFetch is false + !isNewConnectionFetch && + // request’s body is null, or request’s body is non-null and request’s body’s source is non-null + (request.body == null || request.body.source != null) + ) { + // then: + + // 1. If fetchParams is canceled, then return the appropriate network error for fetchParams. + if (isCancelled(fetchParams)) { + return makeAppropriateNetworkError(fetchParams) + } + + // 2. Set response to the result of running HTTP-network-or-cache + // fetch given fetchParams, isAuthenticationFetch, and true. + + // TODO (spec): The spec doesn't specify this but we need to cancel + // the active response before we can start a new one. + // https://github.com/whatwg/fetch/issues/1293 + fetchParams.controller.connection.destroy() + + response = await httpNetworkOrCacheFetch( + fetchParams, + isAuthenticationFetch, + true + ) + } + + // 17. If isAuthenticationFetch is true, then create an authentication entry + if (isAuthenticationFetch) { + // TODO + } + + // 18. Return response. + return response +} + +// https://fetch.spec.whatwg.org/#http-network-fetch +async function httpNetworkFetch ( + fetchParams, + includeCredentials = false, + forceNewConnection = false +) { + assert(!fetchParams.controller.connection || fetchParams.controller.connection.destroyed) + + fetchParams.controller.connection = { + abort: null, + destroyed: false, + destroy (err, abort = true) { + if (!this.destroyed) { + this.destroyed = true + if (abort) { + this.abort?.(err ?? new DOMException('The operation was aborted.', 'AbortError')) + } + } + } + } + + // 1. Let request be fetchParams’s request. + const request = fetchParams.request + + // 2. Let response be null. + let response = null + + // 3. Let timingInfo be fetchParams’s timing info. + const timingInfo = fetchParams.timingInfo + + // 4. Let httpCache be the result of determining the HTTP cache partition, + // given request. + // TODO: cache + const httpCache = null + + // 5. If httpCache is null, then set request’s cache mode to "no-store". + if (httpCache == null) { + request.cache = 'no-store' + } + + // 6. Let networkPartitionKey be the result of determining the network + // partition key given request. + // TODO + + // 7. Let newConnection be "yes" if forceNewConnection is true; otherwise + // "no". + const newConnection = forceNewConnection ? 'yes' : 'no' // eslint-disable-line no-unused-vars + + // 8. Switch on request’s mode: + if (request.mode === 'websocket') { + // Let connection be the result of obtaining a WebSocket connection, + // given request’s current URL. + // TODO + } else { + // Let connection be the result of obtaining a connection, given + // networkPartitionKey, request’s current URL’s origin, + // includeCredentials, and forceNewConnection. + // TODO + } + + // 9. Run these steps, but abort when the ongoing fetch is terminated: + + // 1. If connection is failure, then return a network error. + + // 2. Set timingInfo’s final connection timing info to the result of + // calling clamp and coarsen connection timing info with connection’s + // timing info, timingInfo’s post-redirect start time, and fetchParams’s + // cross-origin isolated capability. + + // 3. If connection is not an HTTP/2 connection, request’s body is non-null, + // and request’s body’s source is null, then append (`Transfer-Encoding`, + // `chunked`) to request’s header list. + + // 4. Set timingInfo’s final network-request start time to the coarsened + // shared current time given fetchParams’s cross-origin isolated + // capability. + + // 5. Set response to the result of making an HTTP request over connection + // using request with the following caveats: + + // - Follow the relevant requirements from HTTP. [HTTP] [HTTP-SEMANTICS] + // [HTTP-COND] [HTTP-CACHING] [HTTP-AUTH] + + // - If request’s body is non-null, and request’s body’s source is null, + // then the user agent may have a buffer of up to 64 kibibytes and store + // a part of request’s body in that buffer. If the user agent reads from + // request’s body beyond that buffer’s size and the user agent needs to + // resend request, then instead return a network error. + + // - Set timingInfo’s final network-response start time to the coarsened + // shared current time given fetchParams’s cross-origin isolated capability, + // immediately after the user agent’s HTTP parser receives the first byte + // of the response (e.g., frame header bytes for HTTP/2 or response status + // line for HTTP/1.x). + + // - Wait until all the headers are transmitted. + + // - Any responses whose status is in the range 100 to 199, inclusive, + // and is not 101, are to be ignored, except for the purposes of setting + // timingInfo’s final network-response start time above. + + // - If request’s header list contains `Transfer-Encoding`/`chunked` and + // response is transferred via HTTP/1.0 or older, then return a network + // error. + + // - If the HTTP request results in a TLS client certificate dialog, then: + + // 1. If request’s window is an environment settings object, make the + // dialog available in request’s window. + + // 2. Otherwise, return a network error. + + // To transmit request’s body body, run these steps: + let requestBody = null + // 1. If body is null and fetchParams’s process request end-of-body is + // non-null, then queue a fetch task given fetchParams’s process request + // end-of-body and fetchParams’s task destination. + if (request.body == null && fetchParams.processRequestEndOfBody) { + queueMicrotask(() => fetchParams.processRequestEndOfBody()) + } else if (request.body != null) { + // 2. Otherwise, if body is non-null: + + // 1. Let processBodyChunk given bytes be these steps: + const processBodyChunk = async function * (bytes) { + // 1. If the ongoing fetch is terminated, then abort these steps. + if (isCancelled(fetchParams)) { + return + } + + // 2. Run this step in parallel: transmit bytes. + yield bytes + + // 3. If fetchParams’s process request body is non-null, then run + // fetchParams’s process request body given bytes’s length. + fetchParams.processRequestBodyChunkLength?.(bytes.byteLength) + } + + // 2. Let processEndOfBody be these steps: + const processEndOfBody = () => { + // 1. If fetchParams is canceled, then abort these steps. + if (isCancelled(fetchParams)) { + return + } + + // 2. If fetchParams’s process request end-of-body is non-null, + // then run fetchParams’s process request end-of-body. + if (fetchParams.processRequestEndOfBody) { + fetchParams.processRequestEndOfBody() + } + } + + // 3. Let processBodyError given e be these steps: + const processBodyError = (e) => { + // 1. If fetchParams is canceled, then abort these steps. + if (isCancelled(fetchParams)) { + return + } + + // 2. If e is an "AbortError" DOMException, then abort fetchParams’s controller. + if (e.name === 'AbortError') { + fetchParams.controller.abort() + } else { + fetchParams.controller.terminate(e) + } + } + + // 4. Incrementally read request’s body given processBodyChunk, processEndOfBody, + // processBodyError, and fetchParams’s task destination. + requestBody = (async function * () { + try { + for await (const bytes of request.body.stream) { + yield * processBodyChunk(bytes) + } + processEndOfBody() + } catch (err) { + processBodyError(err) + } + })() + } + + try { + // socket is only provided for websockets + const { body, status, statusText, headersList, socket } = await dispatch({ body: requestBody }) + + if (socket) { + response = makeResponse({ status, statusText, headersList, socket }) + } else { + const iterator = body[Symbol.asyncIterator]() + fetchParams.controller.next = () => iterator.next() + + response = makeResponse({ status, statusText, headersList }) + } + } catch (err) { + // 10. If aborted, then: + if (err.name === 'AbortError') { + // 1. If connection uses HTTP/2, then transmit an RST_STREAM frame. + fetchParams.controller.connection.destroy() + + // 2. Return the appropriate network error for fetchParams. + return makeAppropriateNetworkError(fetchParams, err) + } + + return makeNetworkError(err) + } + + // 11. Let pullAlgorithm be an action that resumes the ongoing fetch + // if it is suspended. + const pullAlgorithm = () => { + return fetchParams.controller.resume() + } + + // 12. Let cancelAlgorithm be an algorithm that aborts fetchParams’s + // controller with reason, given reason. + const cancelAlgorithm = (reason) => { + // If the aborted fetch was already terminated, then we do not + // need to do anything. + if (!isCancelled(fetchParams)) { + fetchParams.controller.abort(reason) + } + } + + // 13. Let highWaterMark be a non-negative, non-NaN number, chosen by + // the user agent. + // TODO + + // 14. Let sizeAlgorithm be an algorithm that accepts a chunk object + // and returns a non-negative, non-NaN, non-infinite number, chosen by the user agent. + // TODO + + // 15. Let stream be a new ReadableStream. + // 16. Set up stream with byte reading support with pullAlgorithm set to pullAlgorithm, + // cancelAlgorithm set to cancelAlgorithm. + const stream = new ReadableStream( + { + async start (controller) { + fetchParams.controller.controller = controller + }, + async pull (controller) { + await pullAlgorithm(controller) + }, + async cancel (reason) { + await cancelAlgorithm(reason) + }, + type: 'bytes' + } + ) + + // 17. Run these steps, but abort when the ongoing fetch is terminated: + + // 1. Set response’s body to a new body whose stream is stream. + response.body = { stream, source: null, length: null } + + // 2. If response is not a network error and request’s cache mode is + // not "no-store", then update response in httpCache for request. + // TODO + + // 3. If includeCredentials is true and the user agent is not configured + // to block cookies for request (see section 7 of [COOKIES]), then run the + // "set-cookie-string" parsing algorithm (see section 5.2 of [COOKIES]) on + // the value of each header whose name is a byte-case-insensitive match for + // `Set-Cookie` in response’s header list, if any, and request’s current URL. + // TODO + + // 18. If aborted, then: + // TODO + + // 19. Run these steps in parallel: + + // 1. Run these steps, but abort when fetchParams is canceled: + if (!fetchParams.controller.resume) { + fetchParams.controller.on('terminated', onAborted) + } + + fetchParams.controller.resume = async () => { + // 1. While true + while (true) { + // 1-3. See onData... + + // 4. Set bytes to the result of handling content codings given + // codings and bytes. + let bytes + let isFailure + try { + const { done, value } = await fetchParams.controller.next() + + if (isAborted(fetchParams)) { + break + } + + bytes = done ? undefined : value + } catch (err) { + if (fetchParams.controller.ended && !timingInfo.encodedBodySize) { + // zlib doesn't like empty streams. + bytes = undefined + } else { + bytes = err + + // err may be propagated from the result of calling readablestream.cancel, + // which might not be an error. https://github.com/nodejs/undici/issues/2009 + isFailure = true + } + } + + if (bytes === undefined) { + // 2. Otherwise, if the bytes transmission for response’s message + // body is done normally and stream is readable, then close + // stream, finalize response for fetchParams and response, and + // abort these in-parallel steps. + readableStreamClose(fetchParams.controller.controller) + + finalizeResponse(fetchParams, response) + + return + } + + // 5. Increase timingInfo’s decoded body size by bytes’s length. + timingInfo.decodedBodySize += bytes?.byteLength ?? 0 + + // 6. If bytes is failure, then terminate fetchParams’s controller. + if (isFailure) { + fetchParams.controller.terminate(bytes) + return + } + + // 7. Enqueue a Uint8Array wrapping an ArrayBuffer containing bytes + // into stream. + const buffer = new Uint8Array(bytes) + if (buffer.byteLength) { + fetchParams.controller.controller.enqueue(buffer) + } + + // 8. If stream is errored, then terminate the ongoing fetch. + if (isErrored(stream)) { + fetchParams.controller.terminate() + return + } + + // 9. If stream doesn’t need more data ask the user agent to suspend + // the ongoing fetch. + if (fetchParams.controller.controller.desiredSize <= 0) { + return + } + } + } + + // 2. If aborted, then: + function onAborted (reason) { + // 2. If fetchParams is aborted, then: + if (isAborted(fetchParams)) { + // 1. Set response’s aborted flag. + response.aborted = true + + // 2. If stream is readable, then error stream with the result of + // deserialize a serialized abort reason given fetchParams’s + // controller’s serialized abort reason and an + // implementation-defined realm. + if (isReadable(stream)) { + fetchParams.controller.controller.error( + fetchParams.controller.serializedAbortReason + ) + } + } else { + // 3. Otherwise, if stream is readable, error stream with a TypeError. + if (isReadable(stream)) { + fetchParams.controller.controller.error(new TypeError('terminated', { + cause: isErrorLike(reason) ? reason : undefined + })) + } + } + + // 4. If connection uses HTTP/2, then transmit an RST_STREAM frame. + // 5. Otherwise, the user agent should close connection unless it would be bad for performance to do so. + fetchParams.controller.connection.destroy() + } + + // 20. Return response. + return response + + function dispatch ({ body }) { + const url = requestCurrentURL(request) + /** @type {import('../..').Agent} */ + const agent = fetchParams.controller.dispatcher + + return new Promise((resolve, reject) => agent.dispatch( + { + path: url.pathname + url.search, + origin: url.origin, + method: request.method, + body: agent.isMockActive ? request.body && (request.body.source || request.body.stream) : body, + headers: request.headersList.entries, + maxRedirections: 0, + upgrade: request.mode === 'websocket' ? 'websocket' : undefined + }, + { + body: null, + abort: null, + + onConnect (abort) { + // TODO (fix): Do we need connection here? + const { connection } = fetchParams.controller + + // Set timingInfo’s final connection timing info to the result of calling clamp and coarsen + // connection timing info with connection’s timing info, timingInfo’s post-redirect start + // time, and fetchParams’s cross-origin isolated capability. + // TODO: implement connection timing + timingInfo.finalConnectionTimingInfo = clampAndCoarsenConnectionTimingInfo(undefined, timingInfo.postRedirectStartTime, fetchParams.crossOriginIsolatedCapability) + + if (connection.destroyed) { + abort(new DOMException('The operation was aborted.', 'AbortError')) + } else { + fetchParams.controller.on('terminated', abort) + this.abort = connection.abort = abort + } + + // Set timingInfo’s final network-request start time to the coarsened shared current time given + // fetchParams’s cross-origin isolated capability. + timingInfo.finalNetworkRequestStartTime = coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability) + }, + + onResponseStarted () { + // Set timingInfo’s final network-response start time to the coarsened shared current + // time given fetchParams’s cross-origin isolated capability, immediately after the + // user agent’s HTTP parser receives the first byte of the response (e.g., frame header + // bytes for HTTP/2 or response status line for HTTP/1.x). + timingInfo.finalNetworkResponseStartTime = coarsenedSharedCurrentTime(fetchParams.crossOriginIsolatedCapability) + }, + + onHeaders (status, rawHeaders, resume, statusText) { + if (status < 200) { + return + } + + /** @type {string[]} */ + let codings = [] + let location = '' + + const headersList = new HeadersList() + + for (let i = 0; i < rawHeaders.length; i += 2) { + headersList.append(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString('latin1'), true) + } + const contentEncoding = headersList.get('content-encoding', true) + if (contentEncoding) { + // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1 + // "All content-coding values are case-insensitive..." + codings = contentEncoding.toLowerCase().split(',').map((x) => x.trim()) + } + location = headersList.get('location', true) + + this.body = new Readable({ read: resume }) + + const decoders = [] + + const willFollow = location && request.redirect === 'follow' && + redirectStatusSet.has(status) + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding + if (codings.length !== 0 && request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status) && !willFollow) { + for (let i = codings.length - 1; i >= 0; --i) { + const coding = codings[i] + // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2 + if (coding === 'x-gzip' || coding === 'gzip') { + decoders.push(zlib.createGunzip({ + // Be less strict when decoding compressed responses, since sometimes + // servers send slightly invalid responses that are still accepted + // by common browsers. + // Always using Z_SYNC_FLUSH is what cURL does. + flush: zlib.constants.Z_SYNC_FLUSH, + finishFlush: zlib.constants.Z_SYNC_FLUSH + })) + } else if (coding === 'deflate') { + decoders.push(createInflate({ + flush: zlib.constants.Z_SYNC_FLUSH, + finishFlush: zlib.constants.Z_SYNC_FLUSH + })) + } else if (coding === 'br') { + decoders.push(zlib.createBrotliDecompress({ + flush: zlib.constants.BROTLI_OPERATION_FLUSH, + finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH + })) + } else { + decoders.length = 0 + break + } + } + } + + const onError = this.onError.bind(this) + + resolve({ + status, + statusText, + headersList, + body: decoders.length + ? pipeline(this.body, ...decoders, (err) => { + if (err) { + this.onError(err) + } + }).on('error', onError) + : this.body.on('error', onError) + }) + + return true + }, + + onData (chunk) { + if (fetchParams.controller.dump) { + return + } + + // 1. If one or more bytes have been transmitted from response’s + // message body, then: + + // 1. Let bytes be the transmitted bytes. + const bytes = chunk + + // 2. Let codings be the result of extracting header list values + // given `Content-Encoding` and response’s header list. + // See pullAlgorithm. + + // 3. Increase timingInfo’s encoded body size by bytes’s length. + timingInfo.encodedBodySize += bytes.byteLength + + // 4. See pullAlgorithm... + + return this.body.push(bytes) + }, + + onComplete () { + if (this.abort) { + fetchParams.controller.off('terminated', this.abort) + } + + fetchParams.controller.ended = true + + this.body.push(null) + }, + + onError (error) { + if (this.abort) { + fetchParams.controller.off('terminated', this.abort) + } + + this.body?.destroy(error) + + fetchParams.controller.terminate(error) + + reject(error) + }, + + onUpgrade (status, rawHeaders, socket) { + if (status !== 101) { + return + } + + const headersList = new HeadersList() + + for (let i = 0; i < rawHeaders.length; i += 2) { + headersList.append(bufferToLowerCasedHeaderName(rawHeaders[i]), rawHeaders[i + 1].toString('latin1'), true) + } + + resolve({ + status, + statusText: STATUS_CODES[status], + headersList, + socket + }) + + return true + } + } + )) + } +} + +module.exports = { + fetch, + Fetch, + fetching, + finalizeAndReportTiming +} diff --git a/lib/web/fetch/request.js b/lib/web/fetch/request.js new file mode 100644 index 0000000..97fea22 --- /dev/null +++ b/lib/web/fetch/request.js @@ -0,0 +1,1096 @@ +/* globals AbortController */ + +'use strict' + +const { extractBody, mixinBody, cloneBody, bodyUnusable } = require('./body') +const { Headers, fill: fillHeaders, HeadersList, setHeadersGuard, getHeadersGuard, setHeadersList, getHeadersList } = require('./headers') +const { FinalizationRegistry } = require('./dispatcher-weakref')() +const util = require('../../core/util') +const nodeUtil = require('node:util') +const { + isValidHTTPToken, + sameOrigin, + environmentSettingsObject +} = require('./util') +const { + forbiddenMethodsSet, + corsSafeListedMethodsSet, + referrerPolicy, + requestRedirect, + requestMode, + requestCredentials, + requestCache, + requestDuplex +} = require('./constants') +const { kEnumerableProperty, normalizedMethodRecordsBase, normalizedMethodRecords } = util +const { webidl } = require('./webidl') +const { URLSerializer } = require('./data-url') +const { kConstruct } = require('../../core/symbols') +const assert = require('node:assert') +const { getMaxListeners, setMaxListeners, defaultMaxListeners } = require('node:events') + +const kAbortController = Symbol('abortController') + +const requestFinalizer = new FinalizationRegistry(({ signal, abort }) => { + signal.removeEventListener('abort', abort) +}) + +const dependentControllerMap = new WeakMap() + +function buildAbort (acRef) { + return abort + + function abort () { + const ac = acRef.deref() + if (ac !== undefined) { + // Currently, there is a problem with FinalizationRegistry. + // https://github.com/nodejs/node/issues/49344 + // https://github.com/nodejs/node/issues/47748 + // In the case of abort, the first step is to unregister from it. + // If the controller can refer to it, it is still registered. + // It will be removed in the future. + requestFinalizer.unregister(abort) + + // Unsubscribe a listener. + // FinalizationRegistry will no longer be called, so this must be done. + this.removeEventListener('abort', abort) + + ac.abort(this.reason) + + const controllerList = dependentControllerMap.get(ac.signal) + + if (controllerList !== undefined) { + if (controllerList.size !== 0) { + for (const ref of controllerList) { + const ctrl = ref.deref() + if (ctrl !== undefined) { + ctrl.abort(this.reason) + } + } + controllerList.clear() + } + dependentControllerMap.delete(ac.signal) + } + } + } +} + +let patchMethodWarning = false + +// https://fetch.spec.whatwg.org/#request-class +class Request { + /** @type {AbortSignal} */ + #signal + + /** @type {import('../../dispatcher/dispatcher')} */ + #dispatcher + + /** @type {Headers} */ + #headers + + #state + + // https://fetch.spec.whatwg.org/#dom-request + constructor (input, init = undefined) { + webidl.util.markAsUncloneable(this) + + if (input === kConstruct) { + return + } + + const prefix = 'Request constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + input = webidl.converters.RequestInfo(input, prefix, 'input') + init = webidl.converters.RequestInit(init, prefix, 'init') + + // 1. Let request be null. + let request = null + + // 2. Let fallbackMode be null. + let fallbackMode = null + + // 3. Let baseURL be this’s relevant settings object’s API base URL. + const baseUrl = environmentSettingsObject.settingsObject.baseUrl + + // 4. Let signal be null. + let signal = null + + // 5. If input is a string, then: + if (typeof input === 'string') { + this.#dispatcher = init.dispatcher + + // 1. Let parsedURL be the result of parsing input with baseURL. + // 2. If parsedURL is failure, then throw a TypeError. + let parsedURL + try { + parsedURL = new URL(input, baseUrl) + } catch (err) { + throw new TypeError('Failed to parse URL from ' + input, { cause: err }) + } + + // 3. If parsedURL includes credentials, then throw a TypeError. + if (parsedURL.username || parsedURL.password) { + throw new TypeError( + 'Request cannot be constructed from a URL that includes credentials: ' + + input + ) + } + + // 4. Set request to a new request whose URL is parsedURL. + request = makeRequest({ urlList: [parsedURL] }) + + // 5. Set fallbackMode to "cors". + fallbackMode = 'cors' + } else { + // 6. Otherwise: + + // 7. Assert: input is a Request object. + assert(webidl.is.Request(input)) + + // 8. Set request to input’s request. + request = input.#state + + // 9. Set signal to input’s signal. + signal = input.#signal + + this.#dispatcher = init.dispatcher || input.#dispatcher + } + + // 7. Let origin be this’s relevant settings object’s origin. + const origin = environmentSettingsObject.settingsObject.origin + + // 8. Let window be "client". + let window = 'client' + + // 9. If request’s window is an environment settings object and its origin + // is same origin with origin, then set window to request’s window. + if ( + request.window?.constructor?.name === 'EnvironmentSettingsObject' && + sameOrigin(request.window, origin) + ) { + window = request.window + } + + // 10. If init["window"] exists and is non-null, then throw a TypeError. + if (init.window != null) { + throw new TypeError(`'window' option '${window}' must be null`) + } + + // 11. If init["window"] exists, then set window to "no-window". + if ('window' in init) { + window = 'no-window' + } + + // 12. Set request to a new request with the following properties: + request = makeRequest({ + // URL request’s URL. + // undici implementation note: this is set as the first item in request's urlList in makeRequest + // method request’s method. + method: request.method, + // header list A copy of request’s header list. + // undici implementation note: headersList is cloned in makeRequest + headersList: request.headersList, + // unsafe-request flag Set. + unsafeRequest: request.unsafeRequest, + // client This’s relevant settings object. + client: environmentSettingsObject.settingsObject, + // window window. + window, + // priority request’s priority. + priority: request.priority, + // origin request’s origin. The propagation of the origin is only significant for navigation requests + // being handled by a service worker. In this scenario a request can have an origin that is different + // from the current client. + origin: request.origin, + // referrer request’s referrer. + referrer: request.referrer, + // referrer policy request’s referrer policy. + referrerPolicy: request.referrerPolicy, + // mode request’s mode. + mode: request.mode, + // credentials mode request’s credentials mode. + credentials: request.credentials, + // cache mode request’s cache mode. + cache: request.cache, + // redirect mode request’s redirect mode. + redirect: request.redirect, + // integrity metadata request’s integrity metadata. + integrity: request.integrity, + // keepalive request’s keepalive. + keepalive: request.keepalive, + // reload-navigation flag request’s reload-navigation flag. + reloadNavigation: request.reloadNavigation, + // history-navigation flag request’s history-navigation flag. + historyNavigation: request.historyNavigation, + // URL list A clone of request’s URL list. + urlList: [...request.urlList] + }) + + const initHasKey = Object.keys(init).length !== 0 + + // 13. If init is not empty, then: + if (initHasKey) { + // 1. If request’s mode is "navigate", then set it to "same-origin". + if (request.mode === 'navigate') { + request.mode = 'same-origin' + } + + // 2. Unset request’s reload-navigation flag. + request.reloadNavigation = false + + // 3. Unset request’s history-navigation flag. + request.historyNavigation = false + + // 4. Set request’s origin to "client". + request.origin = 'client' + + // 5. Set request’s referrer to "client" + request.referrer = 'client' + + // 6. Set request’s referrer policy to the empty string. + request.referrerPolicy = '' + + // 7. Set request’s URL to request’s current URL. + request.url = request.urlList[request.urlList.length - 1] + + // 8. Set request’s URL list to « request’s URL ». + request.urlList = [request.url] + } + + // 14. If init["referrer"] exists, then: + if (init.referrer !== undefined) { + // 1. Let referrer be init["referrer"]. + const referrer = init.referrer + + // 2. If referrer is the empty string, then set request’s referrer to "no-referrer". + if (referrer === '') { + request.referrer = 'no-referrer' + } else { + // 1. Let parsedReferrer be the result of parsing referrer with + // baseURL. + // 2. If parsedReferrer is failure, then throw a TypeError. + let parsedReferrer + try { + parsedReferrer = new URL(referrer, baseUrl) + } catch (err) { + throw new TypeError(`Referrer "${referrer}" is not a valid URL.`, { cause: err }) + } + + // 3. If one of the following is true + // - parsedReferrer’s scheme is "about" and path is the string "client" + // - parsedReferrer’s origin is not same origin with origin + // then set request’s referrer to "client". + if ( + (parsedReferrer.protocol === 'about:' && parsedReferrer.hostname === 'client') || + (origin && !sameOrigin(parsedReferrer, environmentSettingsObject.settingsObject.baseUrl)) + ) { + request.referrer = 'client' + } else { + // 4. Otherwise, set request’s referrer to parsedReferrer. + request.referrer = parsedReferrer + } + } + } + + // 15. If init["referrerPolicy"] exists, then set request’s referrer policy + // to it. + if (init.referrerPolicy !== undefined) { + request.referrerPolicy = init.referrerPolicy + } + + // 16. Let mode be init["mode"] if it exists, and fallbackMode otherwise. + let mode + if (init.mode !== undefined) { + mode = init.mode + } else { + mode = fallbackMode + } + + // 17. If mode is "navigate", then throw a TypeError. + if (mode === 'navigate') { + throw webidl.errors.exception({ + header: 'Request constructor', + message: 'invalid request mode navigate.' + }) + } + + // 18. If mode is non-null, set request’s mode to mode. + if (mode != null) { + request.mode = mode + } + + // 19. If init["credentials"] exists, then set request’s credentials mode + // to it. + if (init.credentials !== undefined) { + request.credentials = init.credentials + } + + // 18. If init["cache"] exists, then set request’s cache mode to it. + if (init.cache !== undefined) { + request.cache = init.cache + } + + // 21. If request’s cache mode is "only-if-cached" and request’s mode is + // not "same-origin", then throw a TypeError. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + throw new TypeError( + "'only-if-cached' can be set only with 'same-origin' mode" + ) + } + + // 22. If init["redirect"] exists, then set request’s redirect mode to it. + if (init.redirect !== undefined) { + request.redirect = init.redirect + } + + // 23. If init["integrity"] exists, then set request’s integrity metadata to it. + if (init.integrity != null) { + request.integrity = String(init.integrity) + } + + // 24. If init["keepalive"] exists, then set request’s keepalive to it. + if (init.keepalive !== undefined) { + request.keepalive = Boolean(init.keepalive) + } + + // 25. If init["method"] exists, then: + if (init.method !== undefined) { + // 1. Let method be init["method"]. + let method = init.method + + const mayBeNormalized = normalizedMethodRecords[method] + + if (mayBeNormalized !== undefined) { + // Note: Bypass validation DELETE, GET, HEAD, OPTIONS, POST, PUT, PATCH and these lowercase ones + request.method = mayBeNormalized + } else { + // 2. If method is not a method or method is a forbidden method, then + // throw a TypeError. + if (!isValidHTTPToken(method)) { + throw new TypeError(`'${method}' is not a valid HTTP method.`) + } + + const upperCase = method.toUpperCase() + + if (forbiddenMethodsSet.has(upperCase)) { + throw new TypeError(`'${method}' HTTP method is unsupported.`) + } + + // 3. Normalize method. + // https://fetch.spec.whatwg.org/#concept-method-normalize + // Note: must be in uppercase + method = normalizedMethodRecordsBase[upperCase] ?? method + + // 4. Set request’s method to method. + request.method = method + } + + if (!patchMethodWarning && request.method === 'patch') { + process.emitWarning('Using `patch` is highly likely to result in a `405 Method Not Allowed`. `PATCH` is much more likely to succeed.', { + code: 'UNDICI-FETCH-patch' + }) + + patchMethodWarning = true + } + } + + // 26. If init["signal"] exists, then set signal to it. + if (init.signal !== undefined) { + signal = init.signal + } + + // 27. Set this’s request to request. + this.#state = request + + // 28. Set this’s signal to a new AbortSignal object with this’s relevant + // Realm. + // TODO: could this be simplified with AbortSignal.any + // (https://dom.spec.whatwg.org/#dom-abortsignal-any) + const ac = new AbortController() + this.#signal = ac.signal + + // 29. If signal is not null, then make this’s signal follow signal. + if (signal != null) { + if (signal.aborted) { + ac.abort(signal.reason) + } else { + // Keep a strong ref to ac while request object + // is alive. This is needed to prevent AbortController + // from being prematurely garbage collected. + // See, https://github.com/nodejs/undici/issues/1926. + this[kAbortController] = ac + + const acRef = new WeakRef(ac) + const abort = buildAbort(acRef) + + // Third-party AbortControllers may not work with these. + // See, https://github.com/nodejs/undici/pull/1910#issuecomment-1464495619. + try { + // If the max amount of listeners is equal to the default, increase it + // This is only available in node >= v19.9.0 + if (typeof getMaxListeners === 'function' && getMaxListeners(signal) === defaultMaxListeners) { + setMaxListeners(1500, signal) + } + } catch {} + + util.addAbortListener(signal, abort) + // The third argument must be a registry key to be unregistered. + // Without it, you cannot unregister. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry + // abort is used as the unregister key. (because it is unique) + requestFinalizer.register(ac, { signal, abort }, abort) + } + } + + // 30. Set this’s headers to a new Headers object with this’s relevant + // Realm, whose header list is request’s header list and guard is + // "request". + this.#headers = new Headers(kConstruct) + setHeadersList(this.#headers, request.headersList) + setHeadersGuard(this.#headers, 'request') + + // 31. If this’s request’s mode is "no-cors", then: + if (mode === 'no-cors') { + // 1. If this’s request’s method is not a CORS-safelisted method, + // then throw a TypeError. + if (!corsSafeListedMethodsSet.has(request.method)) { + throw new TypeError( + `'${request.method} is unsupported in no-cors mode.` + ) + } + + // 2. Set this’s headers’s guard to "request-no-cors". + setHeadersGuard(this.#headers, 'request-no-cors') + } + + // 32. If init is not empty, then: + if (initHasKey) { + /** @type {HeadersList} */ + const headersList = getHeadersList(this.#headers) + // 1. Let headers be a copy of this’s headers and its associated header + // list. + // 2. If init["headers"] exists, then set headers to init["headers"]. + const headers = init.headers !== undefined ? init.headers : new HeadersList(headersList) + + // 3. Empty this’s headers’s header list. + headersList.clear() + + // 4. If headers is a Headers object, then for each header in its header + // list, append header’s name/header’s value to this’s headers. + if (headers instanceof HeadersList) { + for (const { name, value } of headers.rawValues()) { + headersList.append(name, value, false) + } + // Note: Copy the `set-cookie` meta-data. + headersList.cookies = headers.cookies + } else { + // 5. Otherwise, fill this’s headers with headers. + fillHeaders(this.#headers, headers) + } + } + + // 33. Let inputBody be input’s request’s body if input is a Request + // object; otherwise null. + const inputBody = webidl.is.Request(input) ? input.#state.body : null + + // 34. If either init["body"] exists and is non-null or inputBody is + // non-null, and request’s method is `GET` or `HEAD`, then throw a + // TypeError. + if ( + (init.body != null || inputBody != null) && + (request.method === 'GET' || request.method === 'HEAD') + ) { + throw new TypeError('Request with GET/HEAD method cannot have body.') + } + + // 35. Let initBody be null. + let initBody = null + + // 36. If init["body"] exists and is non-null, then: + if (init.body != null) { + // 1. Let Content-Type be null. + // 2. Set initBody and Content-Type to the result of extracting + // init["body"], with keepalive set to request’s keepalive. + const [extractedBody, contentType] = extractBody( + init.body, + request.keepalive + ) + initBody = extractedBody + + // 3, If Content-Type is non-null and this’s headers’s header list does + // not contain `Content-Type`, then append `Content-Type`/Content-Type to + // this’s headers. + if (contentType && !getHeadersList(this.#headers).contains('content-type', true)) { + this.#headers.append('content-type', contentType, true) + } + } + + // 37. Let inputOrInitBody be initBody if it is non-null; otherwise + // inputBody. + const inputOrInitBody = initBody ?? inputBody + + // 38. If inputOrInitBody is non-null and inputOrInitBody’s source is + // null, then: + if (inputOrInitBody != null && inputOrInitBody.source == null) { + // 1. If initBody is non-null and init["duplex"] does not exist, + // then throw a TypeError. + if (initBody != null && init.duplex == null) { + throw new TypeError('RequestInit: duplex option is required when sending a body.') + } + + // 2. If this’s request’s mode is neither "same-origin" nor "cors", + // then throw a TypeError. + if (request.mode !== 'same-origin' && request.mode !== 'cors') { + throw new TypeError( + 'If request is made from ReadableStream, mode should be "same-origin" or "cors"' + ) + } + + // 3. Set this’s request’s use-CORS-preflight flag. + request.useCORSPreflightFlag = true + } + + // 39. Let finalBody be inputOrInitBody. + let finalBody = inputOrInitBody + + // 40. If initBody is null and inputBody is non-null, then: + if (initBody == null && inputBody != null) { + // 1. If input is unusable, then throw a TypeError. + if (bodyUnusable(input.#state)) { + throw new TypeError( + 'Cannot construct a Request with a Request object that has already been used.' + ) + } + + // 2. Set finalBody to the result of creating a proxy for inputBody. + // https://streams.spec.whatwg.org/#readablestream-create-a-proxy + const identityTransform = new TransformStream() + inputBody.stream.pipeThrough(identityTransform) + finalBody = { + source: inputBody.source, + length: inputBody.length, + stream: identityTransform.readable + } + } + + // 41. Set this’s request’s body to finalBody. + this.#state.body = finalBody + } + + // Returns request’s HTTP method, which is "GET" by default. + get method () { + webidl.brandCheck(this, Request) + + // The method getter steps are to return this’s request’s method. + return this.#state.method + } + + // Returns the URL of request as a string. + get url () { + webidl.brandCheck(this, Request) + + // The url getter steps are to return this’s request’s URL, serialized. + return URLSerializer(this.#state.url) + } + + // Returns a Headers object consisting of the headers associated with request. + // Note that headers added in the network layer by the user agent will not + // be accounted for in this object, e.g., the "Host" header. + get headers () { + webidl.brandCheck(this, Request) + + // The headers getter steps are to return this’s headers. + return this.#headers + } + + // Returns the kind of resource requested by request, e.g., "document" + // or "script". + get destination () { + webidl.brandCheck(this, Request) + + // The destination getter are to return this’s request’s destination. + return this.#state.destination + } + + // Returns the referrer of request. Its value can be a same-origin URL if + // explicitly set in init, the empty string to indicate no referrer, and + // "about:client" when defaulting to the global’s default. This is used + // during fetching to determine the value of the `Referer` header of the + // request being made. + get referrer () { + webidl.brandCheck(this, Request) + + // 1. If this’s request’s referrer is "no-referrer", then return the + // empty string. + if (this.#state.referrer === 'no-referrer') { + return '' + } + + // 2. If this’s request’s referrer is "client", then return + // "about:client". + if (this.#state.referrer === 'client') { + return 'about:client' + } + + // Return this’s request’s referrer, serialized. + return this.#state.referrer.toString() + } + + // Returns the referrer policy associated with request. + // This is used during fetching to compute the value of the request’s + // referrer. + get referrerPolicy () { + webidl.brandCheck(this, Request) + + // The referrerPolicy getter steps are to return this’s request’s referrer policy. + return this.#state.referrerPolicy + } + + // Returns the mode associated with request, which is a string indicating + // whether the request will use CORS, or will be restricted to same-origin + // URLs. + get mode () { + webidl.brandCheck(this, Request) + + // The mode getter steps are to return this’s request’s mode. + return this.#state.mode + } + + // Returns the credentials mode associated with request, + // which is a string indicating whether credentials will be sent with the + // request always, never, or only when sent to a same-origin URL. + get credentials () { + webidl.brandCheck(this, Request) + + // The credentials getter steps are to return this’s request’s credentials mode. + return this.#state.credentials + } + + // Returns the cache mode associated with request, + // which is a string indicating how the request will + // interact with the browser’s cache when fetching. + get cache () { + webidl.brandCheck(this, Request) + + // The cache getter steps are to return this’s request’s cache mode. + return this.#state.cache + } + + // Returns the redirect mode associated with request, + // which is a string indicating how redirects for the + // request will be handled during fetching. A request + // will follow redirects by default. + get redirect () { + webidl.brandCheck(this, Request) + + // The redirect getter steps are to return this’s request’s redirect mode. + return this.#state.redirect + } + + // Returns request’s subresource integrity metadata, which is a + // cryptographic hash of the resource being fetched. Its value + // consists of multiple hashes separated by whitespace. [SRI] + get integrity () { + webidl.brandCheck(this, Request) + + // The integrity getter steps are to return this’s request’s integrity + // metadata. + return this.#state.integrity + } + + // Returns a boolean indicating whether or not request can outlive the + // global in which it was created. + get keepalive () { + webidl.brandCheck(this, Request) + + // The keepalive getter steps are to return this’s request’s keepalive. + return this.#state.keepalive + } + + // Returns a boolean indicating whether or not request is for a reload + // navigation. + get isReloadNavigation () { + webidl.brandCheck(this, Request) + + // The isReloadNavigation getter steps are to return true if this’s + // request’s reload-navigation flag is set; otherwise false. + return this.#state.reloadNavigation + } + + // Returns a boolean indicating whether or not request is for a history + // navigation (a.k.a. back-forward navigation). + get isHistoryNavigation () { + webidl.brandCheck(this, Request) + + // The isHistoryNavigation getter steps are to return true if this’s request’s + // history-navigation flag is set; otherwise false. + return this.#state.historyNavigation + } + + // Returns the signal associated with request, which is an AbortSignal + // object indicating whether or not request has been aborted, and its + // abort event handler. + get signal () { + webidl.brandCheck(this, Request) + + // The signal getter steps are to return this’s signal. + return this.#signal + } + + get body () { + webidl.brandCheck(this, Request) + + return this.#state.body ? this.#state.body.stream : null + } + + get bodyUsed () { + webidl.brandCheck(this, Request) + + return !!this.#state.body && util.isDisturbed(this.#state.body.stream) + } + + get duplex () { + webidl.brandCheck(this, Request) + + return 'half' + } + + // Returns a clone of request. + clone () { + webidl.brandCheck(this, Request) + + // 1. If this is unusable, then throw a TypeError. + if (bodyUnusable(this.#state)) { + throw new TypeError('unusable') + } + + // 2. Let clonedRequest be the result of cloning this’s request. + const clonedRequest = cloneRequest(this.#state) + + // 3. Let clonedRequestObject be the result of creating a Request object, + // given clonedRequest, this’s headers’s guard, and this’s relevant Realm. + // 4. Make clonedRequestObject’s signal follow this’s signal. + const ac = new AbortController() + if (this.signal.aborted) { + ac.abort(this.signal.reason) + } else { + let list = dependentControllerMap.get(this.signal) + if (list === undefined) { + list = new Set() + dependentControllerMap.set(this.signal, list) + } + const acRef = new WeakRef(ac) + list.add(acRef) + util.addAbortListener( + ac.signal, + buildAbort(acRef) + ) + } + + // 4. Return clonedRequestObject. + return fromInnerRequest(clonedRequest, this.#dispatcher, ac.signal, getHeadersGuard(this.#headers)) + } + + [nodeUtil.inspect.custom] (depth, options) { + if (options.depth === null) { + options.depth = 2 + } + + options.colors ??= true + + const properties = { + method: this.method, + url: this.url, + headers: this.headers, + destination: this.destination, + referrer: this.referrer, + referrerPolicy: this.referrerPolicy, + mode: this.mode, + credentials: this.credentials, + cache: this.cache, + redirect: this.redirect, + integrity: this.integrity, + keepalive: this.keepalive, + isReloadNavigation: this.isReloadNavigation, + isHistoryNavigation: this.isHistoryNavigation, + signal: this.signal + } + + return `Request ${nodeUtil.formatWithOptions(options, properties)}` + } + + /** + * @param {Request} request + * @param {AbortSignal} newSignal + */ + static setRequestSignal (request, newSignal) { + request.#signal = newSignal + return request + } + + /** + * @param {Request} request + */ + static getRequestDispatcher (request) { + return request.#dispatcher + } + + /** + * @param {Request} request + * @param {import('../../dispatcher/dispatcher')} newDispatcher + */ + static setRequestDispatcher (request, newDispatcher) { + request.#dispatcher = newDispatcher + } + + /** + * @param {Request} request + * @param {Headers} newHeaders + */ + static setRequestHeaders (request, newHeaders) { + request.#headers = newHeaders + } + + /** + * @param {Request} request + */ + static getRequestState (request) { + return request.#state + } + + /** + * @param {Request} request + * @param {any} newState + */ + static setRequestState (request, newState) { + request.#state = newState + } +} + +const { setRequestSignal, getRequestDispatcher, setRequestDispatcher, setRequestHeaders, getRequestState, setRequestState } = Request +Reflect.deleteProperty(Request, 'setRequestSignal') +Reflect.deleteProperty(Request, 'getRequestDispatcher') +Reflect.deleteProperty(Request, 'setRequestDispatcher') +Reflect.deleteProperty(Request, 'setRequestHeaders') +Reflect.deleteProperty(Request, 'getRequestState') +Reflect.deleteProperty(Request, 'setRequestState') + +mixinBody(Request, getRequestState) + +// https://fetch.spec.whatwg.org/#requests +function makeRequest (init) { + return { + method: init.method ?? 'GET', + localURLsOnly: init.localURLsOnly ?? false, + unsafeRequest: init.unsafeRequest ?? false, + body: init.body ?? null, + client: init.client ?? null, + reservedClient: init.reservedClient ?? null, + replacesClientId: init.replacesClientId ?? '', + window: init.window ?? 'client', + keepalive: init.keepalive ?? false, + serviceWorkers: init.serviceWorkers ?? 'all', + initiator: init.initiator ?? '', + destination: init.destination ?? '', + priority: init.priority ?? null, + origin: init.origin ?? 'client', + policyContainer: init.policyContainer ?? 'client', + referrer: init.referrer ?? 'client', + referrerPolicy: init.referrerPolicy ?? '', + mode: init.mode ?? 'no-cors', + useCORSPreflightFlag: init.useCORSPreflightFlag ?? false, + credentials: init.credentials ?? 'same-origin', + useCredentials: init.useCredentials ?? false, + cache: init.cache ?? 'default', + redirect: init.redirect ?? 'follow', + integrity: init.integrity ?? '', + cryptoGraphicsNonceMetadata: init.cryptoGraphicsNonceMetadata ?? '', + parserMetadata: init.parserMetadata ?? '', + reloadNavigation: init.reloadNavigation ?? false, + historyNavigation: init.historyNavigation ?? false, + userActivation: init.userActivation ?? false, + taintedOrigin: init.taintedOrigin ?? false, + redirectCount: init.redirectCount ?? 0, + responseTainting: init.responseTainting ?? 'basic', + preventNoCacheCacheControlHeaderModification: init.preventNoCacheCacheControlHeaderModification ?? false, + done: init.done ?? false, + timingAllowFailed: init.timingAllowFailed ?? false, + urlList: init.urlList, + url: init.urlList[0], + headersList: init.headersList + ? new HeadersList(init.headersList) + : new HeadersList() + } +} + +// https://fetch.spec.whatwg.org/#concept-request-clone +function cloneRequest (request) { + // To clone a request request, run these steps: + + // 1. Let newRequest be a copy of request, except for its body. + const newRequest = makeRequest({ ...request, body: null }) + + // 2. If request’s body is non-null, set newRequest’s body to the + // result of cloning request’s body. + if (request.body != null) { + newRequest.body = cloneBody(newRequest, request.body) + } + + // 3. Return newRequest. + return newRequest +} + +/** + * @see https://fetch.spec.whatwg.org/#request-create + * @param {any} innerRequest + * @param {import('../../dispatcher/agent')} dispatcher + * @param {AbortSignal} signal + * @param {'request' | 'immutable' | 'request-no-cors' | 'response' | 'none'} guard + * @returns {Request} + */ +function fromInnerRequest (innerRequest, dispatcher, signal, guard) { + const request = new Request(kConstruct) + setRequestState(request, innerRequest) + setRequestDispatcher(request, dispatcher) + setRequestSignal(request, signal) + const headers = new Headers(kConstruct) + setRequestHeaders(request, headers) + setHeadersList(headers, innerRequest.headersList) + setHeadersGuard(headers, guard) + return request +} + +Object.defineProperties(Request.prototype, { + method: kEnumerableProperty, + url: kEnumerableProperty, + headers: kEnumerableProperty, + redirect: kEnumerableProperty, + clone: kEnumerableProperty, + signal: kEnumerableProperty, + duplex: kEnumerableProperty, + destination: kEnumerableProperty, + body: kEnumerableProperty, + bodyUsed: kEnumerableProperty, + isHistoryNavigation: kEnumerableProperty, + isReloadNavigation: kEnumerableProperty, + keepalive: kEnumerableProperty, + integrity: kEnumerableProperty, + cache: kEnumerableProperty, + credentials: kEnumerableProperty, + attribute: kEnumerableProperty, + referrerPolicy: kEnumerableProperty, + referrer: kEnumerableProperty, + mode: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'Request', + configurable: true + } +}) + +webidl.is.Request = webidl.util.MakeTypeAssertion(Request) + +// https://fetch.spec.whatwg.org/#requestinfo +webidl.converters.RequestInfo = function (V, prefix, argument) { + if (typeof V === 'string') { + return webidl.converters.USVString(V) + } + + if (webidl.is.Request(V)) { + return V + } + + return webidl.converters.USVString(V) +} + +// https://fetch.spec.whatwg.org/#requestinit +webidl.converters.RequestInit = webidl.dictionaryConverter([ + { + key: 'method', + converter: webidl.converters.ByteString + }, + { + key: 'headers', + converter: webidl.converters.HeadersInit + }, + { + key: 'body', + converter: webidl.nullableConverter( + webidl.converters.BodyInit + ) + }, + { + key: 'referrer', + converter: webidl.converters.USVString + }, + { + key: 'referrerPolicy', + converter: webidl.converters.DOMString, + // https://w3c.github.io/webappsec-referrer-policy/#referrer-policy + allowedValues: referrerPolicy + }, + { + key: 'mode', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#concept-request-mode + allowedValues: requestMode + }, + { + key: 'credentials', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#requestcredentials + allowedValues: requestCredentials + }, + { + key: 'cache', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#requestcache + allowedValues: requestCache + }, + { + key: 'redirect', + converter: webidl.converters.DOMString, + // https://fetch.spec.whatwg.org/#requestredirect + allowedValues: requestRedirect + }, + { + key: 'integrity', + converter: webidl.converters.DOMString + }, + { + key: 'keepalive', + converter: webidl.converters.boolean + }, + { + key: 'signal', + converter: webidl.nullableConverter( + (signal) => webidl.converters.AbortSignal( + signal, + 'RequestInit', + 'signal' + ) + ) + }, + { + key: 'window', + converter: webidl.converters.any + }, + { + key: 'duplex', + converter: webidl.converters.DOMString, + allowedValues: requestDuplex + }, + { + key: 'dispatcher', // undici specific option + converter: webidl.converters.any + } +]) + +module.exports = { + Request, + makeRequest, + fromInnerRequest, + cloneRequest, + getRequestDispatcher, + getRequestState +} diff --git a/lib/web/fetch/response.js b/lib/web/fetch/response.js new file mode 100644 index 0000000..be82c25 --- /dev/null +++ b/lib/web/fetch/response.js @@ -0,0 +1,636 @@ +'use strict' + +const { Headers, HeadersList, fill, getHeadersGuard, setHeadersGuard, setHeadersList } = require('./headers') +const { extractBody, cloneBody, mixinBody, hasFinalizationRegistry, streamRegistry, bodyUnusable } = require('./body') +const util = require('../../core/util') +const nodeUtil = require('node:util') +const { kEnumerableProperty } = util +const { + isValidReasonPhrase, + isCancelled, + isAborted, + serializeJavascriptValueToJSONString, + isErrorLike, + isomorphicEncode, + environmentSettingsObject: relevantRealm +} = require('./util') +const { + redirectStatusSet, + nullBodyStatus +} = require('./constants') +const { webidl } = require('./webidl') +const { URLSerializer } = require('./data-url') +const { kConstruct } = require('../../core/symbols') +const assert = require('node:assert') +const { types } = require('node:util') + +const textEncoder = new TextEncoder('utf-8') + +// https://fetch.spec.whatwg.org/#response-class +class Response { + /** @type {Headers} */ + #headers + + #state + + // Creates network error Response. + static error () { + // The static error() method steps are to return the result of creating a + // Response object, given a new network error, "immutable", and this’s + // relevant Realm. + const responseObject = fromInnerResponse(makeNetworkError(), 'immutable') + + return responseObject + } + + // https://fetch.spec.whatwg.org/#dom-response-json + static json (data, init = undefined) { + webidl.argumentLengthCheck(arguments, 1, 'Response.json') + + if (init !== null) { + init = webidl.converters.ResponseInit(init) + } + + // 1. Let bytes the result of running serialize a JavaScript value to JSON bytes on data. + const bytes = textEncoder.encode( + serializeJavascriptValueToJSONString(data) + ) + + // 2. Let body be the result of extracting bytes. + const body = extractBody(bytes) + + // 3. Let responseObject be the result of creating a Response object, given a new response, + // "response", and this’s relevant Realm. + const responseObject = fromInnerResponse(makeResponse({}), 'response') + + // 4. Perform initialize a response given responseObject, init, and (body, "application/json"). + initializeResponse(responseObject, init, { body: body[0], type: 'application/json' }) + + // 5. Return responseObject. + return responseObject + } + + // Creates a redirect Response that redirects to url with status status. + static redirect (url, status = 302) { + webidl.argumentLengthCheck(arguments, 1, 'Response.redirect') + + url = webidl.converters.USVString(url) + status = webidl.converters['unsigned short'](status) + + // 1. Let parsedURL be the result of parsing url with current settings + // object’s API base URL. + // 2. If parsedURL is failure, then throw a TypeError. + // TODO: base-URL? + let parsedURL + try { + parsedURL = new URL(url, relevantRealm.settingsObject.baseUrl) + } catch (err) { + throw new TypeError(`Failed to parse URL from ${url}`, { cause: err }) + } + + // 3. If status is not a redirect status, then throw a RangeError. + if (!redirectStatusSet.has(status)) { + throw new RangeError(`Invalid status code ${status}`) + } + + // 4. Let responseObject be the result of creating a Response object, + // given a new response, "immutable", and this’s relevant Realm. + const responseObject = fromInnerResponse(makeResponse({}), 'immutable') + + // 5. Set responseObject’s response’s status to status. + responseObject.#state.status = status + + // 6. Let value be parsedURL, serialized and isomorphic encoded. + const value = isomorphicEncode(URLSerializer(parsedURL)) + + // 7. Append `Location`/value to responseObject’s response’s header list. + responseObject.#state.headersList.append('location', value, true) + + // 8. Return responseObject. + return responseObject + } + + // https://fetch.spec.whatwg.org/#dom-response + constructor (body = null, init = undefined) { + webidl.util.markAsUncloneable(this) + + if (body === kConstruct) { + return + } + + if (body !== null) { + body = webidl.converters.BodyInit(body) + } + + init = webidl.converters.ResponseInit(init) + + // 1. Set this’s response to a new response. + this.#state = makeResponse({}) + + // 2. Set this’s headers to a new Headers object with this’s relevant + // Realm, whose header list is this’s response’s header list and guard + // is "response". + this.#headers = new Headers(kConstruct) + setHeadersGuard(this.#headers, 'response') + setHeadersList(this.#headers, this.#state.headersList) + + // 3. Let bodyWithType be null. + let bodyWithType = null + + // 4. If body is non-null, then set bodyWithType to the result of extracting body. + if (body != null) { + const [extractedBody, type] = extractBody(body) + bodyWithType = { body: extractedBody, type } + } + + // 5. Perform initialize a response given this, init, and bodyWithType. + initializeResponse(this, init, bodyWithType) + } + + // Returns response’s type, e.g., "cors". + get type () { + webidl.brandCheck(this, Response) + + // The type getter steps are to return this’s response’s type. + return this.#state.type + } + + // Returns response’s URL, if it has one; otherwise the empty string. + get url () { + webidl.brandCheck(this, Response) + + const urlList = this.#state.urlList + + // The url getter steps are to return the empty string if this’s + // response’s URL is null; otherwise this’s response’s URL, + // serialized with exclude fragment set to true. + const url = urlList[urlList.length - 1] ?? null + + if (url === null) { + return '' + } + + return URLSerializer(url, true) + } + + // Returns whether response was obtained through a redirect. + get redirected () { + webidl.brandCheck(this, Response) + + // The redirected getter steps are to return true if this’s response’s URL + // list has more than one item; otherwise false. + return this.#state.urlList.length > 1 + } + + // Returns response’s status. + get status () { + webidl.brandCheck(this, Response) + + // The status getter steps are to return this’s response’s status. + return this.#state.status + } + + // Returns whether response’s status is an ok status. + get ok () { + webidl.brandCheck(this, Response) + + // The ok getter steps are to return true if this’s response’s status is an + // ok status; otherwise false. + return this.#state.status >= 200 && this.#state.status <= 299 + } + + // Returns response’s status message. + get statusText () { + webidl.brandCheck(this, Response) + + // The statusText getter steps are to return this’s response’s status + // message. + return this.#state.statusText + } + + // Returns response’s headers as Headers. + get headers () { + webidl.brandCheck(this, Response) + + // The headers getter steps are to return this’s headers. + return this.#headers + } + + get body () { + webidl.brandCheck(this, Response) + + return this.#state.body ? this.#state.body.stream : null + } + + get bodyUsed () { + webidl.brandCheck(this, Response) + + return !!this.#state.body && util.isDisturbed(this.#state.body.stream) + } + + // Returns a clone of response. + clone () { + webidl.brandCheck(this, Response) + + // 1. If this is unusable, then throw a TypeError. + if (bodyUnusable(this.#state)) { + throw webidl.errors.exception({ + header: 'Response.clone', + message: 'Body has already been consumed.' + }) + } + + // 2. Let clonedResponse be the result of cloning this’s response. + const clonedResponse = cloneResponse(this.#state) + + // 3. Return the result of creating a Response object, given + // clonedResponse, this’s headers’s guard, and this’s relevant Realm. + return fromInnerResponse(clonedResponse, getHeadersGuard(this.#headers)) + } + + [nodeUtil.inspect.custom] (depth, options) { + if (options.depth === null) { + options.depth = 2 + } + + options.colors ??= true + + const properties = { + status: this.status, + statusText: this.statusText, + headers: this.headers, + body: this.body, + bodyUsed: this.bodyUsed, + ok: this.ok, + redirected: this.redirected, + type: this.type, + url: this.url + } + + return `Response ${nodeUtil.formatWithOptions(options, properties)}` + } + + /** + * @param {Response} response + */ + static getResponseHeaders (response) { + return response.#headers + } + + /** + * @param {Response} response + * @param {Headers} newHeaders + */ + static setResponseHeaders (response, newHeaders) { + response.#headers = newHeaders + } + + /** + * @param {Response} response + */ + static getResponseState (response) { + return response.#state + } + + /** + * @param {Response} response + * @param {any} newState + */ + static setResponseState (response, newState) { + response.#state = newState + } +} + +const { getResponseHeaders, setResponseHeaders, getResponseState, setResponseState } = Response +Reflect.deleteProperty(Response, 'getResponseHeaders') +Reflect.deleteProperty(Response, 'setResponseHeaders') +Reflect.deleteProperty(Response, 'getResponseState') +Reflect.deleteProperty(Response, 'setResponseState') + +mixinBody(Response, getResponseState) + +Object.defineProperties(Response.prototype, { + type: kEnumerableProperty, + url: kEnumerableProperty, + status: kEnumerableProperty, + ok: kEnumerableProperty, + redirected: kEnumerableProperty, + statusText: kEnumerableProperty, + headers: kEnumerableProperty, + clone: kEnumerableProperty, + body: kEnumerableProperty, + bodyUsed: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'Response', + configurable: true + } +}) + +Object.defineProperties(Response, { + json: kEnumerableProperty, + redirect: kEnumerableProperty, + error: kEnumerableProperty +}) + +// https://fetch.spec.whatwg.org/#concept-response-clone +function cloneResponse (response) { + // To clone a response response, run these steps: + + // 1. If response is a filtered response, then return a new identical + // filtered response whose internal response is a clone of response’s + // internal response. + if (response.internalResponse) { + return filterResponse( + cloneResponse(response.internalResponse), + response.type + ) + } + + // 2. Let newResponse be a copy of response, except for its body. + const newResponse = makeResponse({ ...response, body: null }) + + // 3. If response’s body is non-null, then set newResponse’s body to the + // result of cloning response’s body. + if (response.body != null) { + newResponse.body = cloneBody(newResponse, response.body) + } + + // 4. Return newResponse. + return newResponse +} + +function makeResponse (init) { + return { + aborted: false, + rangeRequested: false, + timingAllowPassed: false, + requestIncludesCredentials: false, + type: 'default', + status: 200, + timingInfo: null, + cacheState: '', + statusText: '', + ...init, + headersList: init?.headersList + ? new HeadersList(init?.headersList) + : new HeadersList(), + urlList: init?.urlList ? [...init.urlList] : [] + } +} + +function makeNetworkError (reason) { + const isError = isErrorLike(reason) + return makeResponse({ + type: 'error', + status: 0, + error: isError + ? reason + : new Error(reason ? String(reason) : reason), + aborted: reason && reason.name === 'AbortError' + }) +} + +// @see https://fetch.spec.whatwg.org/#concept-network-error +function isNetworkError (response) { + return ( + // A network error is a response whose type is "error", + response.type === 'error' && + // status is 0 + response.status === 0 + ) +} + +function makeFilteredResponse (response, state) { + state = { + internalResponse: response, + ...state + } + + return new Proxy(response, { + get (target, p) { + return p in state ? state[p] : target[p] + }, + set (target, p, value) { + assert(!(p in state)) + target[p] = value + return true + } + }) +} + +// https://fetch.spec.whatwg.org/#concept-filtered-response +function filterResponse (response, type) { + // Set response to the following filtered response with response as its + // internal response, depending on request’s response tainting: + if (type === 'basic') { + // A basic filtered response is a filtered response whose type is "basic" + // and header list excludes any headers in internal response’s header list + // whose name is a forbidden response-header name. + + // Note: undici does not implement forbidden response-header names + return makeFilteredResponse(response, { + type: 'basic', + headersList: response.headersList + }) + } else if (type === 'cors') { + // A CORS filtered response is a filtered response whose type is "cors" + // and header list excludes any headers in internal response’s header + // list whose name is not a CORS-safelisted response-header name, given + // internal response’s CORS-exposed header-name list. + + // Note: undici does not implement CORS-safelisted response-header names + return makeFilteredResponse(response, { + type: 'cors', + headersList: response.headersList + }) + } else if (type === 'opaque') { + // An opaque filtered response is a filtered response whose type is + // "opaque", URL list is the empty list, status is 0, status message + // is the empty byte sequence, header list is empty, and body is null. + + return makeFilteredResponse(response, { + type: 'opaque', + urlList: Object.freeze([]), + status: 0, + statusText: '', + body: null + }) + } else if (type === 'opaqueredirect') { + // An opaque-redirect filtered response is a filtered response whose type + // is "opaqueredirect", status is 0, status message is the empty byte + // sequence, header list is empty, and body is null. + + return makeFilteredResponse(response, { + type: 'opaqueredirect', + status: 0, + statusText: '', + headersList: [], + body: null + }) + } else { + assert(false) + } +} + +// https://fetch.spec.whatwg.org/#appropriate-network-error +function makeAppropriateNetworkError (fetchParams, err = null) { + // 1. Assert: fetchParams is canceled. + assert(isCancelled(fetchParams)) + + // 2. Return an aborted network error if fetchParams is aborted; + // otherwise return a network error. + return isAborted(fetchParams) + ? makeNetworkError(Object.assign(new DOMException('The operation was aborted.', 'AbortError'), { cause: err })) + : makeNetworkError(Object.assign(new DOMException('Request was cancelled.'), { cause: err })) +} + +// https://whatpr.org/fetch/1392.html#initialize-a-response +function initializeResponse (response, init, body) { + // 1. If init["status"] is not in the range 200 to 599, inclusive, then + // throw a RangeError. + if (init.status !== null && (init.status < 200 || init.status > 599)) { + throw new RangeError('init["status"] must be in the range of 200 to 599, inclusive.') + } + + // 2. If init["statusText"] does not match the reason-phrase token production, + // then throw a TypeError. + if ('statusText' in init && init.statusText != null) { + // See, https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2: + // reason-phrase = *( HTAB / SP / VCHAR / obs-text ) + if (!isValidReasonPhrase(String(init.statusText))) { + throw new TypeError('Invalid statusText') + } + } + + // 3. Set response’s response’s status to init["status"]. + if ('status' in init && init.status != null) { + getResponseState(response).status = init.status + } + + // 4. Set response’s response’s status message to init["statusText"]. + if ('statusText' in init && init.statusText != null) { + getResponseState(response).statusText = init.statusText + } + + // 5. If init["headers"] exists, then fill response’s headers with init["headers"]. + if ('headers' in init && init.headers != null) { + fill(getResponseHeaders(response), init.headers) + } + + // 6. If body was given, then: + if (body) { + // 1. If response's status is a null body status, then throw a TypeError. + if (nullBodyStatus.includes(response.status)) { + throw webidl.errors.exception({ + header: 'Response constructor', + message: `Invalid response status code ${response.status}` + }) + } + + // 2. Set response's body to body's body. + getResponseState(response).body = body.body + + // 3. If body's type is non-null and response's header list does not contain + // `Content-Type`, then append (`Content-Type`, body's type) to response's header list. + if (body.type != null && !getResponseState(response).headersList.contains('content-type', true)) { + getResponseState(response).headersList.append('content-type', body.type, true) + } + } +} + +/** + * @see https://fetch.spec.whatwg.org/#response-create + * @param {any} innerResponse + * @param {'request' | 'immutable' | 'request-no-cors' | 'response' | 'none'} guard + * @returns {Response} + */ +function fromInnerResponse (innerResponse, guard) { + const response = new Response(kConstruct) + setResponseState(response, innerResponse) + const headers = new Headers(kConstruct) + setResponseHeaders(response, headers) + setHeadersList(headers, innerResponse.headersList) + setHeadersGuard(headers, guard) + + if (hasFinalizationRegistry && innerResponse.body?.stream) { + // If the target (response) is reclaimed, the cleanup callback may be called at some point with + // the held value provided for it (innerResponse.body.stream). The held value can be any value: + // a primitive or an object, even undefined. If the held value is an object, the registry keeps + // a strong reference to it (so it can pass it to the cleanup callback later). Reworded from + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry + streamRegistry.register(response, new WeakRef(innerResponse.body.stream)) + } + + return response +} + +// https://fetch.spec.whatwg.org/#typedefdef-xmlhttprequestbodyinit +webidl.converters.XMLHttpRequestBodyInit = function (V, prefix, name) { + if (typeof V === 'string') { + return webidl.converters.USVString(V, prefix, name) + } + + if (webidl.is.Blob(V)) { + return V + } + + if (ArrayBuffer.isView(V) || types.isArrayBuffer(V)) { + return V + } + + if (webidl.is.FormData(V)) { + return V + } + + if (webidl.is.URLSearchParams(V)) { + return V + } + + return webidl.converters.DOMString(V, prefix, name) +} + +// https://fetch.spec.whatwg.org/#bodyinit +webidl.converters.BodyInit = function (V, prefix, argument) { + if (webidl.is.ReadableStream(V)) { + return V + } + + // Note: the spec doesn't include async iterables, + // this is an undici extension. + if (V?.[Symbol.asyncIterator]) { + return V + } + + return webidl.converters.XMLHttpRequestBodyInit(V, prefix, argument) +} + +webidl.converters.ResponseInit = webidl.dictionaryConverter([ + { + key: 'status', + converter: webidl.converters['unsigned short'], + defaultValue: () => 200 + }, + { + key: 'statusText', + converter: webidl.converters.ByteString, + defaultValue: () => '' + }, + { + key: 'headers', + converter: webidl.converters.HeadersInit + } +]) + +webidl.is.Response = webidl.util.MakeTypeAssertion(Response) + +module.exports = { + isNetworkError, + makeNetworkError, + makeResponse, + makeAppropriateNetworkError, + filterResponse, + Response, + cloneResponse, + fromInnerResponse, + getResponseState +} diff --git a/lib/web/fetch/util.js b/lib/web/fetch/util.js new file mode 100644 index 0000000..be60600 --- /dev/null +++ b/lib/web/fetch/util.js @@ -0,0 +1,1782 @@ +'use strict' + +const { Transform } = require('node:stream') +const zlib = require('node:zlib') +const { redirectStatusSet, referrerPolicyTokens, badPortsSet } = require('./constants') +const { getGlobalOrigin } = require('./global') +const { collectASequenceOfCodePoints, collectAnHTTPQuotedString, removeChars, parseMIMEType } = require('./data-url') +const { performance } = require('node:perf_hooks') +const { ReadableStreamFrom, isValidHTTPToken, normalizedMethodRecordsBase } = require('../../core/util') +const assert = require('node:assert') +const { isUint8Array } = require('node:util/types') +const { webidl } = require('./webidl') + +let supportedHashes = [] + +// https://nodejs.org/api/crypto.html#determining-if-crypto-support-is-unavailable +/** @type {import('crypto')} */ +let crypto +try { + crypto = require('node:crypto') + const possibleRelevantHashes = ['sha256', 'sha384', 'sha512'] + supportedHashes = crypto.getHashes().filter((hash) => possibleRelevantHashes.includes(hash)) +/* c8 ignore next 3 */ +} catch { + +} + +function responseURL (response) { + // https://fetch.spec.whatwg.org/#responses + // A response has an associated URL. It is a pointer to the last URL + // in response’s URL list and null if response’s URL list is empty. + const urlList = response.urlList + const length = urlList.length + return length === 0 ? null : urlList[length - 1].toString() +} + +// https://fetch.spec.whatwg.org/#concept-response-location-url +function responseLocationURL (response, requestFragment) { + // 1. If response’s status is not a redirect status, then return null. + if (!redirectStatusSet.has(response.status)) { + return null + } + + // 2. Let location be the result of extracting header list values given + // `Location` and response’s header list. + let location = response.headersList.get('location', true) + + // 3. If location is a header value, then set location to the result of + // parsing location with response’s URL. + if (location !== null && isValidHeaderValue(location)) { + if (!isValidEncodedURL(location)) { + // Some websites respond location header in UTF-8 form without encoding them as ASCII + // and major browsers redirect them to correctly UTF-8 encoded addresses. + // Here, we handle that behavior in the same way. + location = normalizeBinaryStringToUtf8(location) + } + location = new URL(location, responseURL(response)) + } + + // 4. If location is a URL whose fragment is null, then set location’s + // fragment to requestFragment. + if (location && !location.hash) { + location.hash = requestFragment + } + + // 5. Return location. + return location +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc1738#section-2.2 + * @param {string} url + * @returns {boolean} + */ +function isValidEncodedURL (url) { + for (let i = 0; i < url.length; ++i) { + const code = url.charCodeAt(i) + + if ( + code > 0x7E || // Non-US-ASCII + DEL + code < 0x20 // Control characters NUL - US + ) { + return false + } + } + return true +} + +/** + * If string contains non-ASCII characters, assumes it's UTF-8 encoded and decodes it. + * Since UTF-8 is a superset of ASCII, this will work for ASCII strings as well. + * @param {string} value + * @returns {string} + */ +function normalizeBinaryStringToUtf8 (value) { + return Buffer.from(value, 'binary').toString('utf8') +} + +/** @returns {URL} */ +function requestCurrentURL (request) { + return request.urlList[request.urlList.length - 1] +} + +function requestBadPort (request) { + // 1. Let url be request’s current URL. + const url = requestCurrentURL(request) + + // 2. If url’s scheme is an HTTP(S) scheme and url’s port is a bad port, + // then return blocked. + if (urlIsHttpHttpsScheme(url) && badPortsSet.has(url.port)) { + return 'blocked' + } + + // 3. Return allowed. + return 'allowed' +} + +function isErrorLike (object) { + return object instanceof Error || ( + object?.constructor?.name === 'Error' || + object?.constructor?.name === 'DOMException' + ) +} + +// Check whether |statusText| is a ByteString and +// matches the Reason-Phrase token production. +// RFC 2616: https://tools.ietf.org/html/rfc2616 +// RFC 7230: https://tools.ietf.org/html/rfc7230 +// "reason-phrase = *( HTAB / SP / VCHAR / obs-text )" +// https://github.com/chromium/chromium/blob/94.0.4604.1/third_party/blink/renderer/core/fetch/response.cc#L116 +function isValidReasonPhrase (statusText) { + for (let i = 0; i < statusText.length; ++i) { + const c = statusText.charCodeAt(i) + if ( + !( + ( + c === 0x09 || // HTAB + (c >= 0x20 && c <= 0x7e) || // SP / VCHAR + (c >= 0x80 && c <= 0xff) + ) // obs-text + ) + ) { + return false + } + } + return true +} + +/** + * @see https://fetch.spec.whatwg.org/#header-name + * @param {string} potentialValue + */ +const isValidHeaderName = isValidHTTPToken + +/** + * @see https://fetch.spec.whatwg.org/#header-value + * @param {string} potentialValue + */ +function isValidHeaderValue (potentialValue) { + // - Has no leading or trailing HTTP tab or space bytes. + // - Contains no 0x00 (NUL) or HTTP newline bytes. + return ( + potentialValue[0] === '\t' || + potentialValue[0] === ' ' || + potentialValue[potentialValue.length - 1] === '\t' || + potentialValue[potentialValue.length - 1] === ' ' || + potentialValue.includes('\n') || + potentialValue.includes('\r') || + potentialValue.includes('\0') + ) === false +} + +/** + * Parse a referrer policy from a Referrer-Policy header + * @see https://w3c.github.io/webappsec-referrer-policy/#parse-referrer-policy-from-header + */ +function parseReferrerPolicy (actualResponse) { + // 1. Let policy-tokens be the result of extracting header list values given `Referrer-Policy` and response’s header list. + const policyHeader = (actualResponse.headersList.get('referrer-policy', true) ?? '').split(',') + + // 2. Let policy be the empty string. + let policy = '' + + // 3. For each token in policy-tokens, if token is a referrer policy and token is not the empty string, then set policy to token. + + // Note: As the referrer-policy can contain multiple policies + // separated by comma, we need to loop through all of them + // and pick the first valid one. + // Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#specify_a_fallback_policy + if (policyHeader.length) { + // The right-most policy takes precedence. + // The left-most policy is the fallback. + for (let i = policyHeader.length; i !== 0; i--) { + const token = policyHeader[i - 1].trim() + if (referrerPolicyTokens.has(token)) { + policy = token + break + } + } + } + + // 4. Return policy. + return policy +} + +/** + * Given a request request and a response actualResponse, this algorithm + * updates request’s referrer policy according to the Referrer-Policy + * header (if any) in actualResponse. + * @see https://w3c.github.io/webappsec-referrer-policy/#set-requests-referrer-policy-on-redirect + * @param {import('./request').Request} request + * @param {import('./response').Response} actualResponse + */ +function setRequestReferrerPolicyOnRedirect (request, actualResponse) { + // 1. Let policy be the result of executing § 8.1 Parse a referrer policy + // from a Referrer-Policy header on actualResponse. + const policy = parseReferrerPolicy(actualResponse) + + // 2. If policy is not the empty string, then set request’s referrer policy to policy. + if (policy !== '') { + request.referrerPolicy = policy + } +} + +// https://fetch.spec.whatwg.org/#cross-origin-resource-policy-check +function crossOriginResourcePolicyCheck () { + // TODO + return 'allowed' +} + +// https://fetch.spec.whatwg.org/#concept-cors-check +function corsCheck () { + // TODO + return 'success' +} + +// https://fetch.spec.whatwg.org/#concept-tao-check +function TAOCheck () { + // TODO + return 'success' +} + +function appendFetchMetadata (httpRequest) { + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-dest-header + // TODO + + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-mode-header + + // 1. Assert: r’s url is a potentially trustworthy URL. + // TODO + + // 2. Let header be a Structured Header whose value is a token. + let header = null + + // 3. Set header’s value to r’s mode. + header = httpRequest.mode + + // 4. Set a structured field value `Sec-Fetch-Mode`/header in r’s header list. + httpRequest.headersList.set('sec-fetch-mode', header, true) + + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-site-header + // TODO + + // https://w3c.github.io/webappsec-fetch-metadata/#sec-fetch-user-header + // TODO +} + +// https://fetch.spec.whatwg.org/#append-a-request-origin-header +function appendRequestOriginHeader (request) { + // 1. Let serializedOrigin be the result of byte-serializing a request origin + // with request. + // TODO: implement "byte-serializing a request origin" + let serializedOrigin = request.origin + + // - "'client' is changed to an origin during fetching." + // This doesn't happen in undici (in most cases) because undici, by default, + // has no concept of origin. + // - request.origin can also be set to request.client.origin (client being + // an environment settings object), which is undefined without using + // setGlobalOrigin. + if (serializedOrigin === 'client' || serializedOrigin === undefined) { + return + } + + // 2. If request’s response tainting is "cors" or request’s mode is "websocket", + // then append (`Origin`, serializedOrigin) to request’s header list. + // 3. Otherwise, if request’s method is neither `GET` nor `HEAD`, then: + if (request.responseTainting === 'cors' || request.mode === 'websocket') { + request.headersList.append('origin', serializedOrigin, true) + } else if (request.method !== 'GET' && request.method !== 'HEAD') { + // 1. Switch on request’s referrer policy: + switch (request.referrerPolicy) { + case 'no-referrer': + // Set serializedOrigin to `null`. + serializedOrigin = null + break + case 'no-referrer-when-downgrade': + case 'strict-origin': + case 'strict-origin-when-cross-origin': + // If request’s origin is a tuple origin, its scheme is "https", and + // request’s current URL’s scheme is not "https", then set + // serializedOrigin to `null`. + if (request.origin && urlHasHttpsScheme(request.origin) && !urlHasHttpsScheme(requestCurrentURL(request))) { + serializedOrigin = null + } + break + case 'same-origin': + // If request’s origin is not same origin with request’s current URL’s + // origin, then set serializedOrigin to `null`. + if (!sameOrigin(request, requestCurrentURL(request))) { + serializedOrigin = null + } + break + default: + // Do nothing. + } + + // 2. Append (`Origin`, serializedOrigin) to request’s header list. + request.headersList.append('origin', serializedOrigin, true) + } +} + +// https://w3c.github.io/hr-time/#dfn-coarsen-time +function coarsenTime (timestamp, crossOriginIsolatedCapability) { + // TODO + return timestamp +} + +// https://fetch.spec.whatwg.org/#clamp-and-coarsen-connection-timing-info +function clampAndCoarsenConnectionTimingInfo (connectionTimingInfo, defaultStartTime, crossOriginIsolatedCapability) { + if (!connectionTimingInfo?.startTime || connectionTimingInfo.startTime < defaultStartTime) { + return { + domainLookupStartTime: defaultStartTime, + domainLookupEndTime: defaultStartTime, + connectionStartTime: defaultStartTime, + connectionEndTime: defaultStartTime, + secureConnectionStartTime: defaultStartTime, + ALPNNegotiatedProtocol: connectionTimingInfo?.ALPNNegotiatedProtocol + } + } + + return { + domainLookupStartTime: coarsenTime(connectionTimingInfo.domainLookupStartTime, crossOriginIsolatedCapability), + domainLookupEndTime: coarsenTime(connectionTimingInfo.domainLookupEndTime, crossOriginIsolatedCapability), + connectionStartTime: coarsenTime(connectionTimingInfo.connectionStartTime, crossOriginIsolatedCapability), + connectionEndTime: coarsenTime(connectionTimingInfo.connectionEndTime, crossOriginIsolatedCapability), + secureConnectionStartTime: coarsenTime(connectionTimingInfo.secureConnectionStartTime, crossOriginIsolatedCapability), + ALPNNegotiatedProtocol: connectionTimingInfo.ALPNNegotiatedProtocol + } +} + +// https://w3c.github.io/hr-time/#dfn-coarsened-shared-current-time +function coarsenedSharedCurrentTime (crossOriginIsolatedCapability) { + return coarsenTime(performance.now(), crossOriginIsolatedCapability) +} + +// https://fetch.spec.whatwg.org/#create-an-opaque-timing-info +function createOpaqueTimingInfo (timingInfo) { + return { + startTime: timingInfo.startTime ?? 0, + redirectStartTime: 0, + redirectEndTime: 0, + postRedirectStartTime: timingInfo.startTime ?? 0, + finalServiceWorkerStartTime: 0, + finalNetworkResponseStartTime: 0, + finalNetworkRequestStartTime: 0, + endTime: 0, + encodedBodySize: 0, + decodedBodySize: 0, + finalConnectionTimingInfo: null + } +} + +// https://html.spec.whatwg.org/multipage/origin.html#policy-container +function makePolicyContainer () { + // Note: the fetch spec doesn't make use of embedder policy or CSP list + return { + referrerPolicy: 'strict-origin-when-cross-origin' + } +} + +// https://html.spec.whatwg.org/multipage/origin.html#clone-a-policy-container +function clonePolicyContainer (policyContainer) { + return { + referrerPolicy: policyContainer.referrerPolicy + } +} + +/** + * Determine request’s Referrer + * + * @see https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer + */ +function determineRequestsReferrer (request) { + // Given a request request, we can determine the correct referrer information + // to send by examining its referrer policy as detailed in the following + // steps, which return either no referrer or a URL: + + // 1. Let policy be request's referrer policy. + const policy = request.referrerPolicy + + // Note: policy cannot (shouldn't) be null or an empty string. + assert(policy) + + // 2. Let environment be request’s client. + + let referrerSource = null + + // 3. Switch on request’s referrer: + + // "client" + if (request.referrer === 'client') { + // Note: node isn't a browser and doesn't implement document/iframes, + // so we bypass this step and replace it with our own. + + const globalOrigin = getGlobalOrigin() + + if (!globalOrigin || globalOrigin.origin === 'null') { + return 'no-referrer' + } + + // Note: we need to clone it as it's mutated + referrerSource = new URL(globalOrigin) + // a URL + } else if (webidl.is.URL(request.referrer)) { + // Let referrerSource be request’s referrer. + referrerSource = request.referrer + } + + // 4. Let request’s referrerURL be the result of stripping referrerSource for + // use as a referrer. + let referrerURL = stripURLForReferrer(referrerSource) + + // 5. Let referrerOrigin be the result of stripping referrerSource for use as + // a referrer, with the origin-only flag set to true. + const referrerOrigin = stripURLForReferrer(referrerSource, true) + + // 6. If the result of serializing referrerURL is a string whose length is + // greater than 4096, set referrerURL to referrerOrigin. + if (referrerURL.toString().length > 4096) { + referrerURL = referrerOrigin + } + + // 7. The user agent MAY alter referrerURL or referrerOrigin at this point + // to enforce arbitrary policy considerations in the interests of minimizing + // data leakage. For example, the user agent could strip the URL down to an + // origin, modify its host, replace it with an empty string, etc. + + // 8. Execute the switch statements corresponding to the value of policy: + switch (policy) { + case 'no-referrer': + // Return no referrer + return 'no-referrer' + case 'origin': + // Return referrerOrigin + if (referrerOrigin != null) { + return referrerOrigin + } + return stripURLForReferrer(referrerSource, true) + case 'unsafe-url': + // Return referrerURL. + return referrerURL + case 'strict-origin': { + const currentURL = requestCurrentURL(request) + + // 1. If referrerURL is a potentially trustworthy URL and request’s + // current URL is not a potentially trustworthy URL, then return no + // referrer. + if (isURLPotentiallyTrustworthy(referrerURL) && !isURLPotentiallyTrustworthy(currentURL)) { + return 'no-referrer' + } + // 2. Return referrerOrigin + return referrerOrigin + } + case 'strict-origin-when-cross-origin': { + const currentURL = requestCurrentURL(request) + + // 1. If the origin of referrerURL and the origin of request’s current + // URL are the same, then return referrerURL. + if (sameOrigin(referrerURL, currentURL)) { + return referrerURL + } + + // 2. If referrerURL is a potentially trustworthy URL and request’s + // current URL is not a potentially trustworthy URL, then return no + // referrer. + if (isURLPotentiallyTrustworthy(referrerURL) && !isURLPotentiallyTrustworthy(currentURL)) { + return 'no-referrer' + } + + // 3. Return referrerOrigin. + return referrerOrigin + } + case 'same-origin': + // 1. If the origin of referrerURL and the origin of request’s current + // URL are the same, then return referrerURL. + if (sameOrigin(request, referrerURL)) { + return referrerURL + } + // 2. Return no referrer. + return 'no-referrer' + case 'origin-when-cross-origin': + // 1. If the origin of referrerURL and the origin of request’s current + // URL are the same, then return referrerURL. + if (sameOrigin(request, referrerURL)) { + return referrerURL + } + // 2. Return referrerOrigin. + return referrerOrigin + case 'no-referrer-when-downgrade': { + const currentURL = requestCurrentURL(request) + + // 1. If referrerURL is a potentially trustworthy URL and request’s + // current URL is not a potentially trustworthy URL, then return no + // referrer. + if (isURLPotentiallyTrustworthy(referrerURL) && !isURLPotentiallyTrustworthy(currentURL)) { + return 'no-referrer' + } + // 2. Return referrerOrigin + return referrerOrigin + } + } +} + +/** + * Certain portions of URLs must not be included when sending a URL as the + * value of a `Referer` header: a URLs fragment, username, and password + * components must be stripped from the URL before it’s sent out. This + * algorithm accepts a origin-only flag, which defaults to false. If set to + * true, the algorithm will additionally remove the URL’s path and query + * components, leaving only the scheme, host, and port. + * + * @see https://w3c.github.io/webappsec-referrer-policy/#strip-url + * @param {URL} url + * @param {boolean} [originOnly=false] + */ +function stripURLForReferrer (url, originOnly = false) { + // 1. Assert: url is a URL. + assert(webidl.is.URL(url)) + + // Note: Create a new URL instance to avoid mutating the original URL. + url = new URL(url) + + // 2. If url’s scheme is a local scheme, then return no referrer. + if (urlIsLocal(url)) { + return 'no-referrer' + } + + // 3. Set url’s username to the empty string. + url.username = '' + + // 4. Set url’s password to the empty string. + url.password = '' + + // 5. Set url’s fragment to null. + url.hash = '' + + // 6. If the origin-only flag is true, then: + if (originOnly === true) { + // 1. Set url’s path to « the empty string ». + url.pathname = '' + + // 2. Set url’s query to null. + url.search = '' + } + + // 7. Return url. + return url +} + +const potentialleTrustworthyIPv4RegExp = new RegExp('^(?:' + + '(?:127\\.)' + + '(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){2}' + + '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[1-9])' + +')$') + +const potentialleTrustworthyIPv6RegExp = new RegExp('^(?:' + + '(?:(?:0{1,4}):){7}(?:(?:0{0,3}1))|' + + '(?:(?:0{1,4}):){1,6}(?::(?:0{0,3}1))|' + + '(?:::(?:0{0,3}1))|' + +')$') + +/** + * Check if host matches one of the CIDR notations 127.0.0.0/8 or ::1/128. + * + * @param {string} origin + * @returns {boolean} + */ +function isOriginIPPotentiallyTrustworthy (origin) { + // IPv6 + if (origin.includes(':')) { + // Remove brackets from IPv6 addresses + if (origin[0] === '[' && origin[origin.length - 1] === ']') { + origin = origin.slice(1, -1) + } + return potentialleTrustworthyIPv6RegExp.test(origin) + } + + // IPv4 + return potentialleTrustworthyIPv4RegExp.test(origin) +} + +/** + * A potentially trustworthy origin is one which a user agent can generally + * trust as delivering data securely. + * + * Return value `true` means `Potentially Trustworthy`. + * Return value `false` means `Not Trustworthy`. + * + * @see https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy + * @param {string} origin + * @returns {boolean} + */ +function isOriginPotentiallyTrustworthy (origin) { + // 1. If origin is an opaque origin, return "Not Trustworthy". + if (origin == null || origin === 'null') { + return false + } + + // 2. Assert: origin is a tuple origin. + origin = new URL(origin) + + // 3. If origin’s scheme is either "https" or "wss", + // return "Potentially Trustworthy". + if (origin.protocol === 'https:' || origin.protocol === 'wss:') { + return true + } + + // 4. If origin’s host matches one of the CIDR notations 127.0.0.0/8 or + // ::1/128 [RFC4632], return "Potentially Trustworthy". + if (isOriginIPPotentiallyTrustworthy(origin.hostname)) { + return true + } + + // 5. If the user agent conforms to the name resolution rules in + // [let-localhost-be-localhost] and one of the following is true: + + // origin’s host is "localhost" or "localhost." + if (origin.hostname === 'localhost' || origin.hostname === 'localhost.') { + return true + } + + // origin’s host ends with ".localhost" or ".localhost." + if (origin.hostname.endsWith('.localhost') || origin.hostname.endsWith('.localhost.')) { + return true + } + + // 6. If origin’s scheme is "file", return "Potentially Trustworthy". + if (origin.protocol === 'file:') { + return true + } + + // 7. If origin’s scheme component is one which the user agent considers to + // be authenticated, return "Potentially Trustworthy". + + // 8. If origin has been configured as a trustworthy origin, return + // "Potentially Trustworthy". + + // 9. Return "Not Trustworthy". + return false +} + +/** + * A potentially trustworthy URL is one which either inherits context from its + * creator (about:blank, about:srcdoc, data) or one whose origin is a + * potentially trustworthy origin. + * + * Return value `true` means `Potentially Trustworthy`. + * Return value `false` means `Not Trustworthy`. + * + * @see https://www.w3.org/TR/secure-contexts/#is-url-trustworthy + * @param {URL} url + * @returns {boolean} + */ +function isURLPotentiallyTrustworthy (url) { + // Given a URL record (url), the following algorithm returns "Potentially + // Trustworthy" or "Not Trustworthy" as appropriate: + if (!webidl.is.URL(url)) { + return false + } + + // 1. If url is "about:blank" or "about:srcdoc", + // return "Potentially Trustworthy". + if (url.href === 'about:blank' || url.href === 'about:srcdoc') { + return true + } + + // 2. If url’s scheme is "data", return "Potentially Trustworthy". + if (url.protocol === 'data:') return true + + // Note: The origin of blob: URLs is the origin of the context in which they + // were created. Therefore, blobs created in a trustworthy origin will + // themselves be potentially trustworthy. + if (url.protocol === 'blob:') return true + + // 3. Return the result of executing § 3.1 Is origin potentially trustworthy? + // on url’s origin. + return isOriginPotentiallyTrustworthy(url.origin) +} + +/** + * @see https://w3c.github.io/webappsec-subresource-integrity/#does-response-match-metadatalist + * @param {Uint8Array} bytes + * @param {string} metadataList + */ +function bytesMatch (bytes, metadataList) { + // If node is not built with OpenSSL support, we cannot check + // a request's integrity, so allow it by default (the spec will + // allow requests if an invalid hash is given, as precedence). + /* istanbul ignore if: only if node is built with --without-ssl */ + if (crypto === undefined) { + return true + } + + // 1. Let parsedMetadata be the result of parsing metadataList. + const parsedMetadata = parseMetadata(metadataList) + + // 2. If parsedMetadata is no metadata, return true. + if (parsedMetadata === 'no metadata') { + return true + } + + // 3. If response is not eligible for integrity validation, return false. + // TODO + + // 4. If parsedMetadata is the empty set, return true. + if (parsedMetadata.length === 0) { + return true + } + + // 5. Let metadata be the result of getting the strongest + // metadata from parsedMetadata. + const strongest = getStrongestMetadata(parsedMetadata) + const metadata = filterMetadataListByAlgorithm(parsedMetadata, strongest) + + // 6. For each item in metadata: + for (const item of metadata) { + // 1. Let algorithm be the alg component of item. + const algorithm = item.algo + + // 2. Let expectedValue be the val component of item. + const expectedValue = item.hash + + // See https://github.com/web-platform-tests/wpt/commit/e4c5cc7a5e48093220528dfdd1c4012dc3837a0e + // "be liberal with padding". This is annoying, and it's not even in the spec. + + // 3. Let actualValue be the result of applying algorithm to bytes. + let actualValue = crypto.createHash(algorithm).update(bytes).digest('base64') + + if (actualValue[actualValue.length - 1] === '=') { + if (actualValue[actualValue.length - 2] === '=') { + actualValue = actualValue.slice(0, -2) + } else { + actualValue = actualValue.slice(0, -1) + } + } + + // 4. If actualValue is a case-sensitive match for expectedValue, + // return true. + if (compareBase64Mixed(actualValue, expectedValue)) { + return true + } + } + + // 7. Return false. + return false +} + +// https://w3c.github.io/webappsec-subresource-integrity/#grammardef-hash-with-options +// https://www.w3.org/TR/CSP2/#source-list-syntax +// https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1 +const parseHashWithOptions = /(?sha256|sha384|sha512)-((?[A-Za-z0-9+/]+|[A-Za-z0-9_-]+)={0,2}(?:\s|$)( +[!-~]*)?)?/i + +/** + * @see https://w3c.github.io/webappsec-subresource-integrity/#parse-metadata + * @param {string} metadata + */ +function parseMetadata (metadata) { + // 1. Let result be the empty set. + /** @type {{ algo: string, hash: string }[]} */ + const result = [] + + // 2. Let empty be equal to true. + let empty = true + + // 3. For each token returned by splitting metadata on spaces: + for (const token of metadata.split(' ')) { + // 1. Set empty to false. + empty = false + + // 2. Parse token as a hash-with-options. + const parsedToken = parseHashWithOptions.exec(token) + + // 3. If token does not parse, continue to the next token. + if ( + parsedToken === null || + parsedToken.groups === undefined || + parsedToken.groups.algo === undefined + ) { + // Note: Chromium blocks the request at this point, but Firefox + // gives a warning that an invalid integrity was given. The + // correct behavior is to ignore these, and subsequently not + // check the integrity of the resource. + continue + } + + // 4. Let algorithm be the hash-algo component of token. + const algorithm = parsedToken.groups.algo.toLowerCase() + + // 5. If algorithm is a hash function recognized by the user + // agent, add the parsed token to result. + if (supportedHashes.includes(algorithm)) { + result.push(parsedToken.groups) + } + } + + // 4. Return no metadata if empty is true, otherwise return result. + if (empty === true) { + return 'no metadata' + } + + return result +} + +/** + * @param {{ algo: 'sha256' | 'sha384' | 'sha512' }[]} metadataList + */ +function getStrongestMetadata (metadataList) { + // Let algorithm be the algo component of the first item in metadataList. + // Can be sha256 + let algorithm = metadataList[0].algo + // If the algorithm is sha512, then it is the strongest + // and we can return immediately + if (algorithm[3] === '5') { + return algorithm + } + + for (let i = 1; i < metadataList.length; ++i) { + const metadata = metadataList[i] + // If the algorithm is sha512, then it is the strongest + // and we can break the loop immediately + if (metadata.algo[3] === '5') { + algorithm = 'sha512' + break + // If the algorithm is sha384, then a potential sha256 or sha384 is ignored + } else if (algorithm[3] === '3') { + continue + // algorithm is sha256, check if algorithm is sha384 and if so, set it as + // the strongest + } else if (metadata.algo[3] === '3') { + algorithm = 'sha384' + } + } + return algorithm +} + +function filterMetadataListByAlgorithm (metadataList, algorithm) { + if (metadataList.length === 1) { + return metadataList + } + + let pos = 0 + for (let i = 0; i < metadataList.length; ++i) { + if (metadataList[i].algo === algorithm) { + metadataList[pos++] = metadataList[i] + } + } + + metadataList.length = pos + + return metadataList +} + +/** + * Compares two base64 strings, allowing for base64url + * in the second string. + * +* @param {string} actualValue always base64 + * @param {string} expectedValue base64 or base64url + * @returns {boolean} + */ +function compareBase64Mixed (actualValue, expectedValue) { + if (actualValue.length !== expectedValue.length) { + return false + } + for (let i = 0; i < actualValue.length; ++i) { + if (actualValue[i] !== expectedValue[i]) { + if ( + (actualValue[i] === '+' && expectedValue[i] === '-') || + (actualValue[i] === '/' && expectedValue[i] === '_') + ) { + continue + } + return false + } + } + + return true +} + +// https://w3c.github.io/webappsec-upgrade-insecure-requests/#upgrade-request +function tryUpgradeRequestToAPotentiallyTrustworthyURL (request) { + // TODO +} + +/** + * @link {https://html.spec.whatwg.org/multipage/origin.html#same-origin} + * @param {URL} A + * @param {URL} B + */ +function sameOrigin (A, B) { + // 1. If A and B are the same opaque origin, then return true. + if (A.origin === B.origin && A.origin === 'null') { + return true + } + + // 2. If A and B are both tuple origins and their schemes, + // hosts, and port are identical, then return true. + if (A.protocol === B.protocol && A.hostname === B.hostname && A.port === B.port) { + return true + } + + // 3. Return false. + return false +} + +function createDeferredPromise () { + let res + let rej + const promise = new Promise((resolve, reject) => { + res = resolve + rej = reject + }) + + return { promise, resolve: res, reject: rej } +} + +function isAborted (fetchParams) { + return fetchParams.controller.state === 'aborted' +} + +function isCancelled (fetchParams) { + return fetchParams.controller.state === 'aborted' || + fetchParams.controller.state === 'terminated' +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-method-normalize + * @param {string} method + */ +function normalizeMethod (method) { + return normalizedMethodRecordsBase[method.toLowerCase()] ?? method +} + +// https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-a-json-string +function serializeJavascriptValueToJSONString (value) { + // 1. Let result be ? Call(%JSON.stringify%, undefined, « value »). + const result = JSON.stringify(value) + + // 2. If result is undefined, then throw a TypeError. + if (result === undefined) { + throw new TypeError('Value is not JSON serializable') + } + + // 3. Assert: result is a string. + assert(typeof result === 'string') + + // 4. Return result. + return result +} + +// https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object +const esIteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + +/** + * @see https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + * @param {string} name name of the instance + * @param {((target: any) => any)} kInternalIterator + * @param {string | number} [keyIndex] + * @param {string | number} [valueIndex] + */ +function createIterator (name, kInternalIterator, keyIndex = 0, valueIndex = 1) { + class FastIterableIterator { + /** @type {any} */ + #target + /** @type {'key' | 'value' | 'key+value'} */ + #kind + /** @type {number} */ + #index + + /** + * @see https://webidl.spec.whatwg.org/#dfn-default-iterator-object + * @param {unknown} target + * @param {'key' | 'value' | 'key+value'} kind + */ + constructor (target, kind) { + this.#target = target + this.#kind = kind + this.#index = 0 + } + + next () { + // 1. Let interface be the interface for which the iterator prototype object exists. + // 2. Let thisValue be the this value. + // 3. Let object be ? ToObject(thisValue). + // 4. If object is a platform object, then perform a security + // check, passing: + // 5. If object is not a default iterator object for interface, + // then throw a TypeError. + if (typeof this !== 'object' || this === null || !(#target in this)) { + throw new TypeError( + `'next' called on an object that does not implement interface ${name} Iterator.` + ) + } + + // 6. Let index be object’s index. + // 7. Let kind be object’s kind. + // 8. Let values be object’s target's value pairs to iterate over. + const index = this.#index + const values = kInternalIterator(this.#target) + + // 9. Let len be the length of values. + const len = values.length + + // 10. If index is greater than or equal to len, then return + // CreateIterResultObject(undefined, true). + if (index >= len) { + return { + value: undefined, + done: true + } + } + + // 11. Let pair be the entry in values at index index. + const { [keyIndex]: key, [valueIndex]: value } = values[index] + + // 12. Set object’s index to index + 1. + this.#index = index + 1 + + // 13. Return the iterator result for pair and kind. + + // https://webidl.spec.whatwg.org/#iterator-result + + // 1. Let result be a value determined by the value of kind: + let result + switch (this.#kind) { + case 'key': + // 1. Let idlKey be pair’s key. + // 2. Let key be the result of converting idlKey to an + // ECMAScript value. + // 3. result is key. + result = key + break + case 'value': + // 1. Let idlValue be pair’s value. + // 2. Let value be the result of converting idlValue to + // an ECMAScript value. + // 3. result is value. + result = value + break + case 'key+value': + // 1. Let idlKey be pair’s key. + // 2. Let idlValue be pair’s value. + // 3. Let key be the result of converting idlKey to an + // ECMAScript value. + // 4. Let value be the result of converting idlValue to + // an ECMAScript value. + // 5. Let array be ! ArrayCreate(2). + // 6. Call ! CreateDataProperty(array, "0", key). + // 7. Call ! CreateDataProperty(array, "1", value). + // 8. result is array. + result = [key, value] + break + } + + // 2. Return CreateIterResultObject(result, false). + return { + value: result, + done: false + } + } + } + + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // @ts-ignore + delete FastIterableIterator.prototype.constructor + + Object.setPrototypeOf(FastIterableIterator.prototype, esIteratorPrototype) + + Object.defineProperties(FastIterableIterator.prototype, { + [Symbol.toStringTag]: { + writable: false, + enumerable: false, + configurable: true, + value: `${name} Iterator` + }, + next: { writable: true, enumerable: true, configurable: true } + }) + + /** + * @param {unknown} target + * @param {'key' | 'value' | 'key+value'} kind + * @returns {IterableIterator} + */ + return function (target, kind) { + return new FastIterableIterator(target, kind) + } +} + +/** + * @see https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + * @param {string} name name of the instance + * @param {any} object class + * @param {(target: any) => any} kInternalIterator + * @param {string | number} [keyIndex] + * @param {string | number} [valueIndex] + */ +function iteratorMixin (name, object, kInternalIterator, keyIndex = 0, valueIndex = 1) { + const makeIterator = createIterator(name, kInternalIterator, keyIndex, valueIndex) + + const properties = { + keys: { + writable: true, + enumerable: true, + configurable: true, + value: function keys () { + webidl.brandCheck(this, object) + return makeIterator(this, 'key') + } + }, + values: { + writable: true, + enumerable: true, + configurable: true, + value: function values () { + webidl.brandCheck(this, object) + return makeIterator(this, 'value') + } + }, + entries: { + writable: true, + enumerable: true, + configurable: true, + value: function entries () { + webidl.brandCheck(this, object) + return makeIterator(this, 'key+value') + } + }, + forEach: { + writable: true, + enumerable: true, + configurable: true, + value: function forEach (callbackfn, thisArg = globalThis) { + webidl.brandCheck(this, object) + webidl.argumentLengthCheck(arguments, 1, `${name}.forEach`) + if (typeof callbackfn !== 'function') { + throw new TypeError( + `Failed to execute 'forEach' on '${name}': parameter 1 is not of type 'Function'.` + ) + } + for (const { 0: key, 1: value } of makeIterator(this, 'key+value')) { + callbackfn.call(thisArg, value, key, this) + } + } + } + } + + return Object.defineProperties(object.prototype, { + ...properties, + [Symbol.iterator]: { + writable: true, + enumerable: false, + configurable: true, + value: properties.entries.value + } + }) +} + +/** + * @see https://fetch.spec.whatwg.org/#body-fully-read + */ +function fullyReadBody (body, processBody, processBodyError) { + // 1. If taskDestination is null, then set taskDestination to + // the result of starting a new parallel queue. + + // 2. Let successSteps given a byte sequence bytes be to queue a + // fetch task to run processBody given bytes, with taskDestination. + const successSteps = processBody + + // 3. Let errorSteps be to queue a fetch task to run processBodyError, + // with taskDestination. + const errorSteps = processBodyError + + // 4. Let reader be the result of getting a reader for body’s stream. + // If that threw an exception, then run errorSteps with that + // exception and return. + let reader + + try { + reader = body.stream.getReader() + } catch (e) { + errorSteps(e) + return + } + + // 5. Read all bytes from reader, given successSteps and errorSteps. + readAllBytes(reader, successSteps, errorSteps) +} + +/** + * @param {ReadableStreamController} controller + */ +function readableStreamClose (controller) { + try { + controller.close() + controller.byobRequest?.respond(0) + } catch (err) { + // TODO: add comment explaining why this error occurs. + if (!err.message.includes('Controller is already closed') && !err.message.includes('ReadableStream is already closed')) { + throw err + } + } +} + +const invalidIsomorphicEncodeValueRegex = /[^\x00-\xFF]/ // eslint-disable-line + +/** + * @see https://infra.spec.whatwg.org/#isomorphic-encode + * @param {string} input + */ +function isomorphicEncode (input) { + // 1. Assert: input contains no code points greater than U+00FF. + assert(!invalidIsomorphicEncodeValueRegex.test(input)) + + // 2. Return a byte sequence whose length is equal to input’s code + // point length and whose bytes have the same values as the + // values of input’s code points, in the same order + return input +} + +/** + * @see https://streams.spec.whatwg.org/#readablestreamdefaultreader-read-all-bytes + * @see https://streams.spec.whatwg.org/#read-loop + * @param {ReadableStreamDefaultReader} reader + * @param {(bytes: Uint8Array) => void} successSteps + * @param {(error: Error) => void} failureSteps + */ +async function readAllBytes (reader, successSteps, failureSteps) { + const bytes = [] + let byteLength = 0 + + try { + do { + const { done, value: chunk } = await reader.read() + + if (done) { + // 1. Call successSteps with bytes. + successSteps(Buffer.concat(bytes, byteLength)) + return + } + + // 1. If chunk is not a Uint8Array object, call failureSteps + // with a TypeError and abort these steps. + if (!isUint8Array(chunk)) { + failureSteps(TypeError('Received non-Uint8Array chunk')) + return + } + + // 2. Append the bytes represented by chunk to bytes. + bytes.push(chunk) + byteLength += chunk.length + + // 3. Read-loop given reader, bytes, successSteps, and failureSteps. + } while (true) + } catch (e) { + // 1. Call failureSteps with e. + failureSteps(e) + } +} + +/** + * @see https://fetch.spec.whatwg.org/#is-local + * @param {URL} url + * @returns {boolean} + */ +function urlIsLocal (url) { + assert('protocol' in url) // ensure it's a url object + + const protocol = url.protocol + + // A URL is local if its scheme is a local scheme. + // A local scheme is "about", "blob", or "data". + return protocol === 'about:' || protocol === 'blob:' || protocol === 'data:' +} + +/** + * @param {string|URL} url + * @returns {boolean} + */ +function urlHasHttpsScheme (url) { + return ( + ( + typeof url === 'string' && + url[5] === ':' && + url[0] === 'h' && + url[1] === 't' && + url[2] === 't' && + url[3] === 'p' && + url[4] === 's' + ) || + url.protocol === 'https:' + ) +} + +/** + * @see https://fetch.spec.whatwg.org/#http-scheme + * @param {URL} url + */ +function urlIsHttpHttpsScheme (url) { + assert('protocol' in url) // ensure it's a url object + + const protocol = url.protocol + + return protocol === 'http:' || protocol === 'https:' +} + +/** + * @see https://fetch.spec.whatwg.org/#simple-range-header-value + * @param {string} value + * @param {boolean} allowWhitespace + */ +function simpleRangeHeaderValue (value, allowWhitespace) { + // 1. Let data be the isomorphic decoding of value. + // Note: isomorphic decoding takes a sequence of bytes (ie. a Uint8Array) and turns it into a string, + // nothing more. We obviously don't need to do that if value is a string already. + const data = value + + // 2. If data does not start with "bytes", then return failure. + if (!data.startsWith('bytes')) { + return 'failure' + } + + // 3. Let position be a position variable for data, initially pointing at the 5th code point of data. + const position = { position: 5 } + + // 4. If allowWhitespace is true, collect a sequence of code points that are HTTP tab or space, + // from data given position. + if (allowWhitespace) { + collectASequenceOfCodePoints( + (char) => char === '\t' || char === ' ', + data, + position + ) + } + + // 5. If the code point at position within data is not U+003D (=), then return failure. + if (data.charCodeAt(position.position) !== 0x3D) { + return 'failure' + } + + // 6. Advance position by 1. + position.position++ + + // 7. If allowWhitespace is true, collect a sequence of code points that are HTTP tab or space, from + // data given position. + if (allowWhitespace) { + collectASequenceOfCodePoints( + (char) => char === '\t' || char === ' ', + data, + position + ) + } + + // 8. Let rangeStart be the result of collecting a sequence of code points that are ASCII digits, + // from data given position. + const rangeStart = collectASequenceOfCodePoints( + (char) => { + const code = char.charCodeAt(0) + + return code >= 0x30 && code <= 0x39 + }, + data, + position + ) + + // 9. Let rangeStartValue be rangeStart, interpreted as decimal number, if rangeStart is not the + // empty string; otherwise null. + const rangeStartValue = rangeStart.length ? Number(rangeStart) : null + + // 10. If allowWhitespace is true, collect a sequence of code points that are HTTP tab or space, + // from data given position. + if (allowWhitespace) { + collectASequenceOfCodePoints( + (char) => char === '\t' || char === ' ', + data, + position + ) + } + + // 11. If the code point at position within data is not U+002D (-), then return failure. + if (data.charCodeAt(position.position) !== 0x2D) { + return 'failure' + } + + // 12. Advance position by 1. + position.position++ + + // 13. If allowWhitespace is true, collect a sequence of code points that are HTTP tab + // or space, from data given position. + // Note from Khafra: its the same step as in #8 again lol + if (allowWhitespace) { + collectASequenceOfCodePoints( + (char) => char === '\t' || char === ' ', + data, + position + ) + } + + // 14. Let rangeEnd be the result of collecting a sequence of code points that are + // ASCII digits, from data given position. + // Note from Khafra: you wouldn't guess it, but this is also the same step as #8 + const rangeEnd = collectASequenceOfCodePoints( + (char) => { + const code = char.charCodeAt(0) + + return code >= 0x30 && code <= 0x39 + }, + data, + position + ) + + // 15. Let rangeEndValue be rangeEnd, interpreted as decimal number, if rangeEnd + // is not the empty string; otherwise null. + // Note from Khafra: THE SAME STEP, AGAIN!!! + // Note: why interpret as a decimal if we only collect ascii digits? + const rangeEndValue = rangeEnd.length ? Number(rangeEnd) : null + + // 16. If position is not past the end of data, then return failure. + if (position.position < data.length) { + return 'failure' + } + + // 17. If rangeEndValue and rangeStartValue are null, then return failure. + if (rangeEndValue === null && rangeStartValue === null) { + return 'failure' + } + + // 18. If rangeStartValue and rangeEndValue are numbers, and rangeStartValue is + // greater than rangeEndValue, then return failure. + // Note: ... when can they not be numbers? + if (rangeStartValue > rangeEndValue) { + return 'failure' + } + + // 19. Return (rangeStartValue, rangeEndValue). + return { rangeStartValue, rangeEndValue } +} + +/** + * @see https://fetch.spec.whatwg.org/#build-a-content-range + * @param {number} rangeStart + * @param {number} rangeEnd + * @param {number} fullLength + */ +function buildContentRange (rangeStart, rangeEnd, fullLength) { + // 1. Let contentRange be `bytes `. + let contentRange = 'bytes ' + + // 2. Append rangeStart, serialized and isomorphic encoded, to contentRange. + contentRange += isomorphicEncode(`${rangeStart}`) + + // 3. Append 0x2D (-) to contentRange. + contentRange += '-' + + // 4. Append rangeEnd, serialized and isomorphic encoded to contentRange. + contentRange += isomorphicEncode(`${rangeEnd}`) + + // 5. Append 0x2F (/) to contentRange. + contentRange += '/' + + // 6. Append fullLength, serialized and isomorphic encoded to contentRange. + contentRange += isomorphicEncode(`${fullLength}`) + + // 7. Return contentRange. + return contentRange +} + +// A Stream, which pipes the response to zlib.createInflate() or +// zlib.createInflateRaw() depending on the first byte of the Buffer. +// If the lower byte of the first byte is 0x08, then the stream is +// interpreted as a zlib stream, otherwise it's interpreted as a +// raw deflate stream. +class InflateStream extends Transform { + #zlibOptions + + /** @param {zlib.ZlibOptions} [zlibOptions] */ + constructor (zlibOptions) { + super() + this.#zlibOptions = zlibOptions + } + + _transform (chunk, encoding, callback) { + if (!this._inflateStream) { + if (chunk.length === 0) { + callback() + return + } + this._inflateStream = (chunk[0] & 0x0F) === 0x08 + ? zlib.createInflate(this.#zlibOptions) + : zlib.createInflateRaw(this.#zlibOptions) + + this._inflateStream.on('data', this.push.bind(this)) + this._inflateStream.on('end', () => this.push(null)) + this._inflateStream.on('error', (err) => this.destroy(err)) + } + + this._inflateStream.write(chunk, encoding, callback) + } + + _final (callback) { + if (this._inflateStream) { + this._inflateStream.end() + this._inflateStream = null + } + callback() + } +} + +/** + * @param {zlib.ZlibOptions} [zlibOptions] + * @returns {InflateStream} + */ +function createInflate (zlibOptions) { + return new InflateStream(zlibOptions) +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-header-extract-mime-type + * @param {import('./headers').HeadersList} headers + */ +function extractMimeType (headers) { + // 1. Let charset be null. + let charset = null + + // 2. Let essence be null. + let essence = null + + // 3. Let mimeType be null. + let mimeType = null + + // 4. Let values be the result of getting, decoding, and splitting `Content-Type` from headers. + const values = getDecodeSplit('content-type', headers) + + // 5. If values is null, then return failure. + if (values === null) { + return 'failure' + } + + // 6. For each value of values: + for (const value of values) { + // 6.1. Let temporaryMimeType be the result of parsing value. + const temporaryMimeType = parseMIMEType(value) + + // 6.2. If temporaryMimeType is failure or its essence is "*/*", then continue. + if (temporaryMimeType === 'failure' || temporaryMimeType.essence === '*/*') { + continue + } + + // 6.3. Set mimeType to temporaryMimeType. + mimeType = temporaryMimeType + + // 6.4. If mimeType’s essence is not essence, then: + if (mimeType.essence !== essence) { + // 6.4.1. Set charset to null. + charset = null + + // 6.4.2. If mimeType’s parameters["charset"] exists, then set charset to + // mimeType’s parameters["charset"]. + if (mimeType.parameters.has('charset')) { + charset = mimeType.parameters.get('charset') + } + + // 6.4.3. Set essence to mimeType’s essence. + essence = mimeType.essence + } else if (!mimeType.parameters.has('charset') && charset !== null) { + // 6.5. Otherwise, if mimeType’s parameters["charset"] does not exist, and + // charset is non-null, set mimeType’s parameters["charset"] to charset. + mimeType.parameters.set('charset', charset) + } + } + + // 7. If mimeType is null, then return failure. + if (mimeType == null) { + return 'failure' + } + + // 8. Return mimeType. + return mimeType +} + +/** + * @see https://fetch.spec.whatwg.org/#header-value-get-decode-and-split + * @param {string|null} value + */ +function gettingDecodingSplitting (value) { + // 1. Let input be the result of isomorphic decoding value. + const input = value + + // 2. Let position be a position variable for input, initially pointing at the start of input. + const position = { position: 0 } + + // 3. Let values be a list of strings, initially empty. + const values = [] + + // 4. Let temporaryValue be the empty string. + let temporaryValue = '' + + // 5. While position is not past the end of input: + while (position.position < input.length) { + // 5.1. Append the result of collecting a sequence of code points that are not U+0022 (") + // or U+002C (,) from input, given position, to temporaryValue. + temporaryValue += collectASequenceOfCodePoints( + (char) => char !== '"' && char !== ',', + input, + position + ) + + // 5.2. If position is not past the end of input, then: + if (position.position < input.length) { + // 5.2.1. If the code point at position within input is U+0022 ("), then: + if (input.charCodeAt(position.position) === 0x22) { + // 5.2.1.1. Append the result of collecting an HTTP quoted string from input, given position, to temporaryValue. + temporaryValue += collectAnHTTPQuotedString( + input, + position + ) + + // 5.2.1.2. If position is not past the end of input, then continue. + if (position.position < input.length) { + continue + } + } else { + // 5.2.2. Otherwise: + + // 5.2.2.1. Assert: the code point at position within input is U+002C (,). + assert(input.charCodeAt(position.position) === 0x2C) + + // 5.2.2.2. Advance position by 1. + position.position++ + } + } + + // 5.3. Remove all HTTP tab or space from the start and end of temporaryValue. + temporaryValue = removeChars(temporaryValue, true, true, (char) => char === 0x9 || char === 0x20) + + // 5.4. Append temporaryValue to values. + values.push(temporaryValue) + + // 5.6. Set temporaryValue to the empty string. + temporaryValue = '' + } + + // 6. Return values. + return values +} + +/** + * @see https://fetch.spec.whatwg.org/#concept-header-list-get-decode-split + * @param {string} name lowercase header name + * @param {import('./headers').HeadersList} list + */ +function getDecodeSplit (name, list) { + // 1. Let value be the result of getting name from list. + const value = list.get(name, true) + + // 2. If value is null, then return null. + if (value === null) { + return null + } + + // 3. Return the result of getting, decoding, and splitting value. + return gettingDecodingSplitting(value) +} + +const textDecoder = new TextDecoder() + +/** + * @see https://encoding.spec.whatwg.org/#utf-8-decode + * @param {Buffer} buffer + */ +function utf8DecodeBytes (buffer) { + if (buffer.length === 0) { + return '' + } + + // 1. Let buffer be the result of peeking three bytes from + // ioQueue, converted to a byte sequence. + + // 2. If buffer is 0xEF 0xBB 0xBF, then read three + // bytes from ioQueue. (Do nothing with those bytes.) + if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) { + buffer = buffer.subarray(3) + } + + // 3. Process a queue with an instance of UTF-8’s + // decoder, ioQueue, output, and "replacement". + const output = textDecoder.decode(buffer) + + // 4. Return output. + return output +} + +class EnvironmentSettingsObjectBase { + get baseUrl () { + return getGlobalOrigin() + } + + get origin () { + return this.baseUrl?.origin + } + + policyContainer = makePolicyContainer() +} + +class EnvironmentSettingsObject { + settingsObject = new EnvironmentSettingsObjectBase() +} + +const environmentSettingsObject = new EnvironmentSettingsObject() + +module.exports = { + isAborted, + isCancelled, + isValidEncodedURL, + createDeferredPromise, + ReadableStreamFrom, + tryUpgradeRequestToAPotentiallyTrustworthyURL, + clampAndCoarsenConnectionTimingInfo, + coarsenedSharedCurrentTime, + determineRequestsReferrer, + makePolicyContainer, + clonePolicyContainer, + appendFetchMetadata, + appendRequestOriginHeader, + TAOCheck, + corsCheck, + crossOriginResourcePolicyCheck, + createOpaqueTimingInfo, + setRequestReferrerPolicyOnRedirect, + isValidHTTPToken, + requestBadPort, + requestCurrentURL, + responseURL, + responseLocationURL, + isURLPotentiallyTrustworthy, + isValidReasonPhrase, + sameOrigin, + normalizeMethod, + serializeJavascriptValueToJSONString, + iteratorMixin, + createIterator, + isValidHeaderName, + isValidHeaderValue, + isErrorLike, + fullyReadBody, + bytesMatch, + readableStreamClose, + isomorphicEncode, + urlIsLocal, + urlHasHttpsScheme, + urlIsHttpHttpsScheme, + readAllBytes, + simpleRangeHeaderValue, + buildContentRange, + parseMetadata, + createInflate, + extractMimeType, + getDecodeSplit, + utf8DecodeBytes, + environmentSettingsObject, + isOriginIPPotentiallyTrustworthy +} diff --git a/lib/web/fetch/webidl.js b/lib/web/fetch/webidl.js new file mode 100644 index 0000000..46850e1 --- /dev/null +++ b/lib/web/fetch/webidl.js @@ -0,0 +1,740 @@ +'use strict' + +const { types, inspect } = require('node:util') +const { markAsUncloneable } = require('node:worker_threads') +const { toUSVString } = require('../../core/util') + +const UNDEFINED = 1 +const BOOLEAN = 2 +const STRING = 3 +const SYMBOL = 4 +const NUMBER = 5 +const BIGINT = 6 +const NULL = 7 +const OBJECT = 8 // function and object + +const FunctionPrototypeSymbolHasInstance = Function.call.bind(Function.prototype[Symbol.hasInstance]) + +/** @type {import('../../../types/webidl').Webidl} */ +const webidl = { + converters: {}, + util: {}, + errors: {}, + is: {} +} + +webidl.errors.exception = function (message) { + return new TypeError(`${message.header}: ${message.message}`) +} + +webidl.errors.conversionFailed = function (context) { + const plural = context.types.length === 1 ? '' : ' one of' + const message = + `${context.argument} could not be converted to` + + `${plural}: ${context.types.join(', ')}.` + + return webidl.errors.exception({ + header: context.prefix, + message + }) +} + +webidl.errors.invalidArgument = function (context) { + return webidl.errors.exception({ + header: context.prefix, + message: `"${context.value}" is an invalid ${context.type}.` + }) +} + +// https://webidl.spec.whatwg.org/#implements +webidl.brandCheck = function (V, I) { + if (!FunctionPrototypeSymbolHasInstance(I, V)) { + const err = new TypeError('Illegal invocation') + err.code = 'ERR_INVALID_THIS' // node compat. + throw err + } +} + +webidl.brandCheckMultiple = function (List) { + const prototypes = List.map((c) => webidl.util.MakeTypeAssertion(c)) + + return (V) => { + if (prototypes.every(typeCheck => !typeCheck(V))) { + const err = new TypeError('Illegal invocation') + err.code = 'ERR_INVALID_THIS' // node compat. + throw err + } + } +} + +webidl.argumentLengthCheck = function ({ length }, min, ctx) { + if (length < min) { + throw webidl.errors.exception({ + message: `${min} argument${min !== 1 ? 's' : ''} required, ` + + `but${length ? ' only' : ''} ${length} found.`, + header: ctx + }) + } +} + +webidl.illegalConstructor = function () { + throw webidl.errors.exception({ + header: 'TypeError', + message: 'Illegal constructor' + }) +} + +webidl.util.MakeTypeAssertion = function (I) { + return (O) => FunctionPrototypeSymbolHasInstance(I, O) +} + +// https://tc39.es/ecma262/#sec-ecmascript-data-types-and-values +webidl.util.Type = function (V) { + switch (typeof V) { + case 'undefined': return UNDEFINED + case 'boolean': return BOOLEAN + case 'string': return STRING + case 'symbol': return SYMBOL + case 'number': return NUMBER + case 'bigint': return BIGINT + case 'function': + case 'object': { + if (V === null) { + return NULL + } + + return OBJECT + } + } +} + +webidl.util.Types = { + UNDEFINED, + BOOLEAN, + STRING, + SYMBOL, + NUMBER, + BIGINT, + NULL, + OBJECT +} + +webidl.util.TypeValueToString = function (o) { + switch (webidl.util.Type(o)) { + case UNDEFINED: return 'Undefined' + case BOOLEAN: return 'Boolean' + case STRING: return 'String' + case SYMBOL: return 'Symbol' + case NUMBER: return 'Number' + case BIGINT: return 'BigInt' + case NULL: return 'Null' + case OBJECT: return 'Object' + } +} + +webidl.util.markAsUncloneable = markAsUncloneable || (() => {}) + +// https://webidl.spec.whatwg.org/#abstract-opdef-converttoint +webidl.util.ConvertToInt = function (V, bitLength, signedness, opts) { + let upperBound + let lowerBound + + // 1. If bitLength is 64, then: + if (bitLength === 64) { + // 1. Let upperBound be 2^53 − 1. + upperBound = Math.pow(2, 53) - 1 + + // 2. If signedness is "unsigned", then let lowerBound be 0. + if (signedness === 'unsigned') { + lowerBound = 0 + } else { + // 3. Otherwise let lowerBound be −2^53 + 1. + lowerBound = Math.pow(-2, 53) + 1 + } + } else if (signedness === 'unsigned') { + // 2. Otherwise, if signedness is "unsigned", then: + + // 1. Let lowerBound be 0. + lowerBound = 0 + + // 2. Let upperBound be 2^bitLength − 1. + upperBound = Math.pow(2, bitLength) - 1 + } else { + // 3. Otherwise: + + // 1. Let lowerBound be -2^bitLength − 1. + lowerBound = Math.pow(-2, bitLength) - 1 + + // 2. Let upperBound be 2^bitLength − 1 − 1. + upperBound = Math.pow(2, bitLength - 1) - 1 + } + + // 4. Let x be ? ToNumber(V). + let x = Number(V) + + // 5. If x is −0, then set x to +0. + if (x === 0) { + x = 0 + } + + // 6. If the conversion is to an IDL type associated + // with the [EnforceRange] extended attribute, then: + if (opts?.enforceRange === true) { + // 1. If x is NaN, +∞, or −∞, then throw a TypeError. + if ( + Number.isNaN(x) || + x === Number.POSITIVE_INFINITY || + x === Number.NEGATIVE_INFINITY + ) { + throw webidl.errors.exception({ + header: 'Integer conversion', + message: `Could not convert ${webidl.util.Stringify(V)} to an integer.` + }) + } + + // 2. Set x to IntegerPart(x). + x = webidl.util.IntegerPart(x) + + // 3. If x < lowerBound or x > upperBound, then + // throw a TypeError. + if (x < lowerBound || x > upperBound) { + throw webidl.errors.exception({ + header: 'Integer conversion', + message: `Value must be between ${lowerBound}-${upperBound}, got ${x}.` + }) + } + + // 4. Return x. + return x + } + + // 7. If x is not NaN and the conversion is to an IDL + // type associated with the [Clamp] extended + // attribute, then: + if (!Number.isNaN(x) && opts?.clamp === true) { + // 1. Set x to min(max(x, lowerBound), upperBound). + x = Math.min(Math.max(x, lowerBound), upperBound) + + // 2. Round x to the nearest integer, choosing the + // even integer if it lies halfway between two, + // and choosing +0 rather than −0. + if (Math.floor(x) % 2 === 0) { + x = Math.floor(x) + } else { + x = Math.ceil(x) + } + + // 3. Return x. + return x + } + + // 8. If x is NaN, +0, +∞, or −∞, then return +0. + if ( + Number.isNaN(x) || + (x === 0 && Object.is(0, x)) || + x === Number.POSITIVE_INFINITY || + x === Number.NEGATIVE_INFINITY + ) { + return 0 + } + + // 9. Set x to IntegerPart(x). + x = webidl.util.IntegerPart(x) + + // 10. Set x to x modulo 2^bitLength. + x = x % Math.pow(2, bitLength) + + // 11. If signedness is "signed" and x ≥ 2^bitLength − 1, + // then return x − 2^bitLength. + if (signedness === 'signed' && x >= Math.pow(2, bitLength) - 1) { + return x - Math.pow(2, bitLength) + } + + // 12. Otherwise, return x. + return x +} + +// https://webidl.spec.whatwg.org/#abstract-opdef-integerpart +webidl.util.IntegerPart = function (n) { + // 1. Let r be floor(abs(n)). + const r = Math.floor(Math.abs(n)) + + // 2. If n < 0, then return -1 × r. + if (n < 0) { + return -1 * r + } + + // 3. Otherwise, return r. + return r +} + +webidl.util.Stringify = function (V) { + const type = webidl.util.Type(V) + + switch (type) { + case SYMBOL: + return `Symbol(${V.description})` + case OBJECT: + return inspect(V) + case STRING: + return `"${V}"` + default: + return `${V}` + } +} + +// https://webidl.spec.whatwg.org/#es-sequence +webidl.sequenceConverter = function (converter) { + return (V, prefix, argument, Iterable) => { + // 1. If Type(V) is not Object, throw a TypeError. + if (webidl.util.Type(V) !== OBJECT) { + throw webidl.errors.exception({ + header: prefix, + message: `${argument} (${webidl.util.Stringify(V)}) is not iterable.` + }) + } + + // 2. Let method be ? GetMethod(V, @@iterator). + /** @type {Generator} */ + const method = typeof Iterable === 'function' ? Iterable() : V?.[Symbol.iterator]?.() + const seq = [] + let index = 0 + + // 3. If method is undefined, throw a TypeError. + if ( + method === undefined || + typeof method.next !== 'function' + ) { + throw webidl.errors.exception({ + header: prefix, + message: `${argument} is not iterable.` + }) + } + + // https://webidl.spec.whatwg.org/#create-sequence-from-iterable + while (true) { + const { done, value } = method.next() + + if (done) { + break + } + + seq.push(converter(value, prefix, `${argument}[${index++}]`)) + } + + return seq + } +} + +// https://webidl.spec.whatwg.org/#es-to-record +webidl.recordConverter = function (keyConverter, valueConverter) { + return (O, prefix, argument) => { + // 1. If Type(O) is not Object, throw a TypeError. + if (webidl.util.Type(O) !== OBJECT) { + throw webidl.errors.exception({ + header: prefix, + message: `${argument} ("${webidl.util.TypeValueToString(O)}") is not an Object.` + }) + } + + // 2. Let result be a new empty instance of record. + const result = {} + + if (!types.isProxy(O)) { + // 1. Let desc be ? O.[[GetOwnProperty]](key). + const keys = [...Object.getOwnPropertyNames(O), ...Object.getOwnPropertySymbols(O)] + + for (const key of keys) { + const keyName = webidl.util.Stringify(key) + + // 1. Let typedKey be key converted to an IDL value of type K. + const typedKey = keyConverter(key, prefix, `Key ${keyName} in ${argument}`) + + // 2. Let value be ? Get(O, key). + // 3. Let typedValue be value converted to an IDL value of type V. + const typedValue = valueConverter(O[key], prefix, `${argument}[${keyName}]`) + + // 4. Set result[typedKey] to typedValue. + result[typedKey] = typedValue + } + + // 5. Return result. + return result + } + + // 3. Let keys be ? O.[[OwnPropertyKeys]](). + const keys = Reflect.ownKeys(O) + + // 4. For each key of keys. + for (const key of keys) { + // 1. Let desc be ? O.[[GetOwnProperty]](key). + const desc = Reflect.getOwnPropertyDescriptor(O, key) + + // 2. If desc is not undefined and desc.[[Enumerable]] is true: + if (desc?.enumerable) { + // 1. Let typedKey be key converted to an IDL value of type K. + const typedKey = keyConverter(key, prefix, argument) + + // 2. Let value be ? Get(O, key). + // 3. Let typedValue be value converted to an IDL value of type V. + const typedValue = valueConverter(O[key], prefix, argument) + + // 4. Set result[typedKey] to typedValue. + result[typedKey] = typedValue + } + } + + // 5. Return result. + return result + } +} + +webidl.interfaceConverter = function (TypeCheck, name) { + return (V, prefix, argument) => { + if (!TypeCheck(V)) { + throw webidl.errors.exception({ + header: prefix, + message: `Expected ${argument} ("${webidl.util.Stringify(V)}") to be an instance of ${name}.` + }) + } + + return V + } +} + +webidl.dictionaryConverter = function (converters) { + return (dictionary, prefix, argument) => { + const dict = {} + + if (dictionary != null && webidl.util.Type(dictionary) !== OBJECT) { + throw webidl.errors.exception({ + header: prefix, + message: `Expected ${dictionary} to be one of: Null, Undefined, Object.` + }) + } + + for (const options of converters) { + const { key, defaultValue, required, converter } = options + + if (required === true) { + if (dictionary == null || !Object.hasOwn(dictionary, key)) { + throw webidl.errors.exception({ + header: prefix, + message: `Missing required key "${key}".` + }) + } + } + + let value = dictionary?.[key] + const hasDefault = defaultValue !== undefined + + // Only use defaultValue if value is undefined and + // a defaultValue options was provided. + if (hasDefault && value === undefined) { + value = defaultValue() + } + + // A key can be optional and have no default value. + // When this happens, do not perform a conversion, + // and do not assign the key a value. + if (required || hasDefault || value !== undefined) { + value = converter(value, prefix, `${argument}.${key}`) + + if ( + options.allowedValues && + !options.allowedValues.includes(value) + ) { + throw webidl.errors.exception({ + header: prefix, + message: `${value} is not an accepted type. Expected one of ${options.allowedValues.join(', ')}.` + }) + } + + dict[key] = value + } + } + + return dict + } +} + +webidl.nullableConverter = function (converter) { + return (V, prefix, argument) => { + if (V === null) { + return V + } + + return converter(V, prefix, argument) + } +} + +webidl.is.ReadableStream = webidl.util.MakeTypeAssertion(ReadableStream) +webidl.is.Blob = webidl.util.MakeTypeAssertion(Blob) +webidl.is.URLSearchParams = webidl.util.MakeTypeAssertion(URLSearchParams) +webidl.is.File = webidl.util.MakeTypeAssertion(globalThis.File ?? require('node:buffer').File) +webidl.is.URL = webidl.util.MakeTypeAssertion(URL) +webidl.is.AbortSignal = webidl.util.MakeTypeAssertion(AbortSignal) +webidl.is.MessagePort = webidl.util.MakeTypeAssertion(MessagePort) + +// https://webidl.spec.whatwg.org/#es-DOMString +webidl.converters.DOMString = function (V, prefix, argument, opts) { + // 1. If V is null and the conversion is to an IDL type + // associated with the [LegacyNullToEmptyString] + // extended attribute, then return the DOMString value + // that represents the empty string. + if (V === null && opts?.legacyNullToEmptyString) { + return '' + } + + // 2. Let x be ? ToString(V). + if (typeof V === 'symbol') { + throw webidl.errors.exception({ + header: prefix, + message: `${argument} is a symbol, which cannot be converted to a DOMString.` + }) + } + + // 3. Return the IDL DOMString value that represents the + // same sequence of code units as the one the + // ECMAScript String value x represents. + return String(V) +} + +// https://webidl.spec.whatwg.org/#es-ByteString +webidl.converters.ByteString = function (V, prefix, argument) { + // 1. Let x be ? ToString(V). + if (typeof V === 'symbol') { + throw webidl.errors.exception({ + header: prefix, + message: `${argument} is a symbol, which cannot be converted to a ByteString.` + }) + } + + const x = String(V) + + // 2. If the value of any element of x is greater than + // 255, then throw a TypeError. + for (let index = 0; index < x.length; index++) { + if (x.charCodeAt(index) > 255) { + throw new TypeError( + 'Cannot convert argument to a ByteString because the character at ' + + `index ${index} has a value of ${x.charCodeAt(index)} which is greater than 255.` + ) + } + } + + // 3. Return an IDL ByteString value whose length is the + // length of x, and where the value of each element is + // the value of the corresponding element of x. + return x +} + +// https://webidl.spec.whatwg.org/#es-USVString +// TODO: rewrite this so we can control the errors thrown +webidl.converters.USVString = toUSVString + +// https://webidl.spec.whatwg.org/#es-boolean +webidl.converters.boolean = function (V) { + // 1. Let x be the result of computing ToBoolean(V). + const x = Boolean(V) + + // 2. Return the IDL boolean value that is the one that represents + // the same truth value as the ECMAScript Boolean value x. + return x +} + +// https://webidl.spec.whatwg.org/#es-any +webidl.converters.any = function (V) { + return V +} + +// https://webidl.spec.whatwg.org/#es-long-long +webidl.converters['long long'] = function (V, prefix, argument) { + // 1. Let x be ? ConvertToInt(V, 64, "signed"). + const x = webidl.util.ConvertToInt(V, 64, 'signed', undefined, prefix, argument) + + // 2. Return the IDL long long value that represents + // the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#es-unsigned-long-long +webidl.converters['unsigned long long'] = function (V, prefix, argument) { + // 1. Let x be ? ConvertToInt(V, 64, "unsigned"). + const x = webidl.util.ConvertToInt(V, 64, 'unsigned', undefined, prefix, argument) + + // 2. Return the IDL unsigned long long value that + // represents the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#es-unsigned-long +webidl.converters['unsigned long'] = function (V, prefix, argument) { + // 1. Let x be ? ConvertToInt(V, 32, "unsigned"). + const x = webidl.util.ConvertToInt(V, 32, 'unsigned', undefined, prefix, argument) + + // 2. Return the IDL unsigned long value that + // represents the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#es-unsigned-short +webidl.converters['unsigned short'] = function (V, prefix, argument, opts) { + // 1. Let x be ? ConvertToInt(V, 16, "unsigned"). + const x = webidl.util.ConvertToInt(V, 16, 'unsigned', opts, prefix, argument) + + // 2. Return the IDL unsigned short value that represents + // the same numeric value as x. + return x +} + +// https://webidl.spec.whatwg.org/#idl-ArrayBuffer +webidl.converters.ArrayBuffer = function (V, prefix, argument, opts) { + // 1. If Type(V) is not Object, or V does not have an + // [[ArrayBufferData]] internal slot, then throw a + // TypeError. + // see: https://tc39.es/ecma262/#sec-properties-of-the-arraybuffer-instances + // see: https://tc39.es/ecma262/#sec-properties-of-the-sharedarraybuffer-instances + if ( + webidl.util.Type(V) !== OBJECT || + !types.isAnyArrayBuffer(V) + ) { + throw webidl.errors.conversionFailed({ + prefix, + argument: `${argument} ("${webidl.util.Stringify(V)}")`, + types: ['ArrayBuffer'] + }) + } + + // 2. If the conversion is not to an IDL type associated + // with the [AllowShared] extended attribute, and + // IsSharedArrayBuffer(V) is true, then throw a + // TypeError. + if (opts?.allowShared === false && types.isSharedArrayBuffer(V)) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'SharedArrayBuffer is not allowed.' + }) + } + + // 3. If the conversion is not to an IDL type associated + // with the [AllowResizable] extended attribute, and + // IsResizableArrayBuffer(V) is true, then throw a + // TypeError. + if (V.resizable || V.growable) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'Received a resizable ArrayBuffer.' + }) + } + + // 4. Return the IDL ArrayBuffer value that is a + // reference to the same object as V. + return V +} + +webidl.converters.TypedArray = function (V, T, prefix, name, opts) { + // 1. Let T be the IDL type V is being converted to. + + // 2. If Type(V) is not Object, or V does not have a + // [[TypedArrayName]] internal slot with a value + // equal to T’s name, then throw a TypeError. + if ( + webidl.util.Type(V) !== OBJECT || + !types.isTypedArray(V) || + V.constructor.name !== T.name + ) { + throw webidl.errors.conversionFailed({ + prefix, + argument: `${name} ("${webidl.util.Stringify(V)}")`, + types: [T.name] + }) + } + + // 3. If the conversion is not to an IDL type associated + // with the [AllowShared] extended attribute, and + // IsSharedArrayBuffer(V.[[ViewedArrayBuffer]]) is + // true, then throw a TypeError. + if (opts?.allowShared === false && types.isSharedArrayBuffer(V.buffer)) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'SharedArrayBuffer is not allowed.' + }) + } + + // 4. If the conversion is not to an IDL type associated + // with the [AllowResizable] extended attribute, and + // IsResizableArrayBuffer(V.[[ViewedArrayBuffer]]) is + // true, then throw a TypeError. + if (V.buffer.resizable || V.buffer.growable) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'Received a resizable ArrayBuffer.' + }) + } + + // 5. Return the IDL value of type T that is a reference + // to the same object as V. + return V +} + +webidl.converters.DataView = function (V, prefix, name, opts) { + // 1. If Type(V) is not Object, or V does not have a + // [[DataView]] internal slot, then throw a TypeError. + if (webidl.util.Type(V) !== OBJECT || !types.isDataView(V)) { + throw webidl.errors.exception({ + header: prefix, + message: `${name} is not a DataView.` + }) + } + + // 2. If the conversion is not to an IDL type associated + // with the [AllowShared] extended attribute, and + // IsSharedArrayBuffer(V.[[ViewedArrayBuffer]]) is true, + // then throw a TypeError. + if (opts?.allowShared === false && types.isSharedArrayBuffer(V.buffer)) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'SharedArrayBuffer is not allowed.' + }) + } + + // 3. If the conversion is not to an IDL type associated + // with the [AllowResizable] extended attribute, and + // IsResizableArrayBuffer(V.[[ViewedArrayBuffer]]) is + // true, then throw a TypeError. + if (V.buffer.resizable || V.buffer.growable) { + throw webidl.errors.exception({ + header: 'ArrayBuffer', + message: 'Received a resizable ArrayBuffer.' + }) + } + + // 4. Return the IDL DataView value that is a reference + // to the same object as V. + return V +} + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.ByteString +) + +webidl.converters['sequence>'] = webidl.sequenceConverter( + webidl.converters['sequence'] +) + +webidl.converters['record'] = webidl.recordConverter( + webidl.converters.ByteString, + webidl.converters.ByteString +) + +webidl.converters.Blob = webidl.interfaceConverter(webidl.is.Blob, 'Blob') + +webidl.converters.AbortSignal = webidl.interfaceConverter( + webidl.is.AbortSignal, + 'AbortSignal' +) + +module.exports = { + webidl +} diff --git a/lib/web/websocket/connection.js b/lib/web/websocket/connection.js new file mode 100644 index 0000000..0d0c580 --- /dev/null +++ b/lib/web/websocket/connection.js @@ -0,0 +1,325 @@ +'use strict' + +const { uid, states, sentCloseFrameState, emptyBuffer, opcodes } = require('./constants') +const { parseExtensions, isClosed, isClosing, isEstablished, validateCloseCodeAndReason } = require('./util') +const { channels } = require('../../core/diagnostics') +const { makeRequest } = require('../fetch/request') +const { fetching } = require('../fetch/index') +const { Headers, getHeadersList } = require('../fetch/headers') +const { getDecodeSplit } = require('../fetch/util') +const { WebsocketFrameSend } = require('./frame') +const assert = require('node:assert') + +/** @type {import('crypto')} */ +let crypto +try { + crypto = require('node:crypto') +/* c8 ignore next 3 */ +} catch { + +} + +/** + * @see https://websockets.spec.whatwg.org/#concept-websocket-establish + * @param {URL} url + * @param {string|string[]} protocols + * @param {import('./websocket').Handler} handler + * @param {Partial} options + */ +function establishWebSocketConnection (url, protocols, client, handler, options) { + // 1. Let requestURL be a copy of url, with its scheme set to "http", if url’s + // scheme is "ws", and to "https" otherwise. + const requestURL = url + + requestURL.protocol = url.protocol === 'ws:' ? 'http:' : 'https:' + + // 2. Let request be a new request, whose URL is requestURL, client is client, + // service-workers mode is "none", referrer is "no-referrer", mode is + // "websocket", credentials mode is "include", cache mode is "no-store" , + // and redirect mode is "error". + const request = makeRequest({ + urlList: [requestURL], + client, + serviceWorkers: 'none', + referrer: 'no-referrer', + mode: 'websocket', + credentials: 'include', + cache: 'no-store', + redirect: 'error' + }) + + // Note: undici extension, allow setting custom headers. + if (options.headers) { + const headersList = getHeadersList(new Headers(options.headers)) + + request.headersList = headersList + } + + // 3. Append (`Upgrade`, `websocket`) to request’s header list. + // 4. Append (`Connection`, `Upgrade`) to request’s header list. + // Note: both of these are handled by undici currently. + // https://github.com/nodejs/undici/blob/68c269c4144c446f3f1220951338daef4a6b5ec4/lib/client.js#L1397 + + // 5. Let keyValue be a nonce consisting of a randomly selected + // 16-byte value that has been forgiving-base64-encoded and + // isomorphic encoded. + const keyValue = crypto.randomBytes(16).toString('base64') + + // 6. Append (`Sec-WebSocket-Key`, keyValue) to request’s + // header list. + request.headersList.append('sec-websocket-key', keyValue, true) + + // 7. Append (`Sec-WebSocket-Version`, `13`) to request’s + // header list. + request.headersList.append('sec-websocket-version', '13', true) + + // 8. For each protocol in protocols, combine + // (`Sec-WebSocket-Protocol`, protocol) in request’s header + // list. + for (const protocol of protocols) { + request.headersList.append('sec-websocket-protocol', protocol, true) + } + + // 9. Let permessageDeflate be a user-agent defined + // "permessage-deflate" extension header value. + // https://github.com/mozilla/gecko-dev/blob/ce78234f5e653a5d3916813ff990f053510227bc/netwerk/protocol/websocket/WebSocketChannel.cpp#L2673 + const permessageDeflate = 'permessage-deflate; client_max_window_bits' + + // 10. Append (`Sec-WebSocket-Extensions`, permessageDeflate) to + // request’s header list. + request.headersList.append('sec-websocket-extensions', permessageDeflate, true) + + // 11. Fetch request with useParallelQueue set to true, and + // processResponse given response being these steps: + const controller = fetching({ + request, + useParallelQueue: true, + dispatcher: options.dispatcher, + processResponse (response) { + if (response.type === 'error') { + // If the WebSocket connection could not be established, it is also said + // that _The WebSocket Connection is Closed_, but not _cleanly_. + handler.readyState = states.CLOSED + } + + // 1. If response is a network error or its status is not 101, + // fail the WebSocket connection. + if (response.type === 'error' || response.status !== 101) { + failWebsocketConnection(handler, 1002, 'Received network error or non-101 status code.') + return + } + + // 2. If protocols is not the empty list and extracting header + // list values given `Sec-WebSocket-Protocol` and response’s + // header list results in null, failure, or the empty byte + // sequence, then fail the WebSocket connection. + if (protocols.length !== 0 && !response.headersList.get('Sec-WebSocket-Protocol')) { + failWebsocketConnection(handler, 1002, 'Server did not respond with sent protocols.') + return + } + + // 3. Follow the requirements stated step 2 to step 6, inclusive, + // of the last set of steps in section 4.1 of The WebSocket + // Protocol to validate response. This either results in fail + // the WebSocket connection or the WebSocket connection is + // established. + + // 2. If the response lacks an |Upgrade| header field or the |Upgrade| + // header field contains a value that is not an ASCII case- + // insensitive match for the value "websocket", the client MUST + // _Fail the WebSocket Connection_. + if (response.headersList.get('Upgrade')?.toLowerCase() !== 'websocket') { + failWebsocketConnection(handler, 1002, 'Server did not set Upgrade header to "websocket".') + return + } + + // 3. If the response lacks a |Connection| header field or the + // |Connection| header field doesn't contain a token that is an + // ASCII case-insensitive match for the value "Upgrade", the client + // MUST _Fail the WebSocket Connection_. + if (response.headersList.get('Connection')?.toLowerCase() !== 'upgrade') { + failWebsocketConnection(handler, 1002, 'Server did not set Connection header to "upgrade".') + return + } + + // 4. If the response lacks a |Sec-WebSocket-Accept| header field or + // the |Sec-WebSocket-Accept| contains a value other than the + // base64-encoded SHA-1 of the concatenation of the |Sec-WebSocket- + // Key| (as a string, not base64-decoded) with the string "258EAFA5- + // E914-47DA-95CA-C5AB0DC85B11" but ignoring any leading and + // trailing whitespace, the client MUST _Fail the WebSocket + // Connection_. + const secWSAccept = response.headersList.get('Sec-WebSocket-Accept') + const digest = crypto.createHash('sha1').update(keyValue + uid).digest('base64') + if (secWSAccept !== digest) { + failWebsocketConnection(handler, 1002, 'Incorrect hash received in Sec-WebSocket-Accept header.') + return + } + + // 5. If the response includes a |Sec-WebSocket-Extensions| header + // field and this header field indicates the use of an extension + // that was not present in the client's handshake (the server has + // indicated an extension not requested by the client), the client + // MUST _Fail the WebSocket Connection_. (The parsing of this + // header field to determine which extensions are requested is + // discussed in Section 9.1.) + const secExtension = response.headersList.get('Sec-WebSocket-Extensions') + let extensions + + if (secExtension !== null) { + extensions = parseExtensions(secExtension) + + if (!extensions.has('permessage-deflate')) { + failWebsocketConnection(handler, 1002, 'Sec-WebSocket-Extensions header does not match.') + return + } + } + + // 6. If the response includes a |Sec-WebSocket-Protocol| header field + // and this header field indicates the use of a subprotocol that was + // not present in the client's handshake (the server has indicated a + // subprotocol not requested by the client), the client MUST _Fail + // the WebSocket Connection_. + const secProtocol = response.headersList.get('Sec-WebSocket-Protocol') + + if (secProtocol !== null) { + const requestProtocols = getDecodeSplit('sec-websocket-protocol', request.headersList) + + // The client can request that the server use a specific subprotocol by + // including the |Sec-WebSocket-Protocol| field in its handshake. If it + // is specified, the server needs to include the same field and one of + // the selected subprotocol values in its response for the connection to + // be established. + if (!requestProtocols.includes(secProtocol)) { + failWebsocketConnection(handler, 1002, 'Protocol was not set in the opening handshake.') + return + } + } + + response.socket.on('data', handler.onSocketData) + response.socket.on('close', handler.onSocketClose) + response.socket.on('error', handler.onSocketError) + + if (channels.open.hasSubscribers) { + channels.open.publish({ + address: response.socket.address(), + protocol: secProtocol, + extensions: secExtension + }) + } + + handler.wasEverConnected = true + handler.onConnectionEstablished(response, extensions) + } + }) + + return controller +} + +/** + * @see https://whatpr.org/websockets/48.html#close-the-websocket + * @param {import('./websocket').Handler} object + * @param {number} [code=null] + * @param {string} [reason=''] + */ +function closeWebSocketConnection (object, code, reason, validate = false) { + // 1. If code was not supplied, let code be null. + code ??= null + + // 2. If reason was not supplied, let reason be the empty string. + reason ??= '' + + // 3. Validate close code and reason with code and reason. + if (validate) validateCloseCodeAndReason(code, reason) + + // 4. Run the first matching steps from the following list: + // - If object’s ready state is CLOSING (2) or CLOSED (3) + // - If the WebSocket connection is not yet established [WSP] + // - If the WebSocket closing handshake has not yet been started [WSP] + // - Otherwise + if (isClosed(object.readyState) || isClosing(object.readyState)) { + // Do nothing. + } else if (!isEstablished(object.readyState)) { + // Fail the WebSocket connection and set object’s ready state to CLOSING (2). [WSP] + failWebsocketConnection(object) + object.readyState = states.CLOSING + } else if (!object.closeState.has(sentCloseFrameState.SENT) && !object.closeState.has(sentCloseFrameState.RECEIVED)) { + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + + const frame = new WebsocketFrameSend() + + // If neither code nor reason is present, the WebSocket Close + // message must not have a body. + + // If code is present, then the status code to use in the + // WebSocket Close message must be the integer given by code. + // If code is null and reason is the empty string, the WebSocket Close frame must not have a body. + // If reason is non-empty but code is null, then set code to 1000 ("Normal Closure"). + if (reason.length !== 0 && code === null) { + code = 1000 + } + + // If code is set, then the status code to use in the WebSocket Close frame must be the integer given by code. + assert(code === null || Number.isInteger(code)) + + if (code === null && reason.length === 0) { + frame.frameData = emptyBuffer + } else if (code !== null && reason === null) { + frame.frameData = Buffer.allocUnsafe(2) + frame.frameData.writeUInt16BE(code, 0) + } else if (code !== null && reason !== null) { + // If reason is also present, then reasonBytes must be + // provided in the Close message after the status code. + frame.frameData = Buffer.allocUnsafe(2 + Buffer.byteLength(reason)) + frame.frameData.writeUInt16BE(code, 0) + // the body MAY contain UTF-8-encoded data with value /reason/ + frame.frameData.write(reason, 2, 'utf-8') + } else { + frame.frameData = emptyBuffer + } + + object.socket.write(frame.createFrame(opcodes.CLOSE)) + + object.closeState.add(sentCloseFrameState.SENT) + + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + object.readyState = states.CLOSING + } else { + // Set object’s ready state to CLOSING (2). + object.readyState = states.CLOSING + } +} + +/** + * @param {import('./websocket').Handler} handler + * @param {number} code + * @param {string|undefined} reason + * @returns {void} + */ +function failWebsocketConnection (handler, code, reason) { + // If _The WebSocket Connection is Established_ prior to the point where + // the endpoint is required to _Fail the WebSocket Connection_, the + // endpoint SHOULD send a Close frame with an appropriate status code + // (Section 7.4) before proceeding to _Close the WebSocket Connection_. + if (isEstablished(handler.readyState)) { + closeWebSocketConnection(handler, code, reason, false) + } + + handler.controller.abort() + + if (handler.socket?.destroyed === false) { + handler.socket.destroy() + } + + handler.onFail(code, reason) +} + +module.exports = { + establishWebSocketConnection, + failWebsocketConnection, + closeWebSocketConnection +} diff --git a/lib/web/websocket/constants.js b/lib/web/websocket/constants.js new file mode 100644 index 0000000..e4e6990 --- /dev/null +++ b/lib/web/websocket/constants.js @@ -0,0 +1,126 @@ +'use strict' + +/** + * This is a Globally Unique Identifier unique used to validate that the + * endpoint accepts websocket connections. + * @see https://www.rfc-editor.org/rfc/rfc6455.html#section-1.3 + * @type {'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'} + */ +const uid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +/** + * @type {PropertyDescriptor} + */ +const staticPropertyDescriptors = { + enumerable: true, + writable: false, + configurable: false +} + +/** + * The states of the WebSocket connection. + * + * @readonly + * @enum + * @property {0} CONNECTING + * @property {1} OPEN + * @property {2} CLOSING + * @property {3} CLOSED + */ +const states = { + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3 +} + +/** + * @readonly + * @enum + * @property {0} NOT_SENT + * @property {1} PROCESSING + * @property {2} SENT + */ +const sentCloseFrameState = { + SENT: 1, + RECEIVED: 2 +} + +/** + * The WebSocket opcodes. + * + * @readonly + * @enum + * @property {0x0} CONTINUATION + * @property {0x1} TEXT + * @property {0x2} BINARY + * @property {0x8} CLOSE + * @property {0x9} PING + * @property {0xA} PONG + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 + */ +const opcodes = { + CONTINUATION: 0x0, + TEXT: 0x1, + BINARY: 0x2, + CLOSE: 0x8, + PING: 0x9, + PONG: 0xA +} + +/** + * The maximum value for an unsigned 16-bit integer. + * + * @type {65535} 2 ** 16 - 1 + */ +const maxUnsigned16Bit = 65535 + +/** + * The states of the parser. + * + * @readonly + * @enum + * @property {0} INFO + * @property {2} PAYLOADLENGTH_16 + * @property {3} PAYLOADLENGTH_64 + * @property {4} READ_DATA + */ +const parserStates = { + INFO: 0, + PAYLOADLENGTH_16: 2, + PAYLOADLENGTH_64: 3, + READ_DATA: 4 +} + +/** + * An empty buffer. + * + * @type {Buffer} + */ +const emptyBuffer = Buffer.allocUnsafe(0) + +/** + * @readonly + * @property {1} text + * @property {2} typedArray + * @property {3} arrayBuffer + * @property {4} blob + */ +const sendHints = { + text: 1, + typedArray: 2, + arrayBuffer: 3, + blob: 4 +} + +module.exports = { + uid, + sentCloseFrameState, + staticPropertyDescriptors, + states, + opcodes, + maxUnsigned16Bit, + parserStates, + emptyBuffer, + sendHints +} diff --git a/lib/web/websocket/events.js b/lib/web/websocket/events.js new file mode 100644 index 0000000..3432279 --- /dev/null +++ b/lib/web/websocket/events.js @@ -0,0 +1,331 @@ +'use strict' + +const { webidl } = require('../fetch/webidl') +const { kEnumerableProperty } = require('../../core/util') +const { kConstruct } = require('../../core/symbols') + +/** + * @see https://html.spec.whatwg.org/multipage/comms.html#messageevent + */ +class MessageEvent extends Event { + #eventInit + + constructor (type, eventInitDict = {}) { + if (type === kConstruct) { + super(arguments[1], arguments[2]) + webidl.util.markAsUncloneable(this) + return + } + + const prefix = 'MessageEvent constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + type = webidl.converters.DOMString(type, prefix, 'type') + eventInitDict = webidl.converters.MessageEventInit(eventInitDict, prefix, 'eventInitDict') + + super(type, eventInitDict) + + this.#eventInit = eventInitDict + webidl.util.markAsUncloneable(this) + } + + get data () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.data + } + + get origin () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.origin + } + + get lastEventId () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.lastEventId + } + + get source () { + webidl.brandCheck(this, MessageEvent) + + return this.#eventInit.source + } + + get ports () { + webidl.brandCheck(this, MessageEvent) + + if (!Object.isFrozen(this.#eventInit.ports)) { + Object.freeze(this.#eventInit.ports) + } + + return this.#eventInit.ports + } + + initMessageEvent ( + type, + bubbles = false, + cancelable = false, + data = null, + origin = '', + lastEventId = '', + source = null, + ports = [] + ) { + webidl.brandCheck(this, MessageEvent) + + webidl.argumentLengthCheck(arguments, 1, 'MessageEvent.initMessageEvent') + + return new MessageEvent(type, { + bubbles, cancelable, data, origin, lastEventId, source, ports + }) + } + + static createFastMessageEvent (type, init) { + const messageEvent = new MessageEvent(kConstruct, type, init) + messageEvent.#eventInit = init + messageEvent.#eventInit.data ??= null + messageEvent.#eventInit.origin ??= '' + messageEvent.#eventInit.lastEventId ??= '' + messageEvent.#eventInit.source ??= null + messageEvent.#eventInit.ports ??= [] + return messageEvent + } +} + +const { createFastMessageEvent } = MessageEvent +delete MessageEvent.createFastMessageEvent + +/** + * @see https://websockets.spec.whatwg.org/#the-closeevent-interface + */ +class CloseEvent extends Event { + #eventInit + + constructor (type, eventInitDict = {}) { + const prefix = 'CloseEvent constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + type = webidl.converters.DOMString(type, prefix, 'type') + eventInitDict = webidl.converters.CloseEventInit(eventInitDict) + + super(type, eventInitDict) + + this.#eventInit = eventInitDict + webidl.util.markAsUncloneable(this) + } + + get wasClean () { + webidl.brandCheck(this, CloseEvent) + + return this.#eventInit.wasClean + } + + get code () { + webidl.brandCheck(this, CloseEvent) + + return this.#eventInit.code + } + + get reason () { + webidl.brandCheck(this, CloseEvent) + + return this.#eventInit.reason + } +} + +// https://html.spec.whatwg.org/multipage/webappapis.html#the-errorevent-interface +class ErrorEvent extends Event { + #eventInit + + constructor (type, eventInitDict) { + const prefix = 'ErrorEvent constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + super(type, eventInitDict) + webidl.util.markAsUncloneable(this) + + type = webidl.converters.DOMString(type, prefix, 'type') + eventInitDict = webidl.converters.ErrorEventInit(eventInitDict ?? {}) + + this.#eventInit = eventInitDict + } + + get message () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.message + } + + get filename () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.filename + } + + get lineno () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.lineno + } + + get colno () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.colno + } + + get error () { + webidl.brandCheck(this, ErrorEvent) + + return this.#eventInit.error + } +} + +Object.defineProperties(MessageEvent.prototype, { + [Symbol.toStringTag]: { + value: 'MessageEvent', + configurable: true + }, + data: kEnumerableProperty, + origin: kEnumerableProperty, + lastEventId: kEnumerableProperty, + source: kEnumerableProperty, + ports: kEnumerableProperty, + initMessageEvent: kEnumerableProperty +}) + +Object.defineProperties(CloseEvent.prototype, { + [Symbol.toStringTag]: { + value: 'CloseEvent', + configurable: true + }, + reason: kEnumerableProperty, + code: kEnumerableProperty, + wasClean: kEnumerableProperty +}) + +Object.defineProperties(ErrorEvent.prototype, { + [Symbol.toStringTag]: { + value: 'ErrorEvent', + configurable: true + }, + message: kEnumerableProperty, + filename: kEnumerableProperty, + lineno: kEnumerableProperty, + colno: kEnumerableProperty, + error: kEnumerableProperty +}) + +webidl.converters.MessagePort = webidl.interfaceConverter( + webidl.is.MessagePort, + 'MessagePort' +) + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.MessagePort +) + +const eventInit = [ + { + key: 'bubbles', + converter: webidl.converters.boolean, + defaultValue: () => false + }, + { + key: 'cancelable', + converter: webidl.converters.boolean, + defaultValue: () => false + }, + { + key: 'composed', + converter: webidl.converters.boolean, + defaultValue: () => false + } +] + +webidl.converters.MessageEventInit = webidl.dictionaryConverter([ + ...eventInit, + { + key: 'data', + converter: webidl.converters.any, + defaultValue: () => null + }, + { + key: 'origin', + converter: webidl.converters.USVString, + defaultValue: () => '' + }, + { + key: 'lastEventId', + converter: webidl.converters.DOMString, + defaultValue: () => '' + }, + { + key: 'source', + // Node doesn't implement WindowProxy or ServiceWorker, so the only + // valid value for source is a MessagePort. + converter: webidl.nullableConverter(webidl.converters.MessagePort), + defaultValue: () => null + }, + { + key: 'ports', + converter: webidl.converters['sequence'], + defaultValue: () => new Array(0) + } +]) + +webidl.converters.CloseEventInit = webidl.dictionaryConverter([ + ...eventInit, + { + key: 'wasClean', + converter: webidl.converters.boolean, + defaultValue: () => false + }, + { + key: 'code', + converter: webidl.converters['unsigned short'], + defaultValue: () => 0 + }, + { + key: 'reason', + converter: webidl.converters.USVString, + defaultValue: () => '' + } +]) + +webidl.converters.ErrorEventInit = webidl.dictionaryConverter([ + ...eventInit, + { + key: 'message', + converter: webidl.converters.DOMString, + defaultValue: () => '' + }, + { + key: 'filename', + converter: webidl.converters.USVString, + defaultValue: () => '' + }, + { + key: 'lineno', + converter: webidl.converters['unsigned long'], + defaultValue: () => 0 + }, + { + key: 'colno', + converter: webidl.converters['unsigned long'], + defaultValue: () => 0 + }, + { + key: 'error', + converter: webidl.converters.any + } +]) + +module.exports = { + MessageEvent, + CloseEvent, + ErrorEvent, + createFastMessageEvent +} diff --git a/lib/web/websocket/frame.js b/lib/web/websocket/frame.js new file mode 100644 index 0000000..e773b33 --- /dev/null +++ b/lib/web/websocket/frame.js @@ -0,0 +1,138 @@ +'use strict' + +const { maxUnsigned16Bit, opcodes } = require('./constants') + +const BUFFER_SIZE = 8 * 1024 + +/** @type {import('crypto')} */ +let crypto +let buffer = null +let bufIdx = BUFFER_SIZE + +try { + crypto = require('node:crypto') +/* c8 ignore next 3 */ +} catch { + crypto = { + // not full compatibility, but minimum. + randomFillSync: function randomFillSync (buffer, _offset, _size) { + for (let i = 0; i < buffer.length; ++i) { + buffer[i] = Math.random() * 255 | 0 + } + return buffer + } + } +} + +function generateMask () { + if (bufIdx === BUFFER_SIZE) { + bufIdx = 0 + crypto.randomFillSync((buffer ??= Buffer.allocUnsafeSlow(BUFFER_SIZE)), 0, BUFFER_SIZE) + } + return [buffer[bufIdx++], buffer[bufIdx++], buffer[bufIdx++], buffer[bufIdx++]] +} + +class WebsocketFrameSend { + /** + * @param {Buffer|undefined} data + */ + constructor (data) { + this.frameData = data + } + + createFrame (opcode) { + const frameData = this.frameData + const maskKey = generateMask() + const bodyLength = frameData?.byteLength ?? 0 + + /** @type {number} */ + let payloadLength = bodyLength // 0-125 + let offset = 6 + + if (bodyLength > maxUnsigned16Bit) { + offset += 8 // payload length is next 8 bytes + payloadLength = 127 + } else if (bodyLength > 125) { + offset += 2 // payload length is next 2 bytes + payloadLength = 126 + } + + const buffer = Buffer.allocUnsafe(bodyLength + offset) + + // Clear first 2 bytes, everything else is overwritten + buffer[0] = buffer[1] = 0 + buffer[0] |= 0x80 // FIN + buffer[0] = (buffer[0] & 0xF0) + opcode // opcode + + /*! ws. MIT License. Einar Otto Stangvik */ + buffer[offset - 4] = maskKey[0] + buffer[offset - 3] = maskKey[1] + buffer[offset - 2] = maskKey[2] + buffer[offset - 1] = maskKey[3] + + buffer[1] = payloadLength + + if (payloadLength === 126) { + buffer.writeUInt16BE(bodyLength, 2) + } else if (payloadLength === 127) { + // Clear extended payload length + buffer[2] = buffer[3] = 0 + buffer.writeUIntBE(bodyLength, 4, 6) + } + + buffer[1] |= 0x80 // MASK + + // mask body + for (let i = 0; i < bodyLength; ++i) { + buffer[offset + i] = frameData[i] ^ maskKey[i & 3] + } + + return buffer + } + + /** + * @param {Uint8Array} buffer + */ + static createFastTextFrame (buffer) { + const maskKey = generateMask() + + const bodyLength = buffer.length + + // mask body + for (let i = 0; i < bodyLength; ++i) { + buffer[i] ^= maskKey[i & 3] + } + + let payloadLength = bodyLength + let offset = 6 + + if (bodyLength > maxUnsigned16Bit) { + offset += 8 // payload length is next 8 bytes + payloadLength = 127 + } else if (bodyLength > 125) { + offset += 2 // payload length is next 2 bytes + payloadLength = 126 + } + const head = Buffer.allocUnsafeSlow(offset) + + head[0] = 0x80 /* FIN */ | opcodes.TEXT /* opcode TEXT */ + head[1] = payloadLength | 0x80 /* MASK */ + head[offset - 4] = maskKey[0] + head[offset - 3] = maskKey[1] + head[offset - 2] = maskKey[2] + head[offset - 1] = maskKey[3] + + if (payloadLength === 126) { + head.writeUInt16BE(bodyLength, 2) + } else if (payloadLength === 127) { + head[2] = head[3] = 0 + head.writeUIntBE(bodyLength, 4, 6) + } + + return [head, buffer] + } +} + +module.exports = { + WebsocketFrameSend +} diff --git a/lib/web/websocket/permessage-deflate.js b/lib/web/websocket/permessage-deflate.js new file mode 100644 index 0000000..76cb366 --- /dev/null +++ b/lib/web/websocket/permessage-deflate.js @@ -0,0 +1,70 @@ +'use strict' + +const { createInflateRaw, Z_DEFAULT_WINDOWBITS } = require('node:zlib') +const { isValidClientWindowBits } = require('./util') + +const tail = Buffer.from([0x00, 0x00, 0xff, 0xff]) +const kBuffer = Symbol('kBuffer') +const kLength = Symbol('kLength') + +class PerMessageDeflate { + /** @type {import('node:zlib').InflateRaw} */ + #inflate + + #options = {} + + constructor (extensions) { + this.#options.serverNoContextTakeover = extensions.has('server_no_context_takeover') + this.#options.serverMaxWindowBits = extensions.get('server_max_window_bits') + } + + decompress (chunk, fin, callback) { + // An endpoint uses the following algorithm to decompress a message. + // 1. Append 4 octets of 0x00 0x00 0xff 0xff to the tail end of the + // payload of the message. + // 2. Decompress the resulting data using DEFLATE. + + if (!this.#inflate) { + let windowBits = Z_DEFAULT_WINDOWBITS + + if (this.#options.serverMaxWindowBits) { // empty values default to Z_DEFAULT_WINDOWBITS + if (!isValidClientWindowBits(this.#options.serverMaxWindowBits)) { + callback(new Error('Invalid server_max_window_bits')) + return + } + + windowBits = Number.parseInt(this.#options.serverMaxWindowBits) + } + + this.#inflate = createInflateRaw({ windowBits }) + this.#inflate[kBuffer] = [] + this.#inflate[kLength] = 0 + + this.#inflate.on('data', (data) => { + this.#inflate[kBuffer].push(data) + this.#inflate[kLength] += data.length + }) + + this.#inflate.on('error', (err) => { + this.#inflate = null + callback(err) + }) + } + + this.#inflate.write(chunk) + if (fin) { + this.#inflate.write(tail) + } + + this.#inflate.flush(() => { + const full = Buffer.concat(this.#inflate[kBuffer], this.#inflate[kLength]) + + this.#inflate[kBuffer].length = 0 + this.#inflate[kLength] = 0 + + callback(null, full) + }) + } +} + +module.exports = { PerMessageDeflate } diff --git a/lib/web/websocket/receiver.js b/lib/web/websocket/receiver.js new file mode 100644 index 0000000..3ea603e --- /dev/null +++ b/lib/web/websocket/receiver.js @@ -0,0 +1,454 @@ +'use strict' + +const { Writable } = require('node:stream') +const assert = require('node:assert') +const { parserStates, opcodes, states, emptyBuffer, sentCloseFrameState } = require('./constants') +const { channels } = require('../../core/diagnostics') +const { + isValidStatusCode, + isValidOpcode, + websocketMessageReceived, + utf8Decode, + isControlFrame, + isTextBinaryFrame, + isContinuationFrame +} = require('./util') +const { failWebsocketConnection } = require('./connection') +const { WebsocketFrameSend } = require('./frame') +const { PerMessageDeflate } = require('./permessage-deflate') + +// This code was influenced by ws released under the MIT license. +// Copyright (c) 2011 Einar Otto Stangvik +// Copyright (c) 2013 Arnout Kazemier and contributors +// Copyright (c) 2016 Luigi Pinca and contributors + +class ByteParser extends Writable { + #buffers = [] + #fragmentsBytes = 0 + #byteOffset = 0 + #loop = false + + #state = parserStates.INFO + + #info = {} + #fragments = [] + + /** @type {Map} */ + #extensions + + /** @type {import('./websocket').Handler} */ + #handler + + constructor (handler, extensions) { + super() + + this.#handler = handler + this.#extensions = extensions == null ? new Map() : extensions + + if (this.#extensions.has('permessage-deflate')) { + this.#extensions.set('permessage-deflate', new PerMessageDeflate(extensions)) + } + } + + /** + * @param {Buffer} chunk + * @param {() => void} callback + */ + _write (chunk, _, callback) { + this.#buffers.push(chunk) + this.#byteOffset += chunk.length + this.#loop = true + + this.run(callback) + } + + /** + * Runs whenever a new chunk is received. + * Callback is called whenever there are no more chunks buffering, + * or not enough bytes are buffered to parse. + */ + run (callback) { + while (this.#loop) { + if (this.#state === parserStates.INFO) { + // If there aren't enough bytes to parse the payload length, etc. + if (this.#byteOffset < 2) { + return callback() + } + + const buffer = this.consume(2) + const fin = (buffer[0] & 0x80) !== 0 + const opcode = buffer[0] & 0x0F + const masked = (buffer[1] & 0x80) === 0x80 + + const fragmented = !fin && opcode !== opcodes.CONTINUATION + const payloadLength = buffer[1] & 0x7F + + const rsv1 = buffer[0] & 0x40 + const rsv2 = buffer[0] & 0x20 + const rsv3 = buffer[0] & 0x10 + + if (!isValidOpcode(opcode)) { + failWebsocketConnection(this.#handler, 1002, 'Invalid opcode received') + return callback() + } + + if (masked) { + failWebsocketConnection(this.#handler, 1002, 'Frame cannot be masked') + return callback() + } + + // MUST be 0 unless an extension is negotiated that defines meanings + // for non-zero values. If a nonzero value is received and none of + // the negotiated extensions defines the meaning of such a nonzero + // value, the receiving endpoint MUST _Fail the WebSocket + // Connection_. + // This document allocates the RSV1 bit of the WebSocket header for + // PMCEs and calls the bit the "Per-Message Compressed" bit. On a + // WebSocket connection where a PMCE is in use, this bit indicates + // whether a message is compressed or not. + if (rsv1 !== 0 && !this.#extensions.has('permessage-deflate')) { + failWebsocketConnection(this.#handler, 1002, 'Expected RSV1 to be clear.') + return + } + + if (rsv2 !== 0 || rsv3 !== 0) { + failWebsocketConnection(this.#handler, 1002, 'RSV1, RSV2, RSV3 must be clear') + return + } + + if (fragmented && !isTextBinaryFrame(opcode)) { + // Only text and binary frames can be fragmented + failWebsocketConnection(this.#handler, 1002, 'Invalid frame type was fragmented.') + return + } + + // If we are already parsing a text/binary frame and do not receive either + // a continuation frame or close frame, fail the connection. + if (isTextBinaryFrame(opcode) && this.#fragments.length > 0) { + failWebsocketConnection(this.#handler, 1002, 'Expected continuation frame') + return + } + + if (this.#info.fragmented && fragmented) { + // A fragmented frame can't be fragmented itself + failWebsocketConnection(this.#handler, 1002, 'Fragmented frame exceeded 125 bytes.') + return + } + + // "All control frames MUST have a payload length of 125 bytes or less + // and MUST NOT be fragmented." + if ((payloadLength > 125 || fragmented) && isControlFrame(opcode)) { + failWebsocketConnection(this.#handler, 1002, 'Control frame either too large or fragmented') + return + } + + if (isContinuationFrame(opcode) && this.#fragments.length === 0 && !this.#info.compressed) { + failWebsocketConnection(this.#handler, 1002, 'Unexpected continuation frame') + return + } + + if (payloadLength <= 125) { + this.#info.payloadLength = payloadLength + this.#state = parserStates.READ_DATA + } else if (payloadLength === 126) { + this.#state = parserStates.PAYLOADLENGTH_16 + } else if (payloadLength === 127) { + this.#state = parserStates.PAYLOADLENGTH_64 + } + + if (isTextBinaryFrame(opcode)) { + this.#info.binaryType = opcode + this.#info.compressed = rsv1 !== 0 + } + + this.#info.opcode = opcode + this.#info.masked = masked + this.#info.fin = fin + this.#info.fragmented = fragmented + } else if (this.#state === parserStates.PAYLOADLENGTH_16) { + if (this.#byteOffset < 2) { + return callback() + } + + const buffer = this.consume(2) + + this.#info.payloadLength = buffer.readUInt16BE(0) + this.#state = parserStates.READ_DATA + } else if (this.#state === parserStates.PAYLOADLENGTH_64) { + if (this.#byteOffset < 8) { + return callback() + } + + const buffer = this.consume(8) + const upper = buffer.readUInt32BE(0) + + // 2^31 is the maximum bytes an arraybuffer can contain + // on 32-bit systems. Although, on 64-bit systems, this is + // 2^53-1 bytes. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Invalid_array_length + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;drc=1946212ac0100668f14eb9e2843bdd846e510a1e;bpv=1;bpt=1;l=1275 + // https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-array-buffer.h;l=34;drc=1946212ac0100668f14eb9e2843bdd846e510a1e + if (upper > 2 ** 31 - 1) { + failWebsocketConnection(this.#handler, 1009, 'Received payload length > 2^31 bytes.') + return + } + + const lower = buffer.readUInt32BE(4) + + this.#info.payloadLength = (upper << 8) + lower + this.#state = parserStates.READ_DATA + } else if (this.#state === parserStates.READ_DATA) { + if (this.#byteOffset < this.#info.payloadLength) { + return callback() + } + + const body = this.consume(this.#info.payloadLength) + + if (isControlFrame(this.#info.opcode)) { + this.#loop = this.parseControlFrame(body) + this.#state = parserStates.INFO + } else { + if (!this.#info.compressed) { + this.writeFragments(body) + + // If the frame is not fragmented, a message has been received. + // If the frame is fragmented, it will terminate with a fin bit set + // and an opcode of 0 (continuation), therefore we handle that when + // parsing continuation frames, not here. + if (!this.#info.fragmented && this.#info.fin) { + websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments()) + } + + this.#state = parserStates.INFO + } else { + this.#extensions.get('permessage-deflate').decompress(body, this.#info.fin, (error, data) => { + if (error) { + failWebsocketConnection(this.#handler, 1007, error.message) + return + } + + this.writeFragments(data) + + if (!this.#info.fin) { + this.#state = parserStates.INFO + this.#loop = true + this.run(callback) + return + } + + websocketMessageReceived(this.#handler, this.#info.binaryType, this.consumeFragments()) + + this.#loop = true + this.#state = parserStates.INFO + this.run(callback) + }) + + this.#loop = false + break + } + } + } + } + } + + /** + * Take n bytes from the buffered Buffers + * @param {number} n + * @returns {Buffer} + */ + consume (n) { + if (n > this.#byteOffset) { + throw new Error('Called consume() before buffers satiated.') + } else if (n === 0) { + return emptyBuffer + } + + this.#byteOffset -= n + + const first = this.#buffers[0] + + if (first.length > n) { + // replace with remaining buffer + this.#buffers[0] = first.subarray(n, first.length) + return first.subarray(0, n) + } else if (first.length === n) { + // prefect match + return this.#buffers.shift() + } else { + let offset = 0 + // If Buffer.allocUnsafe is used, extra copies will be made because the offset is non-zero. + const buffer = Buffer.allocUnsafeSlow(n) + while (offset !== n) { + const next = this.#buffers[0] + const length = next.length + + if (length + offset === n) { + buffer.set(this.#buffers.shift(), offset) + break + } else if (length + offset > n) { + buffer.set(next.subarray(0, n - offset), offset) + this.#buffers[0] = next.subarray(n - offset) + break + } else { + buffer.set(this.#buffers.shift(), offset) + offset += length + } + } + + return buffer + } + } + + writeFragments (fragment) { + this.#fragmentsBytes += fragment.length + this.#fragments.push(fragment) + } + + consumeFragments () { + const fragments = this.#fragments + + if (fragments.length === 1) { + // single fragment + this.#fragmentsBytes = 0 + return fragments.shift() + } + + let offset = 0 + // If Buffer.allocUnsafe is used, extra copies will be made because the offset is non-zero. + const output = Buffer.allocUnsafeSlow(this.#fragmentsBytes) + + for (let i = 0; i < fragments.length; ++i) { + const buffer = fragments[i] + output.set(buffer, offset) + offset += buffer.length + } + + this.#fragments = [] + this.#fragmentsBytes = 0 + + return output + } + + parseCloseBody (data) { + assert(data.length !== 1) + + // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5 + /** @type {number|undefined} */ + let code + + if (data.length >= 2) { + // _The WebSocket Connection Close Code_ is + // defined as the status code (Section 7.4) contained in the first Close + // control frame received by the application + code = data.readUInt16BE(0) + } + + if (code !== undefined && !isValidStatusCode(code)) { + return { code: 1002, reason: 'Invalid status code', error: true } + } + + // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.6 + /** @type {Buffer} */ + let reason = data.subarray(2) + + // Remove BOM + if (reason[0] === 0xEF && reason[1] === 0xBB && reason[2] === 0xBF) { + reason = reason.subarray(3) + } + + try { + reason = utf8Decode(reason) + } catch { + return { code: 1007, reason: 'Invalid UTF-8', error: true } + } + + return { code, reason, error: false } + } + + /** + * Parses control frames. + * @param {Buffer} body + */ + parseControlFrame (body) { + const { opcode, payloadLength } = this.#info + + if (opcode === opcodes.CLOSE) { + if (payloadLength === 1) { + failWebsocketConnection(this.#handler, 1002, 'Received close frame with a 1-byte body.') + return false + } + + this.#info.closeInfo = this.parseCloseBody(body) + + if (this.#info.closeInfo.error) { + const { code, reason } = this.#info.closeInfo + + failWebsocketConnection(this.#handler, code, reason) + return false + } + + // Upon receiving such a frame, the other peer sends a + // Close frame in response, if it hasn't already sent one. + if (!this.#handler.closeState.has(sentCloseFrameState.SENT) && !this.#handler.closeState.has(sentCloseFrameState.RECEIVED)) { + // If an endpoint receives a Close frame and did not previously send a + // Close frame, the endpoint MUST send a Close frame in response. (When + // sending a Close frame in response, the endpoint typically echos the + // status code it received.) + let body = emptyBuffer + if (this.#info.closeInfo.code) { + body = Buffer.allocUnsafe(2) + body.writeUInt16BE(this.#info.closeInfo.code, 0) + } + const closeFrame = new WebsocketFrameSend(body) + + this.#handler.socket.write(closeFrame.createFrame(opcodes.CLOSE)) + this.#handler.closeState.add(sentCloseFrameState.SENT) + } + + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + this.#handler.readyState = states.CLOSING + this.#handler.closeState.add(sentCloseFrameState.RECEIVED) + + return false + } else if (opcode === opcodes.PING) { + // Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in + // response, unless it already received a Close frame. + // A Pong frame sent in response to a Ping frame must have identical + // "Application data" + + if (!this.#handler.closeState.has(sentCloseFrameState.RECEIVED)) { + const frame = new WebsocketFrameSend(body) + + this.#handler.socket.write(frame.createFrame(opcodes.PONG)) + + if (channels.ping.hasSubscribers) { + channels.ping.publish({ + payload: body + }) + } + } + } else if (opcode === opcodes.PONG) { + // A Pong frame MAY be sent unsolicited. This serves as a + // unidirectional heartbeat. A response to an unsolicited Pong frame is + // not expected. + + if (channels.pong.hasSubscribers) { + channels.pong.publish({ + payload: body + }) + } + } + + return true + } + + get closingInfo () { + return this.#info.closeInfo + } +} + +module.exports = { + ByteParser +} diff --git a/lib/web/websocket/sender.js b/lib/web/websocket/sender.js new file mode 100644 index 0000000..c647bf6 --- /dev/null +++ b/lib/web/websocket/sender.js @@ -0,0 +1,109 @@ +'use strict' + +const { WebsocketFrameSend } = require('./frame') +const { opcodes, sendHints } = require('./constants') +const FixedQueue = require('../../dispatcher/fixed-queue') + +/** + * @typedef {object} SendQueueNode + * @property {Promise | null} promise + * @property {((...args: any[]) => any)} callback + * @property {Buffer | null} frame + */ + +class SendQueue { + /** + * @type {FixedQueue} + */ + #queue = new FixedQueue() + + /** + * @type {boolean} + */ + #running = false + + /** @type {import('node:net').Socket} */ + #socket + + constructor (socket) { + this.#socket = socket + } + + add (item, cb, hint) { + if (hint !== sendHints.blob) { + if (!this.#running) { + // TODO(@tsctx): support fast-path for string on running + if (hint === sendHints.text) { + // special fast-path for string + const { 0: head, 1: body } = WebsocketFrameSend.createFastTextFrame(item) + this.#socket.cork() + this.#socket.write(head) + this.#socket.write(body, cb) + this.#socket.uncork() + } else { + // direct writing + this.#socket.write(createFrame(item, hint), cb) + } + } else { + /** @type {SendQueueNode} */ + const node = { + promise: null, + callback: cb, + frame: createFrame(item, hint) + } + this.#queue.push(node) + } + return + } + + /** @type {SendQueueNode} */ + const node = { + promise: item.arrayBuffer().then((ab) => { + node.promise = null + node.frame = createFrame(ab, hint) + }), + callback: cb, + frame: null + } + + this.#queue.push(node) + + if (!this.#running) { + this.#run() + } + } + + async #run () { + this.#running = true + const queue = this.#queue + while (!queue.isEmpty()) { + const node = queue.shift() + // wait pending promise + if (node.promise !== null) { + await node.promise + } + // write + this.#socket.write(node.frame, node.callback) + // cleanup + node.callback = node.frame = null + } + this.#running = false + } +} + +function createFrame (data, hint) { + return new WebsocketFrameSend(toBuffer(data, hint)).createFrame(hint === sendHints.text ? opcodes.TEXT : opcodes.BINARY) +} + +function toBuffer (data, hint) { + switch (hint) { + case sendHints.text: + case sendHints.typedArray: + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + case sendHints.arrayBuffer: + case sendHints.blob: + return new Uint8Array(data) + } +} + +module.exports = { SendQueue } diff --git a/lib/web/websocket/stream/websocketerror.js b/lib/web/websocket/stream/websocketerror.js new file mode 100644 index 0000000..888af98 --- /dev/null +++ b/lib/web/websocket/stream/websocketerror.js @@ -0,0 +1,83 @@ +'use strict' + +const { webidl } = require('../../fetch/webidl') +const { validateCloseCodeAndReason } = require('../util') +const { kConstruct } = require('../../../core/symbols') +const { kEnumerableProperty } = require('../../../core/util') + +class WebSocketError extends DOMException { + #closeCode + #reason + + constructor (message = '', init = undefined) { + message = webidl.converters.DOMString(message, 'WebSocketError', 'message') + + // 1. Set this 's name to " WebSocketError ". + // 2. Set this 's message to message . + super(message, 'WebSocketError') + + if (init === kConstruct) { + return + } else if (init !== null) { + init = webidl.converters.WebSocketCloseInfo(init) + } + + // 3. Let code be init [" closeCode "] if it exists , or null otherwise. + let code = init.closeCode ?? null + + // 4. Let reason be init [" reason "] if it exists , or the empty string otherwise. + const reason = init.reason ?? '' + + // 5. Validate close code and reason with code and reason . + validateCloseCodeAndReason(code, reason) + + // 6. If reason is non-empty, but code is not set, then set code to 1000 ("Normal Closure"). + if (reason.length !== 0 && code === null) { + code = 1000 + } + + // 7. Set this 's closeCode to code . + this.#closeCode = code + + // 8. Set this 's reason to reason . + this.#reason = reason + } + + get closeCode () { + return this.#closeCode + } + + get reason () { + return this.#reason + } + + /** + * @param {string} message + * @param {number|null} code + * @param {string} reason + */ + static createUnvalidatedWebSocketError (message, code, reason) { + const error = new WebSocketError(message, kConstruct) + error.#closeCode = code + error.#reason = reason + return error + } +} + +const { createUnvalidatedWebSocketError } = WebSocketError +delete WebSocketError.createUnvalidatedWebSocketError + +Object.defineProperties(WebSocketError.prototype, { + closeCode: kEnumerableProperty, + reason: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'WebSocketError', + writable: false, + enumerable: false, + configurable: true + } +}) + +webidl.is.WebSocketError = webidl.util.MakeTypeAssertion(WebSocketError) + +module.exports = { WebSocketError, createUnvalidatedWebSocketError } diff --git a/lib/web/websocket/stream/websocketstream.js b/lib/web/websocket/stream/websocketstream.js new file mode 100644 index 0000000..c44183d --- /dev/null +++ b/lib/web/websocket/stream/websocketstream.js @@ -0,0 +1,485 @@ +'use strict' + +const { createDeferredPromise, environmentSettingsObject } = require('../../fetch/util') +const { states, opcodes, sentCloseFrameState } = require('../constants') +const { webidl } = require('../../fetch/webidl') +const { getURLRecord, isValidSubprotocol, isEstablished, utf8Decode } = require('../util') +const { establishWebSocketConnection, failWebsocketConnection, closeWebSocketConnection } = require('../connection') +const { types } = require('node:util') +const { channels } = require('../../../core/diagnostics') +const { WebsocketFrameSend } = require('../frame') +const { ByteParser } = require('../receiver') +const { WebSocketError, createUnvalidatedWebSocketError } = require('./websocketerror') +const { utf8DecodeBytes } = require('../../fetch/util') +const { kEnumerableProperty } = require('../../../core/util') + +let emittedExperimentalWarning = false + +class WebSocketStream { + // Each WebSocketStream object has an associated url , which is a URL record . + /** @type {URL} */ + #url + + // Each WebSocketStream object has an associated opened promise , which is a promise. + /** @type {ReturnType} */ + #openedPromise + + // Each WebSocketStream object has an associated closed promise , which is a promise. + /** @type {ReturnType} */ + #closedPromise + + // Each WebSocketStream object has an associated readable stream , which is a ReadableStream . + /** @type {ReadableStream} */ + #readableStream + /** @type {ReadableStreamDefaultController} */ + #readableStreamController + + // Each WebSocketStream object has an associated writable stream , which is a WritableStream . + /** @type {WritableStream} */ + #writableStream + + // Each WebSocketStream object has an associated boolean handshake aborted , which is initially false. + #handshakeAborted = false + + /** @type {import('../websocket').Handler} */ + #handler = { + // https://whatpr.org/websockets/48/7b748d3...d5570f3.html#feedback-to-websocket-stream-from-the-protocol + onConnectionEstablished: (response, extensions) => this.#onConnectionEstablished(response, extensions), + onFail: (_code, _reason) => {}, + onMessage: (opcode, data) => this.#onMessage(opcode, data), + onParserError: (err) => failWebsocketConnection(this.#handler, null, err.message), + onParserDrain: () => this.#handler.socket.resume(), + onSocketData: (chunk) => { + if (!this.#parser.write(chunk)) { + this.#handler.socket.pause() + } + }, + onSocketError: (err) => { + this.#handler.readyState = states.CLOSING + + if (channels.socketError.hasSubscribers) { + channels.socketError.publish(err) + } + + this.#handler.socket.destroy() + }, + onSocketClose: () => this.#onSocketClose(), + + readyState: states.CONNECTING, + socket: null, + closeState: new Set(), + controller: null, + wasEverConnected: false + } + + /** @type {import('../receiver').ByteParser} */ + #parser + + constructor (url, options = undefined) { + if (!emittedExperimentalWarning) { + process.emitWarning('WebSocketStream is experimental! Expect it to change at any time.', { + code: 'UNDICI-WSS' + }) + emittedExperimentalWarning = true + } + + webidl.argumentLengthCheck(arguments, 1, 'WebSocket') + + url = webidl.converters.USVString(url) + if (options !== null) { + options = webidl.converters.WebSocketStreamOptions(options) + } + + // 1. Let baseURL be this 's relevant settings object 's API base URL . + const baseURL = environmentSettingsObject.settingsObject.baseUrl + + // 2. Let urlRecord be the result of getting a URL record given url and baseURL . + const urlRecord = getURLRecord(url, baseURL) + + // 3. Let protocols be options [" protocols "] if it exists , otherwise an empty sequence. + const protocols = options.protocols + + // 4. If any of the values in protocols occur more than once or otherwise fail to match the requirements for elements that comprise the value of ` Sec-WebSocket-Protocol ` fields as defined by The WebSocket Protocol , then throw a " SyntaxError " DOMException . [WSP] + if (protocols.length !== new Set(protocols.map(p => p.toLowerCase())).size) { + throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') + } + + if (protocols.length > 0 && !protocols.every(p => isValidSubprotocol(p))) { + throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') + } + + // 5. Set this 's url to urlRecord . + this.#url = urlRecord.toString() + + // 6. Set this 's opened promise and closed promise to new promises. + this.#openedPromise = createDeferredPromise() + this.#closedPromise = createDeferredPromise() + + // 7. Apply backpressure to the WebSocket. + // TODO + + // 8. If options [" signal "] exists , + if (options.signal != null) { + // 8.1. Let signal be options [" signal "]. + const signal = options.signal + + // 8.2. If signal is aborted , then reject this 's opened promise and closed promise with signal ’s abort reason + // and return. + if (signal.aborted) { + this.#openedPromise.reject(signal.reason) + this.#closedPromise.reject(signal.reason) + return + } + + // 8.3. Add the following abort steps to signal : + signal.addEventListener('abort', () => { + // 8.3.1. If the WebSocket connection is not yet established : [WSP] + if (!isEstablished(this.#handler.readyState)) { + // 8.3.1.1. Fail the WebSocket connection . + failWebsocketConnection(this.#handler) + + // Set this 's ready state to CLOSING . + this.#handler.readyState = states.CLOSING + + // Reject this 's opened promise and closed promise with signal ’s abort reason . + this.#openedPromise.reject(signal.reason) + this.#closedPromise.reject(signal.reason) + + // Set this 's handshake aborted to true. + this.#handshakeAborted = true + } + }, { once: true }) + } + + // 9. Let client be this 's relevant settings object . + const client = environmentSettingsObject.settingsObject + + // 10. Run this step in parallel : + // 10.1. Establish a WebSocket connection given urlRecord , protocols , and client . [FETCH] + this.#handler.controller = establishWebSocketConnection( + urlRecord, + protocols, + client, + this.#handler, + options + ) + } + + // The url getter steps are to return this 's url , serialized . + get url () { + return this.#url.toString() + } + + // The opened getter steps are to return this 's opened promise . + get opened () { + return this.#openedPromise.promise + } + + // The closed getter steps are to return this 's closed promise . + get closed () { + return this.#closedPromise.promise + } + + // The close( closeInfo ) method steps are: + close (closeInfo = undefined) { + if (closeInfo !== null) { + closeInfo = webidl.converters.WebSocketCloseInfo(closeInfo) + } + + // 1. Let code be closeInfo [" closeCode "] if present, or null otherwise. + const code = closeInfo.closeCode ?? null + + // 2. Let reason be closeInfo [" reason "]. + const reason = closeInfo.reason + + // 3. Close the WebSocket with this , code , and reason . + closeWebSocketConnection(this.#handler, code, reason, true) + } + + #write (chunk) { + // 1. Let promise be a new promise created in stream ’s relevant realm . + const promise = createDeferredPromise() + + // 2. Let data be null. + let data = null + + // 3. Let opcode be null. + let opcode = null + + // 4. If chunk is a BufferSource , + if (ArrayBuffer.isView(chunk) || types.isArrayBuffer(chunk)) { + // 4.1. Set data to a copy of the bytes given chunk . + data = new Uint8Array(ArrayBuffer.isView(chunk) ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) : chunk) + + // 4.2. Set opcode to a binary frame opcode. + opcode = opcodes.BINARY + } else { + // 5. Otherwise, + + // 5.1. Let string be the result of converting chunk to an IDL USVString . + // If this throws an exception, return a promise rejected with the exception. + let string + + try { + string = webidl.converters.DOMString(chunk) + } catch (e) { + promise.reject(e) + return + } + + // 5.2. Set data to the result of UTF-8 encoding string . + data = new TextEncoder().encode(string) + + // 5.3. Set opcode to a text frame opcode. + opcode = opcodes.TEXT + } + + // 6. In parallel, + // 6.1. Wait until there is sufficient buffer space in stream to send the message. + + // 6.2. If the closing handshake has not yet started , Send a WebSocket Message to stream comprised of data using opcode . + if (!this.#handler.closeState.has(sentCloseFrameState.SENT) && !this.#handler.closeState.has(sentCloseFrameState.RECEIVED)) { + const frame = new WebsocketFrameSend(data) + + this.#handler.socket.write(frame.createFrame(opcode), () => { + promise.resolve(undefined) + }) + } + + // 6.3. Queue a global task on the WebSocket task source given stream ’s relevant global object to resolve promise with undefined. + return promise + } + + /** @type {import('../websocket').Handler['onConnectionEstablished']} */ + #onConnectionEstablished (response, parsedExtensions) { + this.#handler.socket = response.socket + + const parser = new ByteParser(this.#handler, parsedExtensions) + parser.on('drain', () => this.#handler.onParserDrain()) + parser.on('error', (err) => this.#handler.onParserError(err)) + + this.#parser = parser + + // 1. Change stream ’s ready state to OPEN (1). + this.#handler.readyState = states.OPEN + + // 2. Set stream ’s was ever connected to true. + // This is done in the opening handshake. + + // 3. Let extensions be the extensions in use . + const extensions = parsedExtensions ?? '' + + // 4. Let protocol be the subprotocol in use . + const protocol = response.headersList.get('sec-websocket-protocol') ?? '' + + // 5. Let pullAlgorithm be an action that pulls bytes from stream . + // 6. Let cancelAlgorithm be an action that cancels stream with reason , given reason . + // 7. Let readable be a new ReadableStream . + // 8. Set up readable with pullAlgorithm and cancelAlgorithm . + const readable = new ReadableStream({ + start: (controller) => { + this.#readableStreamController = controller + }, + pull (controller) { + let chunk + while (controller.desiredSize > 0 && (chunk = response.socket.read()) !== null) { + controller.enqueue(chunk) + } + }, + cancel: (reason) => this.#cancel(reason) + }) + + // 9. Let writeAlgorithm be an action that writes chunk to stream , given chunk . + // 10. Let closeAlgorithm be an action that closes stream . + // 11. Let abortAlgorithm be an action that aborts stream with reason , given reason . + // 12. Let writable be a new WritableStream . + // 13. Set up writable with writeAlgorithm , closeAlgorithm , and abortAlgorithm . + const writable = new WritableStream({ + write: (chunk) => this.#write(chunk), + close: () => closeWebSocketConnection(this.#handler, null, null), + abort: (reason) => this.#closeUsingReason(reason) + }) + + // Set stream ’s readable stream to readable . + this.#readableStream = readable + + // Set stream ’s writable stream to writable . + this.#writableStream = writable + + // Resolve stream ’s opened promise with WebSocketOpenInfo «[ " extensions " → extensions , " protocol " → protocol , " readable " → readable , " writable " → writable ]». + this.#openedPromise.resolve({ + extensions, + protocol, + readable, + writable + }) + } + + /** @type {import('../websocket').Handler['onMessage']} */ + #onMessage (type, data) { + // 1. If stream’s ready state is not OPEN (1), then return. + if (this.#handler.readyState !== states.OPEN) { + return + } + + // 2. Let chunk be determined by switching on type: + // - type indicates that the data is Text + // a new DOMString containing data + // - type indicates that the data is Binary + // a new Uint8Array object, created in the relevant Realm of the + // WebSocketStream object, whose contents are data + let chunk + + if (type === opcodes.TEXT) { + try { + chunk = utf8Decode(data) + } catch { + failWebsocketConnection(this.#handler, 'Received invalid UTF-8 in text frame.') + return + } + } else if (type === opcodes.BINARY) { + chunk = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + } + + // 3. Enqueue chunk into stream’s readable stream. + this.#readableStreamController.enqueue(chunk) + + // 4. Apply backpressure to the WebSocket. + } + + /** @type {import('../websocket').Handler['onSocketClose']} */ + #onSocketClose () { + const wasClean = + this.#handler.closeState.has(sentCloseFrameState.SENT) && + this.#handler.closeState.has(sentCloseFrameState.RECEIVED) + + // 1. Change the ready state to CLOSED (3). + this.#handler.readyState = states.CLOSED + + // 2. If stream ’s handshake aborted is true, then return. + if (this.#handshakeAborted) { + return + } + + // 3. If stream ’s was ever connected is false, then reject stream ’s opened promise with a new WebSocketError. + if (!this.#handler.wasEverConnected) { + this.#openedPromise.reject(new WebSocketError('Socket never opened')) + } + + const result = this.#parser.closingInfo + + // 4. Let code be the WebSocket connection close code . + // https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5 + // If this Close control frame contains no status code, _The WebSocket + // Connection Close Code_ is considered to be 1005. If _The WebSocket + // Connection is Closed_ and no Close control frame was received by the + // endpoint (such as could occur if the underlying transport connection + // is lost), _The WebSocket Connection Close Code_ is considered to be + // 1006. + let code = result?.code ?? 1005 + + if (!this.#handler.closeState.has(sentCloseFrameState.SENT) && !this.#handler.closeState.has(sentCloseFrameState.RECEIVED)) { + code = 1006 + } + + // 5. Let reason be the result of applying UTF-8 decode without BOM to the WebSocket connection close reason . + const reason = result?.reason == null ? '' : utf8DecodeBytes(Buffer.from(result.reason)) + + // 6. If the connection was closed cleanly , + if (wasClean) { + // 6.1. Close stream ’s readable stream . + this.#readableStream.cancel().catch(() => {}) + + // 6.2. Error stream ’s writable stream with an " InvalidStateError " DOMException indicating that a closed WebSocketStream cannot be written to. + if (!this.#writableStream.locked) { + this.#writableStream.abort(new DOMException('A closed WebSocketStream cannot be written to', 'InvalidStateError')) + } + + // 6.3. Resolve stream ’s closed promise with WebSocketCloseInfo «[ " closeCode " → code , " reason " → reason ]». + this.#closedPromise.resolve({ + closeCode: code, + reason + }) + } else { + // 7. Otherwise, + + // 7.1. Let error be a new WebSocketError whose closeCode is code and reason is reason . + const error = createUnvalidatedWebSocketError('unclean close', code, reason) + + // 7.2. Error stream ’s readable stream with error . + this.#readableStreamController.error(error) + + // 7.3. Error stream ’s writable stream with error . + this.#writableStream.abort(error) + + // 7.4. Reject stream ’s closed promise with error . + this.#closedPromise.reject(error) + } + } + + #closeUsingReason (reason) { + // 1. Let code be null. + let code = null + + // 2. Let reasonString be the empty string. + let reasonString = '' + + // 3. If reason implements WebSocketError , + if (webidl.is.WebSocketError(reason)) { + // 3.1. Set code to reason ’s closeCode . + code = reason.closeCode + + // 3.2. Set reasonString to reason ’s reason . + reasonString = reason.reason + } + + // 4. Close the WebSocket with stream , code , and reasonString . If this throws an exception, + // discard code and reasonString and close the WebSocket with stream . + closeWebSocketConnection(this.#handler, code, reasonString) + } + + // To cancel a WebSocketStream stream given reason , close using reason giving stream and reason . + #cancel (reason) { + this.#closeUsingReason(reason) + } +} + +Object.defineProperties(WebSocketStream.prototype, { + url: kEnumerableProperty, + opened: kEnumerableProperty, + closed: kEnumerableProperty, + close: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'WebSocketStream', + writable: false, + enumerable: false, + configurable: true + } +}) + +webidl.converters.WebSocketStreamOptions = webidl.dictionaryConverter([ + { + key: 'protocols', + converter: webidl.sequenceConverter(webidl.converters.USVString), + defaultValue: () => [] + }, + { + key: 'signal', + converter: webidl.nullableConverter(webidl.converters.AbortSignal), + defaultValue: () => null + } +]) + +webidl.converters.WebSocketCloseInfo = webidl.dictionaryConverter([ + { + key: 'closeCode', + converter: (V) => webidl.converters['unsigned short'](V, { enforceRange: true }) + }, + { + key: 'reason', + converter: webidl.converters.USVString, + defaultValue: () => '' + } +]) + +module.exports = { WebSocketStream } diff --git a/lib/web/websocket/util.js b/lib/web/websocket/util.js new file mode 100644 index 0000000..3e9c547 --- /dev/null +++ b/lib/web/websocket/util.js @@ -0,0 +1,338 @@ +'use strict' + +const { states, opcodes } = require('./constants') +const { isUtf8 } = require('node:buffer') +const { collectASequenceOfCodePointsFast, removeHTTPWhitespace } = require('../fetch/data-url') + +/** + * @param {number} readyState + * @returns {boolean} + */ +function isConnecting (readyState) { + // If the WebSocket connection is not yet established, and the connection + // is not yet closed, then the WebSocket connection is in the CONNECTING state. + return readyState === states.CONNECTING +} + +/** + * @param {number} readyState + * @returns {boolean} + */ +function isEstablished (readyState) { + // If the server's response is validated as provided for above, it is + // said that _The WebSocket Connection is Established_ and that the + // WebSocket Connection is in the OPEN state. + return readyState === states.OPEN +} + +/** + * @param {number} readyState + * @returns {boolean} + */ +function isClosing (readyState) { + // Upon either sending or receiving a Close control frame, it is said + // that _The WebSocket Closing Handshake is Started_ and that the + // WebSocket connection is in the CLOSING state. + return readyState === states.CLOSING +} + +/** + * @param {number} readyState + * @returns {boolean} + */ +function isClosed (readyState) { + return readyState === states.CLOSED +} + +/** + * @see https://dom.spec.whatwg.org/#concept-event-fire + * @param {string} e + * @param {EventTarget} target + * @param {(...args: ConstructorParameters) => Event} eventFactory + * @param {EventInit | undefined} eventInitDict + * @returns {void} + */ +function fireEvent (e, target, eventFactory = (type, init) => new Event(type, init), eventInitDict = {}) { + // 1. If eventConstructor is not given, then let eventConstructor be Event. + + // 2. Let event be the result of creating an event given eventConstructor, + // in the relevant realm of target. + // 3. Initialize event’s type attribute to e. + const event = eventFactory(e, eventInitDict) + + // 4. Initialize any other IDL attributes of event as described in the + // invocation of this algorithm. + + // 5. Return the result of dispatching event at target, with legacy target + // override flag set if set. + target.dispatchEvent(event) +} + +/** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + * @param {import('./websocket').Handler} handler + * @param {number} type Opcode + * @param {Buffer} data application data + * @returns {void} + */ +function websocketMessageReceived (handler, type, data) { + handler.onMessage(type, data) +} + +/** + * @param {Buffer} buffer + * @returns {ArrayBuffer} + */ +function toArrayBuffer (buffer) { + if (buffer.byteLength === buffer.buffer.byteLength) { + return buffer.buffer + } + return new Uint8Array(buffer).buffer +} + +/** + * @see https://datatracker.ietf.org/doc/html/rfc6455 + * @see https://datatracker.ietf.org/doc/html/rfc2616 + * @see https://bugs.chromium.org/p/chromium/issues/detail?id=398407 + * @param {string} protocol + * @returns {boolean} + */ +function isValidSubprotocol (protocol) { + // If present, this value indicates one + // or more comma-separated subprotocol the client wishes to speak, + // ordered by preference. The elements that comprise this value + // MUST be non-empty strings with characters in the range U+0021 to + // U+007E not including separator characters as defined in + // [RFC2616] and MUST all be unique strings. + if (protocol.length === 0) { + return false + } + + for (let i = 0; i < protocol.length; ++i) { + const code = protocol.charCodeAt(i) + + if ( + code < 0x21 || // CTL, contains SP (0x20) and HT (0x09) + code > 0x7E || + code === 0x22 || // " + code === 0x28 || // ( + code === 0x29 || // ) + code === 0x2C || // , + code === 0x2F || // / + code === 0x3A || // : + code === 0x3B || // ; + code === 0x3C || // < + code === 0x3D || // = + code === 0x3E || // > + code === 0x3F || // ? + code === 0x40 || // @ + code === 0x5B || // [ + code === 0x5C || // \ + code === 0x5D || // ] + code === 0x7B || // { + code === 0x7D // } + ) { + return false + } + } + + return true +} + +/** + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7-4 + * @param {number} code + * @returns {boolean} + */ +function isValidStatusCode (code) { + if (code >= 1000 && code < 1015) { + return ( + code !== 1004 && // reserved + code !== 1005 && // "MUST NOT be set as a status code" + code !== 1006 // "MUST NOT be set as a status code" + ) + } + + return code >= 3000 && code <= 4999 +} + +/** + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-5.5 + * @param {number} opcode + * @returns {boolean} + */ +function isControlFrame (opcode) { + return ( + opcode === opcodes.CLOSE || + opcode === opcodes.PING || + opcode === opcodes.PONG + ) +} + +/** + * @param {number} opcode + * @returns {boolean} + */ +function isContinuationFrame (opcode) { + return opcode === opcodes.CONTINUATION +} + +/** + * @param {number} opcode + * @returns {boolean} + */ +function isTextBinaryFrame (opcode) { + return opcode === opcodes.TEXT || opcode === opcodes.BINARY +} + +/** + * + * @param {number} opcode + * @returns {boolean} + */ +function isValidOpcode (opcode) { + return isTextBinaryFrame(opcode) || isContinuationFrame(opcode) || isControlFrame(opcode) +} + +/** + * Parses a Sec-WebSocket-Extensions header value. + * @param {string} extensions + * @returns {Map} + */ +// TODO(@Uzlopak, @KhafraDev): make compliant https://datatracker.ietf.org/doc/html/rfc6455#section-9.1 +function parseExtensions (extensions) { + const position = { position: 0 } + const extensionList = new Map() + + while (position.position < extensions.length) { + const pair = collectASequenceOfCodePointsFast(';', extensions, position) + const [name, value = ''] = pair.split('=') + + extensionList.set( + removeHTTPWhitespace(name, true, false), + removeHTTPWhitespace(value, false, true) + ) + + position.position++ + } + + return extensionList +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc7692#section-7.1.2.2 + * @description "client-max-window-bits = 1*DIGIT" + * @param {string} value + * @returns {boolean} + */ +function isValidClientWindowBits (value) { + for (let i = 0; i < value.length; i++) { + const byte = value.charCodeAt(i) + + if (byte < 0x30 || byte > 0x39) { + return false + } + } + + return true +} + +/** + * @see https://whatpr.org/websockets/48/7b748d3...d5570f3.html#get-a-url-record + * @param {string} url + * @param {string} [baseURL] + */ +function getURLRecord (url, baseURL) { + // 1. Let urlRecord be the result of applying the URL parser to url with baseURL . + // 2. If urlRecord is failure, then throw a " SyntaxError " DOMException . + let urlRecord + + try { + urlRecord = new URL(url, baseURL) + } catch (e) { + throw new DOMException(e, 'SyntaxError') + } + + // 3. If urlRecord ’s scheme is " http ", then set urlRecord ’s scheme to " ws ". + // 4. Otherwise, if urlRecord ’s scheme is " https ", set urlRecord ’s scheme to " wss ". + if (urlRecord.protocol === 'http:') { + urlRecord.protocol = 'ws:' + } else if (urlRecord.protocol === 'https:') { + urlRecord.protocol = 'wss:' + } + + // 5. If urlRecord ’s scheme is not " ws " or " wss ", then throw a " SyntaxError " DOMException . + if (urlRecord.protocol !== 'ws:' && urlRecord.protocol !== 'wss:') { + throw new DOMException('expected a ws: or wss: url', 'SyntaxError') + } + + // If urlRecord ’s fragment is non-null, then throw a " SyntaxError " DOMException . + if (urlRecord.hash.length || urlRecord.href.endsWith('#')) { + throw new DOMException('hash', 'SyntaxError') + } + + // Return urlRecord . + return urlRecord +} + +// https://whatpr.org/websockets/48.html#validate-close-code-and-reason +function validateCloseCodeAndReason (code, reason) { + // 1. If code is not null, but is neither an integer equal to + // 1000 nor an integer in the range 3000 to 4999, inclusive, + // throw an "InvalidAccessError" DOMException. + if (code !== null) { + if (code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException('invalid code', 'InvalidAccessError') + } + } + + // 2. If reason is not null, then: + if (reason !== null) { + // 2.1. Let reasonBytes be the result of UTF-8 encoding reason. + // 2.2. If reasonBytes is longer than 123 bytes, then throw a + // "SyntaxError" DOMException. + const reasonBytesLength = Buffer.byteLength(reason) + + if (reasonBytesLength > 123) { + throw new DOMException(`Reason must be less than 123 bytes; received ${reasonBytesLength}`, 'SyntaxError') + } + } +} + +/** + * Converts a Buffer to utf-8, even on platforms without icu. + * @type {(buffer: Buffer) => string} + */ +const utf8Decode = (() => { + if (typeof process.versions.icu === 'string') { + const fatalDecoder = new TextDecoder('utf-8', { fatal: true }) + return fatalDecoder.decode.bind(fatalDecoder) + } + return function (buffer) { + if (isUtf8(buffer)) { + return buffer.toString('utf-8') + } + throw new TypeError('Invalid utf-8 received.') + } +})() + +module.exports = { + isConnecting, + isEstablished, + isClosing, + isClosed, + fireEvent, + isValidSubprotocol, + isValidStatusCode, + websocketMessageReceived, + utf8Decode, + isControlFrame, + isContinuationFrame, + isTextBinaryFrame, + isValidOpcode, + parseExtensions, + isValidClientWindowBits, + toArrayBuffer, + getURLRecord, + validateCloseCodeAndReason +} diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js new file mode 100644 index 0000000..651eadd --- /dev/null +++ b/lib/web/websocket/websocket.js @@ -0,0 +1,686 @@ +'use strict' + +const { webidl } = require('../fetch/webidl') +const { URLSerializer } = require('../fetch/data-url') +const { environmentSettingsObject } = require('../fetch/util') +const { staticPropertyDescriptors, states, sentCloseFrameState, sendHints, opcodes } = require('./constants') +const { + isConnecting, + isEstablished, + isClosing, + isValidSubprotocol, + fireEvent, + utf8Decode, + toArrayBuffer, + getURLRecord +} = require('./util') +const { establishWebSocketConnection, closeWebSocketConnection, failWebsocketConnection } = require('./connection') +const { ByteParser } = require('./receiver') +const { kEnumerableProperty } = require('../../core/util') +const { getGlobalDispatcher } = require('../../global') +const { types } = require('node:util') +const { ErrorEvent, CloseEvent, createFastMessageEvent } = require('./events') +const { SendQueue } = require('./sender') +const { channels } = require('../../core/diagnostics') + +/** + * @typedef {object} Handler + * @property {(response: any, extensions?: string[]) => void} onConnectionEstablished + * @property {(code: number, reason: any) => void} onFail + * @property {(opcode: number, data: Buffer) => void} onMessage + * @property {(error: Error) => void} onParserError + * @property {() => void} onParserDrain + * @property {(chunk: Buffer) => void} onSocketData + * @property {(err: Error) => void} onSocketError + * @property {() => void} onSocketClose + * + * @property {number} readyState + * @property {import('stream').Duplex} socket + * @property {Set} closeState + * @property {import('../fetch/index').Fetch} controller + * @property {boolean} [wasEverConnected=false] + */ + +// https://websockets.spec.whatwg.org/#interface-definition +class WebSocket extends EventTarget { + #events = { + open: null, + error: null, + close: null, + message: null + } + + #bufferedAmount = 0 + #protocol = '' + #extensions = '' + + /** @type {SendQueue} */ + #sendQueue + + /** @type {Handler} */ + #handler = { + onConnectionEstablished: (response, extensions) => this.#onConnectionEstablished(response, extensions), + onFail: (code, reason) => this.#onFail(code, reason), + onMessage: (opcode, data) => this.#onMessage(opcode, data), + onParserError: (err) => failWebsocketConnection(this.#handler, null, err.message), + onParserDrain: () => this.#onParserDrain(), + onSocketData: (chunk) => { + if (!this.#parser.write(chunk)) { + this.#handler.socket.pause() + } + }, + onSocketError: (err) => { + this.#handler.readyState = states.CLOSING + + if (channels.socketError.hasSubscribers) { + channels.socketError.publish(err) + } + + this.#handler.socket.destroy() + }, + onSocketClose: () => this.#onSocketClose(), + + readyState: states.CONNECTING, + socket: null, + closeState: new Set(), + controller: null, + wasEverConnected: false + } + + #url + #binaryType + /** @type {import('./receiver').ByteParser} */ + #parser + + /** + * @param {string} url + * @param {string|string[]} protocols + */ + constructor (url, protocols = []) { + super() + + webidl.util.markAsUncloneable(this) + + const prefix = 'WebSocket constructor' + webidl.argumentLengthCheck(arguments, 1, prefix) + + const options = webidl.converters['DOMString or sequence or WebSocketInit'](protocols, prefix, 'options') + + url = webidl.converters.USVString(url) + protocols = options.protocols + + // 1. Let baseURL be this's relevant settings object's API base URL. + const baseURL = environmentSettingsObject.settingsObject.baseUrl + + // 2. Let urlRecord be the result of getting a URL record given url and baseURL. + const urlRecord = getURLRecord(url, baseURL) + + // 3. If protocols is a string, set protocols to a sequence consisting + // of just that string. + if (typeof protocols === 'string') { + protocols = [protocols] + } + + // 4. If any of the values in protocols occur more than once or otherwise + // fail to match the requirements for elements that comprise the value + // of `Sec-WebSocket-Protocol` fields as defined by The WebSocket + // protocol, then throw a "SyntaxError" DOMException. + if (protocols.length !== new Set(protocols.map(p => p.toLowerCase())).size) { + throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') + } + + if (protocols.length > 0 && !protocols.every(p => isValidSubprotocol(p))) { + throw new DOMException('Invalid Sec-WebSocket-Protocol value', 'SyntaxError') + } + + // 5. Set this's url to urlRecord. + this.#url = new URL(urlRecord.href) + + // 6. Let client be this's relevant settings object. + const client = environmentSettingsObject.settingsObject + + // 7. Run this step in parallel: + // 7.1. Establish a WebSocket connection given urlRecord, protocols, + // and client. + this.#handler.controller = establishWebSocketConnection( + urlRecord, + protocols, + client, + this.#handler, + options + ) + + // Each WebSocket object has an associated ready state, which is a + // number representing the state of the connection. Initially it must + // be CONNECTING (0). + this.#handler.readyState = WebSocket.CONNECTING + + // The extensions attribute must initially return the empty string. + + // The protocol attribute must initially return the empty string. + + // Each WebSocket object has an associated binary type, which is a + // BinaryType. Initially it must be "blob". + this.#binaryType = 'blob' + } + + /** + * @see https://websockets.spec.whatwg.org/#dom-websocket-close + * @param {number|undefined} code + * @param {string|undefined} reason + */ + close (code = undefined, reason = undefined) { + webidl.brandCheck(this, WebSocket) + + const prefix = 'WebSocket.close' + + if (code !== undefined) { + code = webidl.converters['unsigned short'](code, prefix, 'code', { clamp: true }) + } + + if (reason !== undefined) { + reason = webidl.converters.USVString(reason) + } + + // 1. If code is the special value "missing", then set code to null. + code ??= null + + // 2. If reason is the special value "missing", then set reason to the empty string. + reason ??= '' + + // 3. Close the WebSocket with this, code, and reason. + closeWebSocketConnection(this.#handler, code, reason, true) + } + + /** + * @see https://websockets.spec.whatwg.org/#dom-websocket-send + * @param {NodeJS.TypedArray|ArrayBuffer|Blob|string} data + */ + send (data) { + webidl.brandCheck(this, WebSocket) + + const prefix = 'WebSocket.send' + webidl.argumentLengthCheck(arguments, 1, prefix) + + data = webidl.converters.WebSocketSendData(data, prefix, 'data') + + // 1. If this's ready state is CONNECTING, then throw an + // "InvalidStateError" DOMException. + if (isConnecting(this.#handler.readyState)) { + throw new DOMException('Sent before connected.', 'InvalidStateError') + } + + // 2. Run the appropriate set of steps from the following list: + // https://datatracker.ietf.org/doc/html/rfc6455#section-6.1 + // https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 + + if (!isEstablished(this.#handler.readyState) || isClosing(this.#handler.readyState)) { + return + } + + // If data is a string + if (typeof data === 'string') { + // If the WebSocket connection is established and the WebSocket + // closing handshake has not yet started, then the user agent + // must send a WebSocket Message comprised of the data argument + // using a text frame opcode; if the data cannot be sent, e.g. + // because it would need to be buffered but the buffer is full, + // the user agent must flag the WebSocket as full and then close + // the WebSocket connection. Any invocation of this method with a + // string argument that does not throw an exception must increase + // the bufferedAmount attribute by the number of bytes needed to + // express the argument as UTF-8. + + const buffer = Buffer.from(data) + + this.#bufferedAmount += buffer.byteLength + this.#sendQueue.add(buffer, () => { + this.#bufferedAmount -= buffer.byteLength + }, sendHints.text) + } else if (types.isArrayBuffer(data)) { + // If the WebSocket connection is established, and the WebSocket + // closing handshake has not yet started, then the user agent must + // send a WebSocket Message comprised of data using a binary frame + // opcode; if the data cannot be sent, e.g. because it would need + // to be buffered but the buffer is full, the user agent must flag + // the WebSocket as full and then close the WebSocket connection. + // The data to be sent is the data stored in the buffer described + // by the ArrayBuffer object. Any invocation of this method with an + // ArrayBuffer argument that does not throw an exception must + // increase the bufferedAmount attribute by the length of the + // ArrayBuffer in bytes. + + this.#bufferedAmount += data.byteLength + this.#sendQueue.add(data, () => { + this.#bufferedAmount -= data.byteLength + }, sendHints.arrayBuffer) + } else if (ArrayBuffer.isView(data)) { + // If the WebSocket connection is established, and the WebSocket + // closing handshake has not yet started, then the user agent must + // send a WebSocket Message comprised of data using a binary frame + // opcode; if the data cannot be sent, e.g. because it would need to + // be buffered but the buffer is full, the user agent must flag the + // WebSocket as full and then close the WebSocket connection. The + // data to be sent is the data stored in the section of the buffer + // described by the ArrayBuffer object that data references. Any + // invocation of this method with this kind of argument that does + // not throw an exception must increase the bufferedAmount attribute + // by the length of data’s buffer in bytes. + + this.#bufferedAmount += data.byteLength + this.#sendQueue.add(data, () => { + this.#bufferedAmount -= data.byteLength + }, sendHints.typedArray) + } else if (webidl.is.Blob(data)) { + // If the WebSocket connection is established, and the WebSocket + // closing handshake has not yet started, then the user agent must + // send a WebSocket Message comprised of data using a binary frame + // opcode; if the data cannot be sent, e.g. because it would need to + // be buffered but the buffer is full, the user agent must flag the + // WebSocket as full and then close the WebSocket connection. The data + // to be sent is the raw data represented by the Blob object. Any + // invocation of this method with a Blob argument that does not throw + // an exception must increase the bufferedAmount attribute by the size + // of the Blob object’s raw data, in bytes. + + this.#bufferedAmount += data.size + this.#sendQueue.add(data, () => { + this.#bufferedAmount -= data.size + }, sendHints.blob) + } + } + + get readyState () { + webidl.brandCheck(this, WebSocket) + + // The readyState getter steps are to return this's ready state. + return this.#handler.readyState + } + + get bufferedAmount () { + webidl.brandCheck(this, WebSocket) + + return this.#bufferedAmount + } + + get url () { + webidl.brandCheck(this, WebSocket) + + // The url getter steps are to return this's url, serialized. + return URLSerializer(this.#url) + } + + get extensions () { + webidl.brandCheck(this, WebSocket) + + return this.#extensions + } + + get protocol () { + webidl.brandCheck(this, WebSocket) + + return this.#protocol + } + + get onopen () { + webidl.brandCheck(this, WebSocket) + + return this.#events.open + } + + set onopen (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.open) { + this.removeEventListener('open', this.#events.open) + } + + if (typeof fn === 'function') { + this.#events.open = fn + this.addEventListener('open', fn) + } else { + this.#events.open = null + } + } + + get onerror () { + webidl.brandCheck(this, WebSocket) + + return this.#events.error + } + + set onerror (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.error) { + this.removeEventListener('error', this.#events.error) + } + + if (typeof fn === 'function') { + this.#events.error = fn + this.addEventListener('error', fn) + } else { + this.#events.error = null + } + } + + get onclose () { + webidl.brandCheck(this, WebSocket) + + return this.#events.close + } + + set onclose (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.close) { + this.removeEventListener('close', this.#events.close) + } + + if (typeof fn === 'function') { + this.#events.close = fn + this.addEventListener('close', fn) + } else { + this.#events.close = null + } + } + + get onmessage () { + webidl.brandCheck(this, WebSocket) + + return this.#events.message + } + + set onmessage (fn) { + webidl.brandCheck(this, WebSocket) + + if (this.#events.message) { + this.removeEventListener('message', this.#events.message) + } + + if (typeof fn === 'function') { + this.#events.message = fn + this.addEventListener('message', fn) + } else { + this.#events.message = null + } + } + + get binaryType () { + webidl.brandCheck(this, WebSocket) + + return this.#binaryType + } + + set binaryType (type) { + webidl.brandCheck(this, WebSocket) + + if (type !== 'blob' && type !== 'arraybuffer') { + this.#binaryType = 'blob' + } else { + this.#binaryType = type + } + } + + /** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + */ + #onConnectionEstablished (response, parsedExtensions) { + // processResponse is called when the "response’s header list has been received and initialized." + // once this happens, the connection is open + this.#handler.socket = response.socket + + const parser = new ByteParser(this.#handler, parsedExtensions) + parser.on('drain', () => this.#handler.onParserDrain()) + parser.on('error', (err) => this.#handler.onParserError(err)) + + this.#parser = parser + this.#sendQueue = new SendQueue(response.socket) + + // 1. Change the ready state to OPEN (1). + this.#handler.readyState = states.OPEN + + // 2. Change the extensions attribute’s value to the extensions in use, if + // it is not the null value. + // https://datatracker.ietf.org/doc/html/rfc6455#section-9.1 + const extensions = response.headersList.get('sec-websocket-extensions') + + if (extensions !== null) { + this.#extensions = extensions + } + + // 3. Change the protocol attribute’s value to the subprotocol in use, if + // it is not the null value. + // https://datatracker.ietf.org/doc/html/rfc6455#section-1.9 + const protocol = response.headersList.get('sec-websocket-protocol') + + if (protocol !== null) { + this.#protocol = protocol + } + + // 4. Fire an event named open at the WebSocket object. + fireEvent('open', this) + } + + #onFail (code, reason) { + if (reason) { + // TODO: process.nextTick + fireEvent('error', this, (type, init) => new ErrorEvent(type, init), { + error: new Error(reason), + message: reason + }) + } + + if (!this.#handler.wasEverConnected) { + this.#handler.readyState = states.CLOSED + + // If the WebSocket connection could not be established, it is also said + // that _The WebSocket Connection is Closed_, but not _cleanly_. + fireEvent('close', this, (type, init) => new CloseEvent(type, init), { + wasClean: false, code, reason + }) + } + } + + #onMessage (type, data) { + // 1. If ready state is not OPEN (1), then return. + if (this.#handler.readyState !== states.OPEN) { + return + } + + // 2. Let dataForEvent be determined by switching on type and binary type: + let dataForEvent + + if (type === opcodes.TEXT) { + // -> type indicates that the data is Text + // a new DOMString containing data + try { + dataForEvent = utf8Decode(data) + } catch { + failWebsocketConnection(this.#handler, 1007, 'Received invalid UTF-8 in text frame.') + return + } + } else if (type === opcodes.BINARY) { + if (this.#binaryType === 'blob') { + // -> type indicates that the data is Binary and binary type is "blob" + // a new Blob object, created in the relevant Realm of the WebSocket + // object, that represents data as its raw data + dataForEvent = new Blob([data]) + } else { + // -> type indicates that the data is Binary and binary type is "arraybuffer" + // a new ArrayBuffer object, created in the relevant Realm of the + // WebSocket object, whose contents are data + dataForEvent = toArrayBuffer(data) + } + } + + // 3. Fire an event named message at the WebSocket object, using MessageEvent, + // with the origin attribute initialized to the serialization of the WebSocket + // object’s url's origin, and the data attribute initialized to dataForEvent. + fireEvent('message', this, createFastMessageEvent, { + origin: this.#url.origin, + data: dataForEvent + }) + } + + #onParserDrain () { + this.#handler.socket.resume() + } + + /** + * @see https://websockets.spec.whatwg.org/#feedback-from-the-protocol + * @see https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.4 + */ + #onSocketClose () { + // If the TCP connection was closed after the + // WebSocket closing handshake was completed, the WebSocket connection + // is said to have been closed _cleanly_. + const wasClean = + this.#handler.closeState.has(sentCloseFrameState.SENT) && + this.#handler.closeState.has(sentCloseFrameState.RECEIVED) + + let code = 1005 + let reason = '' + + const result = this.#parser.closingInfo + + if (result && !result.error) { + code = result.code ?? 1005 + reason = result.reason + } else if (!this.#handler.closeState.has(sentCloseFrameState.RECEIVED)) { + // If _The WebSocket + // Connection is Closed_ and no Close control frame was received by the + // endpoint (such as could occur if the underlying transport connection + // is lost), _The WebSocket Connection Close Code_ is considered to be + // 1006. + code = 1006 + } + + // 1. Change the ready state to CLOSED (3). + this.#handler.readyState = states.CLOSED + + // 2. If the user agent was required to fail the WebSocket + // connection, or if the WebSocket connection was closed + // after being flagged as full, fire an event named error + // at the WebSocket object. + // TODO + + // 3. Fire an event named close at the WebSocket object, + // using CloseEvent, with the wasClean attribute + // initialized to true if the connection closed cleanly + // and false otherwise, the code attribute initialized to + // the WebSocket connection close code, and the reason + // attribute initialized to the result of applying UTF-8 + // decode without BOM to the WebSocket connection close + // reason. + // TODO: process.nextTick + fireEvent('close', this, (type, init) => new CloseEvent(type, init), { + wasClean, code, reason + }) + + if (channels.close.hasSubscribers) { + channels.close.publish({ + websocket: this, + code, + reason + }) + } + } +} + +// https://websockets.spec.whatwg.org/#dom-websocket-connecting +WebSocket.CONNECTING = WebSocket.prototype.CONNECTING = states.CONNECTING +// https://websockets.spec.whatwg.org/#dom-websocket-open +WebSocket.OPEN = WebSocket.prototype.OPEN = states.OPEN +// https://websockets.spec.whatwg.org/#dom-websocket-closing +WebSocket.CLOSING = WebSocket.prototype.CLOSING = states.CLOSING +// https://websockets.spec.whatwg.org/#dom-websocket-closed +WebSocket.CLOSED = WebSocket.prototype.CLOSED = states.CLOSED + +Object.defineProperties(WebSocket.prototype, { + CONNECTING: staticPropertyDescriptors, + OPEN: staticPropertyDescriptors, + CLOSING: staticPropertyDescriptors, + CLOSED: staticPropertyDescriptors, + url: kEnumerableProperty, + readyState: kEnumerableProperty, + bufferedAmount: kEnumerableProperty, + onopen: kEnumerableProperty, + onerror: kEnumerableProperty, + onclose: kEnumerableProperty, + close: kEnumerableProperty, + onmessage: kEnumerableProperty, + binaryType: kEnumerableProperty, + send: kEnumerableProperty, + extensions: kEnumerableProperty, + protocol: kEnumerableProperty, + [Symbol.toStringTag]: { + value: 'WebSocket', + writable: false, + enumerable: false, + configurable: true + } +}) + +Object.defineProperties(WebSocket, { + CONNECTING: staticPropertyDescriptors, + OPEN: staticPropertyDescriptors, + CLOSING: staticPropertyDescriptors, + CLOSED: staticPropertyDescriptors +}) + +webidl.converters['sequence'] = webidl.sequenceConverter( + webidl.converters.DOMString +) + +webidl.converters['DOMString or sequence'] = function (V, prefix, argument) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT && Symbol.iterator in V) { + return webidl.converters['sequence'](V) + } + + return webidl.converters.DOMString(V, prefix, argument) +} + +// This implements the proposal made in https://github.com/whatwg/websockets/issues/42 +webidl.converters.WebSocketInit = webidl.dictionaryConverter([ + { + key: 'protocols', + converter: webidl.converters['DOMString or sequence'], + defaultValue: () => new Array(0) + }, + { + key: 'dispatcher', + converter: webidl.converters.any, + defaultValue: () => getGlobalDispatcher() + }, + { + key: 'headers', + converter: webidl.nullableConverter(webidl.converters.HeadersInit) + } +]) + +webidl.converters['DOMString or sequence or WebSocketInit'] = function (V) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT && !(Symbol.iterator in V)) { + return webidl.converters.WebSocketInit(V) + } + + return { protocols: webidl.converters['DOMString or sequence'](V) } +} + +webidl.converters.WebSocketSendData = function (V) { + if (webidl.util.Type(V) === webidl.util.Types.OBJECT) { + if (webidl.is.Blob(V)) { + return V + } + + if (ArrayBuffer.isView(V) || types.isArrayBuffer(V)) { + return V + } + } + + return webidl.converters.USVString(V) +} + +module.exports = { + WebSocket +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e90a4db --- /dev/null +++ b/package.json @@ -0,0 +1,149 @@ +{ + "name": "undici", + "version": "7.3.0", + "description": "An HTTP/1.1 client, written from scratch for Node.js", + "homepage": "https://undici.nodejs.org", + "bugs": { + "url": "https://github.com/nodejs/undici/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/undici.git" + }, + "license": "MIT", + "contributors": [ + { + "name": "Daniele Belardi", + "url": "https://github.com/dnlup", + "author": true + }, + { + "name": "Ethan Arrowood", + "url": "https://github.com/ethan-arrowood", + "author": true + }, + { + "name": "Matteo Collina", + "url": "https://github.com/mcollina", + "author": true + }, + { + "name": "Matthew Aitken", + "url": "https://github.com/KhafraDev", + "author": true + }, + { + "name": "Robert Nagy", + "url": "https://github.com/ronag", + "author": true + }, + { + "name": "Szymon Marczak", + "url": "https://github.com/szmarczak", + "author": true + }, + { + "name": "Tomas Della Vedova", + "url": "https://github.com/delvedor", + "author": true + } + ], + "keywords": [ + "fetch", + "http", + "https", + "promise", + "request", + "curl", + "wget", + "xhr", + "whatwg" + ], + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "build:node": "esbuild index-fetch.js --bundle --platform=node --outfile=undici-fetch.js --define:esbuildDetection=1 --keep-names && node scripts/strip-comments.js", + "build:wasm": "node build/wasm.js --docker", + "generate-pem": "node scripts/generate-pem.js", + "lint": "eslint --cache", + "lint:fix": "eslint --fix --cache", + "test": "npm run test:javascript && cross-env NODE_V8_COVERAGE= npm run test:typescript", + "test:javascript": "npm run test:javascript:no-jest && npm run test:jest", + "test:javascript:no-jest": "npm run generate-pem && npm run test:unit && npm run test:node-fetch && npm run test:cache && npm run test:cache-interceptor && npm run test:interceptors && npm run test:fetch && npm run test:cookies && npm run test:eventsource && npm run test:wpt && npm run test:websocket && npm run test:node-test && npm run test:cache-tests", + "test:javascript:without-intl": "npm run test:javascript:no-jest", + "test:busboy": "borp -p \"test/busboy/*.js\"", + "test:cache": "borp -p \"test/cache/*.js\"", + "test:sqlite": "NODE_OPTIONS=--experimental-sqlite borp -p \"test/cache-interceptor/*.js\"", + "test:cache-interceptor": "borp -p \"test/cache-interceptor/*.js\"", + "test:cookies": "borp -p \"test/cookie/*.js\"", + "test:eventsource": "npm run build:node && borp --expose-gc -p \"test/eventsource/*.js\"", + "test:fuzzing": "node test/fuzzing/fuzzing.test.js", + "test:fetch": "npm run build:node && borp --timeout 180000 --expose-gc --concurrency 1 -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy", + "test:h2": "npm run test:h2:core && npm run test:h2:fetch", + "test:h2:core": "borp -p \"test/http2*.js\"", + "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"", + "test:interceptors": "borp -p \"test/interceptors/*.js\"", + "test:jest": "cross-env NODE_V8_COVERAGE= jest", + "test:unit": "borp --expose-gc -p \"test/*.js\"", + "test:node-fetch": "borp -p \"test/node-fetch/**/*.js\"", + "test:node-test": "borp -p \"test/node-test/**/*.js\"", + "test:tdd": "borp --expose-gc -p \"test/*.js\"", + "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w", + "test:typescript": "tsd && tsc test/imports/undici-import.ts --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types", + "test:webidl": "borp -p \"test/webidl/*.js\"", + "test:websocket": "borp -p \"test/websocket/*.js\"", + "test:websocket:autobahn": "node test/autobahn/client.js", + "test:websocket:autobahn:report": "node test/autobahn/report.js", + "test:wpt": "node test/wpt/start-fetch.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-websockets.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs", + "test:wpt:withoutintl": "node test/wpt/start-fetch.mjs && node test/wpt/start-mimesniff.mjs && node test/wpt/start-xhr.mjs && node test/wpt/start-cacheStorage.mjs && node test/wpt/start-eventsource.mjs", + "test:cache-tests": "node test/cache-interceptor/cache-tests.mjs --ci", + "coverage": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report", + "coverage:ci": "npm run coverage:clean && cross-env NODE_V8_COVERAGE=./coverage/tmp npm run test:javascript && npm run coverage:report:ci", + "coverage:clean": "node ./scripts/clean-coverage.js", + "coverage:report": "cross-env NODE_V8_COVERAGE= c8 report", + "coverage:report:ci": "c8 report", + "bench": "echo \"Error: Benchmarks have been moved to '/benchmarks'\" && exit 1", + "serve:website": "echo \"Error: Documentation has been moved to '/docs'\" && exit 1", + "prepare": "husky && node ./scripts/platform-shell.js" + }, + "devDependencies": { + "@fastify/busboy": "3.1.1", + "@matteo.collina/tspl": "^0.1.1", + "@sinonjs/fake-timers": "^12.0.0", + "@types/node": "^18.19.50", + "abort-controller": "^3.0.0", + "borp": "^0.19.0", + "c8": "^10.0.0", + "cross-env": "^7.0.3", + "dns-packet": "^5.4.0", + "esbuild": "^0.24.0", + "eslint": "^9.9.0", + "fast-check": "^3.17.1", + "https-pem": "^3.0.0", + "husky": "^9.0.7", + "jest": "^29.0.2", + "neostandard": "^0.12.0", + "node-forge": "^1.3.1", + "proxy": "^2.1.1", + "tsd": "^0.31.2", + "typescript": "^5.6.2", + "ws": "^8.11.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "tsd": { + "directory": "test/types", + "compilerOptions": { + "esModuleInterop": true, + "lib": [ + "esnext" + ] + } + }, + "jest": { + "testMatch": [ + "/test/jest/**" + ] + } +} diff --git a/scripts/clean-coverage.js b/scripts/clean-coverage.js new file mode 100644 index 0000000..0be1687 --- /dev/null +++ b/scripts/clean-coverage.js @@ -0,0 +1,15 @@ +'use strict' + +const { rmSync } = require('node:fs') +const { resolve } = require('node:path') + +if (process.env.NODE_V8_COVERAGE) { + if (process.env.NODE_V8_COVERAGE.endsWith('/tmp')) { + rmSync(resolve(__dirname, process.env.NODE_V8_COVERAGE, '..'), { recursive: true, force: true }) + } else { + rmSync(resolve(__dirname, process.env.NODE_V8_COVERAGE), { recursive: true, force: true }) + } +} else { + console.log(resolve(__dirname, 'coverage')) + rmSync(resolve(__dirname, '../coverage'), { recursive: true, force: true }) +} diff --git a/scripts/generate-pem.js b/scripts/generate-pem.js new file mode 100644 index 0000000..88ac5c8 --- /dev/null +++ b/scripts/generate-pem.js @@ -0,0 +1,4 @@ +'use strict' +/* istanbul ignore file */ + +require('https-pem/install') diff --git a/scripts/generate-undici-types-package-json.js b/scripts/generate-undici-types-package-json.js new file mode 100644 index 0000000..ea2dd07 --- /dev/null +++ b/scripts/generate-undici-types-package-json.js @@ -0,0 +1,30 @@ +'use strict' + +const fs = require('node:fs') +const path = require('node:path') + +const packageJSONPath = path.join(__dirname, '..', 'package.json') +const packageJSONRaw = fs.readFileSync(packageJSONPath, 'utf-8') +const packageJSON = JSON.parse(packageJSONRaw) + +const licensePath = path.join(__dirname, '..', 'LICENSE') +const licenseRaw = fs.readFileSync(licensePath, 'utf-8') + +const packageTypesJSON = { + name: 'undici-types', + version: packageJSON.version, + description: 'A stand-alone types package for Undici', + homepage: packageJSON.homepage, + bugs: packageJSON.bugs, + repository: packageJSON.repository, + license: packageJSON.license, + types: 'index.d.ts', + files: ['*.d.ts'], + contributors: packageJSON.contributors +} + +const packageTypesPath = path.join(__dirname, '..', 'types', 'package.json') +const licenseTypesPath = path.join(__dirname, '..', 'types', 'LICENSE') + +fs.writeFileSync(packageTypesPath, JSON.stringify(packageTypesJSON, null, 2)) +fs.writeFileSync(licenseTypesPath, licenseRaw) diff --git a/scripts/platform-shell.js b/scripts/platform-shell.js new file mode 100644 index 0000000..093ce53 --- /dev/null +++ b/scripts/platform-shell.js @@ -0,0 +1,12 @@ +'use strict' + +const { platform } = require('node:os') +const { writeFileSync } = require('node:fs') +const { resolve } = require('node:path') + +if (platform() === 'win32') { + writeFileSync( + resolve(__dirname, '.npmrc'), + 'script-shell = "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"\n' + ) +} diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 0000000..dd3e86e --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,73 @@ +'use strict' + +// Called from .github/workflows + +const generateReleaseNotes = async ({ github, owner, repo, versionTag, defaultBranch }) => { + const { data: releases } = await github.rest.repos.listReleases({ + owner, + repo + }) + + const previousRelease = releases.find((r) => r.tag_name.startsWith('v7')) + + const { data: { body } } = await github.rest.repos.generateReleaseNotes({ + owner, + repo, + tag_name: versionTag, + target_commitish: defaultBranch, + previous_tag_name: previousRelease?.tag_name + }) + + const bodyWithoutReleasePr = body.split('\n') + .filter((line) => !line.includes('[Release] v')) + .join('\n') + + return bodyWithoutReleasePr +} + +const generatePr = async ({ github, context, defaultBranch, versionTag }) => { + const { owner, repo } = context.repo + const releaseNotes = await generateReleaseNotes({ github, owner, repo, versionTag, defaultBranch }) + + await github.rest.pulls.create({ + owner, + repo, + head: `release/${versionTag}`, + base: defaultBranch, + title: `[Release] ${versionTag}`, + body: releaseNotes + }) +} + +const release = async ({ github, context, defaultBranch, versionTag }) => { + const { owner, repo } = context.repo + const releaseNotes = await generateReleaseNotes({ github, owner, repo, versionTag, defaultBranch }) + + await github.rest.repos.createRelease({ + owner, + repo, + tag_name: versionTag, + target_commitish: defaultBranch, + name: versionTag, + body: releaseNotes, + draft: false, + prerelease: false, + generate_release_notes: false + }) + + try { + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/release/${versionTag}` + }) + } catch (err) { + console.log("Couldn't delete release PR ref") + console.log(err) + } +} + +module.exports = { + generatePr, + release +} diff --git a/scripts/strip-comments.js b/scripts/strip-comments.js new file mode 100644 index 0000000..d687a26 --- /dev/null +++ b/scripts/strip-comments.js @@ -0,0 +1,10 @@ +'use strict' + +const { readFileSync, writeFileSync } = require('node:fs') +const { transcode } = require('node:buffer') + +const buffer = transcode + ? transcode(readFileSync('./undici-fetch.js'), 'utf8', 'latin1') + : readFileSync('./undici-fetch.js') + +writeFileSync('./undici-fetch.js', buffer.toString('latin1')) diff --git a/scripts/verifyVersion.js b/scripts/verifyVersion.js new file mode 100644 index 0000000..dbaafa2 --- /dev/null +++ b/scripts/verifyVersion.js @@ -0,0 +1,17 @@ +'use strict' + +/* istanbul ignore file */ + +const [major, minor, patch] = process.versions.node.split('.').map(v => Number(v)) +const required = process.argv.pop().split('.').map(v => Number(v)) + +const badMajor = major < required[0] +const badMinor = major === required[0] && minor < required[1] +const badPatch = major === required[0] && minor === required[1] && patch < required[2] + +if (badMajor || badMinor || badPatch) { + console.log(`Required Node.js >=${required.join('.')}, got ${process.versions.node}`) + console.log('Skipping') +} else { + process.exit(1) +} diff --git a/test/autobahn/.gitignore b/test/autobahn/.gitignore new file mode 100644 index 0000000..f073a2d --- /dev/null +++ b/test/autobahn/.gitignore @@ -0,0 +1 @@ +reports/clients diff --git a/test/autobahn/client.js b/test/autobahn/client.js new file mode 100644 index 0000000..3f01392 --- /dev/null +++ b/test/autobahn/client.js @@ -0,0 +1,52 @@ +'use strict' + +const { WebSocket } = require('../..') + +const logOnError = process.env.LOG_ON_ERROR === 'true' + +let currentTest = 1 +let testCount + +const autobahnFuzzingserverUrl = process.env.FUZZING_SERVER_URL || 'ws://localhost:9001' + +function nextTest () { + let ws + + if (currentTest > testCount) { + ws = new WebSocket(`${autobahnFuzzingserverUrl}/updateReports?agent=undici`) + ws.addEventListener('close', () => require('./report')) + return + } + + console.log(`Running test case ${currentTest}/${testCount}`) + + ws = new WebSocket( + `${autobahnFuzzingserverUrl}/runCase?case=${currentTest}&agent=undici` + ) + ws.addEventListener('message', (data) => { + ws.send(data.data) + }) + ws.addEventListener('close', () => { + currentTest++ + process.nextTick(nextTest) + }) + if (logOnError) { + ws.addEventListener('error', (e) => { + console.error(e.error) + }) + } +} + +const ws = new WebSocket(`${autobahnFuzzingserverUrl}/getCaseCount`) +ws.addEventListener('message', (data) => { + testCount = parseInt(data.data) +}) +ws.addEventListener('close', () => { + if (testCount > 0) { + nextTest() + } +}) +ws.addEventListener('error', (e) => { + console.error(e.error) + process.exit(1) +}) diff --git a/test/autobahn/config/fuzzingserver.json b/test/autobahn/config/fuzzingserver.json new file mode 100644 index 0000000..e21b420 --- /dev/null +++ b/test/autobahn/config/fuzzingserver.json @@ -0,0 +1,7 @@ +{ + "url": "ws://127.0.0.1:9001", + "outdir": "./reports/clients", + "cases": ["*"], + "exclude-cases": [], + "exclude-agent-cases": {} +} diff --git a/test/autobahn/report.js b/test/autobahn/report.js new file mode 100644 index 0000000..7dcae5f --- /dev/null +++ b/test/autobahn/report.js @@ -0,0 +1,106 @@ +'use strict' + +const result = require('./reports/clients/index.json').undici + +const failOnError = process.env.FAIL_ON_ERROR === 'true' +let runFailed = false + +let okTests = 0 +let failedTests = 0 +let nonStrictTests = 0 +let wrongCodeTests = 0 +let uncleanTests = 0 +let failedByClientTests = 0 +let informationalTests = 0 +let unimplementedTests = 0 + +let totalTests = 0 + +function testCaseIdToWeight (testCaseId) { + const [major, minor, sub] = testCaseId.split('.') + return sub + ? parseInt(major, 10) * 10000 + parseInt(minor, 10) * 100 + parseInt(sub, 10) + : parseInt(major, 10) * 10000 + parseInt(minor, 10) * 100 +} + +function isFailedTestCase (testCase) { + return ( + testCase.behavior === 'FAILED' || + testCase.behavior === 'WRONG CODE' || + testCase.behavior === 'UNCLEAN' || + testCase.behavior === 'FAILED BY CLIENT' || + testCase.behaviorClose === 'FAILED' || + testCase.behaviorClose === 'WRONG CODE' || + testCase.behaviorClose === 'UNCLEAN' || + testCase.behaviorClose === 'FAILED BY CLIENT' + ) +} + +const keys = Object.keys(result).sort((a, b) => { + a = testCaseIdToWeight(a) + b = testCaseIdToWeight(b) + return a - b +}) + +const reorderedResult = {} +for (const key of keys) { + reorderedResult[key] = result[key] + delete reorderedResult[key].reportfile + + totalTests++ + + if ( + failOnError && + !runFailed && + isFailedTestCase(result[key]) + ) { + runFailed = true + } + + switch (result[key].behavior) { + case 'OK': + okTests++ + break + case 'FAILED': + failedTests++ + break + case 'NON-STRICT': + nonStrictTests++ + break + case 'WRONG CODE': + wrongCodeTests++ + break + case 'UNCLEAN': + uncleanTests++ + break + case 'FAILED BY CLIENT': + failedByClientTests++ + break + case 'INFORMATIONAL': + informationalTests++ + break + case 'UNIMPLEMENTED': + unimplementedTests++ + break + } +} + +console.log('Autobahn Test Report\n\nSummary:') + +console.table({ + OK: okTests, + Failed: failedTests, + 'Non-Strict': nonStrictTests, + 'Wrong Code': wrongCodeTests, + Unclean: uncleanTests, + 'Failed By Client': failedByClientTests, + Informational: informationalTests, + Unimplemented: unimplementedTests, + Total: totalTests +}) + +console.log('Details:') + +console.table(reorderedResult) + +process.exit(runFailed ? 1 : 0) diff --git a/test/autobahn/run.sh b/test/autobahn/run.sh new file mode 100755 index 0000000..907ff71 --- /dev/null +++ b/test/autobahn/run.sh @@ -0,0 +1,6 @@ +docker run -it --rm \ + -v "${PWD}/config:/config" \ + -v "${PWD}/reports:/reports" \ + -p 9001:9001 \ + --name fuzzingserver \ + crossbario/autobahn-testsuite diff --git a/test/busboy/LICENSE b/test/busboy/LICENSE new file mode 100644 index 0000000..da2ac4a --- /dev/null +++ b/test/busboy/LICENSE @@ -0,0 +1,19 @@ +Copyright Brian White. All rights reserved. + +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/test/busboy/issue-3676.js b/test/busboy/issue-3676.js new file mode 100644 index 0000000..4b74af8 --- /dev/null +++ b/test/busboy/issue-3676.js @@ -0,0 +1,24 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { Response } = require('../..') + +// https://github.com/nodejs/undici/issues/3676 +test('Leading and trailing CRLFs are ignored', async (t) => { + const response = new Response([ + '--axios-1.7.7-boundary-bPgZ9x77LfApGVUN839vui4V7\r\n' + + 'Content-Disposition: form-data; name="file"; filename="doc.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + 'Helloworld\r\n' + + '--axios-1.7.7-boundary-bPgZ9x77LfApGVUN839vui4V7--\r\n' + + '\r\n' + ].join(''), { + headers: { + 'content-type': 'multipart/form-data; boundary=axios-1.7.7-boundary-bPgZ9x77LfApGVUN839vui4V7' + } + }) + + await assert.doesNotReject(response.formData()) +}) diff --git a/test/busboy/issue-3760.js b/test/busboy/issue-3760.js new file mode 100644 index 0000000..d37852c --- /dev/null +++ b/test/busboy/issue-3760.js @@ -0,0 +1,59 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { Response } = require('../..') + +// https://github.com/nodejs/undici/issues/3760 +test('filename* parameter is parsed properly', async (t) => { + const response = new Response([ + '--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' + + 'Content-Type: text/plain\r\n' + + 'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'%e2%82%ac%20rates\r\n' + + '\r\n' + + 'testabc\r\n' + + '--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' + + '\r\n' + ].join(''), { + headers: { + 'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"' + } + }) + + const fd = await response.formData() + assert.deepEqual(fd.get('file').name, '€ rates') +}) + +test('whitespace after filename[*]= is ignored', async () => { + for (const response of [ + new Response([ + '--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' + + 'Content-Type: text/plain\r\n' + + 'Content-Disposition: form-data; name="file"; filename*= utf-8\'\'hello\r\n' + + '\r\n' + + 'testabc\r\n' + + '--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' + + '\r\n' + ].join(''), { + headers: { + 'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"' + } + }), + new Response([ + '--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' + + 'Content-Type: text/plain\r\n' + + 'Content-Disposition: form-data; name="file"; filename= "hello"\r\n' + + '\r\n' + + 'testabc\r\n' + + '--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' + + '\r\n' + ].join(''), { + headers: { + 'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"' + } + }) + ]) { + const fd = await response.formData() + assert.deepEqual(fd.get('file').name, 'hello') + } +}) diff --git a/test/busboy/test-types-multipart-charsets.js b/test/busboy/test-types-multipart-charsets.js new file mode 100644 index 0000000..b162024 --- /dev/null +++ b/test/busboy/test-types-multipart-charsets.js @@ -0,0 +1,73 @@ +'use strict' + +const assert = require('node:assert') +const { inspect } = require('node:util') +const { test } = require('node:test') +const { Response } = require('../..') + +const input = Buffer.from([ + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="テスト.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' +].join('\r\n')) +const boundary = '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k' +const expected = [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('A'.repeat(1023)), + info: { + filename: 'テスト.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } +] + +test('unicode filename', async (t) => { + const response = new Response(input, { + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } + }) + + const fd = await response.formData() + const results = [] + + for (const [name, value] of fd) { + if (typeof value === 'string') { // field + results.push({ + type: 'field', + name, + val: value, + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }) + } else { // File + results.push({ + type: 'file', + name, + data: Buffer.from(await value.arrayBuffer()), + info: { + filename: value.name, + encoding: '7bit', + mimeType: value.type + } + }) + } + } + + assert.deepStrictEqual( + results, + expected, + 'Results mismatch.\n' + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(expected)}` + ) +}) diff --git a/test/busboy/test-types-multipart.js b/test/busboy/test-types-multipart.js new file mode 100644 index 0000000..73960f2 --- /dev/null +++ b/test/busboy/test-types-multipart.js @@ -0,0 +1,629 @@ +'use strict' + +const assert = require('node:assert') +const { inspect } = require('node:util') +const { describe } = require('node:test') +const { Response } = require('../..') + +const active = new Map() + +const tests = [ + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_0"', + '', + 'super alpha file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; name="file_name_1"', + '', + 'super beta file', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'A'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'B'.repeat(1023), + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'field', + name: 'file_name_0', + val: 'super alpha file', + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'file_name_1', + val: 'super beta file', + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('A'.repeat(1023)), + info: { + filename: '1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('B'.repeat(1023)), + info: { + filename: '1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Fields and files' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + '', + 'some random content', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="pass"', + '', + 'some random pass', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="bit"', + '', + '2', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: 'some random content', + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'pass', + val: 'some random pass', + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }, + { + type: 'field', + name: 'bit', + val: '2', + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Fields only' + }, + { + source: [ + '' + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'No fields and no files' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="/tmp/1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="C:\\files\\1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_2"; filename="relative/1k_c.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '/tmp/1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'C:\\files\\1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'relative/1k_c.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Files with filenames containing paths preserve path' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="/absolute/1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="C:\\absolute\\1k_b.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_2"; filename="relative/1k_c.dat"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: '/absolute/1k_a.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'C:\\absolute\\1k_b.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'relative/1k_c.dat', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Paths to be preserved' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + 'Content-Type: ', + '', + 'some random content', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: 'some random content', + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + } + ], + what: 'Empty content-type defaults to text/plain' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="file"; filename*="utf-8\'\'n%C3%A4me.txt"', + 'Content-Type: application/octet-stream', + '', + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'file', + data: Buffer.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + info: { + filename: 'utf-8\'\'n%C3%A4me.txt', + encoding: '7bit', + mimeType: 'application/octet-stream' + } + } + ], + what: 'Unicode filenames' + }, + { + source: [ + ['--asdasdasdasd\r\n', + 'Content-Type: text/plain\r\n', + 'Content-Disposition: form-data; name="foo"\r\n', + '\r\n', + 'asd\r\n', + '--asdasdasdasd--' + ].join(':)') + ], + boundary: 'asdasdasdasd', + expected: [ + { error: 'Malformed part header' } + ], + what: 'Stopped mid-header' + }, + { + source: [ + ['------WebKitFormBoundaryTB2MiQ36fnSJlrhY', + 'Content-Disposition: form-data; name="cont"', + 'Content-Type: application/json', + '', + '{}', + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ].join('\r\n') + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [ + { + type: 'field', + name: 'cont', + val: '{}', + info: { + encoding: '7bit', + // TODO: there's no way to get the content-type of a field + mimeType: 'text/plain' // 'application/json' + } + } + ], + what: 'content-type for fields' + }, + { + source: [ + '------WebKitFormBoundaryTB2MiQ36fnSJlrhY--' + ], + boundary: '----WebKitFormBoundaryTB2MiQ36fnSJlrhY', + expected: [], + what: 'empty form' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name=upload_file_0; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + 'Content-Transfer-Encoding: binary', + '', + '' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-file #1' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name=upload_file_0; filename="1k_a.dat"', + 'Content-Type: application/octet-stream', + '', + 'a' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { error: 'Unexpected end of form' } + ], + what: 'Stopped mid-file #2' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'a', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + } + ], + what: 'Text file with charset' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_0"; filename="${'a'.repeat(64 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_1"; filename="notes2.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + // TODO: the RFC does not mention the max size of a filename? + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ab'), + info: { + filename: `${'a'.repeat(64 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + }, // { error: 'Malformed part header' }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('cd'), + info: { + filename: 'notes2.txt', + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + } + ], + what: 'Oversized part header' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + 'name="upload_file_0"; filename="notes.txt"', + 'Content-Type: text/plain; charset=utf8', + '', + 'a'.repeat(31) + '\r' + ].join('\r\n'), + 'b'.repeat(40), + '\r\n-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('a'.repeat(31) + '\r' + 'b'.repeat(40)), + info: { + filename: 'notes.txt', + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + } + ], + what: 'Lookbehind data should not stall file streams' + }, + { + source: [ + ['-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_0"; filename="${'a'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ab', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_1"; filename="${'b'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'cd', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + 'Content-Disposition: form-data; ' + + `name="upload_file_2"; filename="${'c'.repeat(8 * 1024)}.txt"`, + 'Content-Type: text/plain; charset=utf8', + '', + 'ef', + '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--' + ].join('\r\n') + ], + boundary: '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k', + expected: [ + { + type: 'file', + name: 'upload_file_0', + data: Buffer.from('ab'), + info: { + filename: `${'a'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + }, + { + type: 'file', + name: 'upload_file_1', + data: Buffer.from('cd'), + info: { + filename: `${'b'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + }, + { + type: 'file', + name: 'upload_file_2', + data: Buffer.from('ef'), + info: { + filename: `${'c'.repeat(8 * 1024)}.txt`, + encoding: '7bit', + mimeType: 'text/plain; charset=utf8' + } + } + ], + what: 'Large filename' + }, + { + source: [ + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee\r\n', + 'Content-Type: application/gzip\r\n' + + 'Content-Encoding: gzip\r\n' + + 'Content-Disposition: form-data; name="batch-1"; filename="batch-1"' + + '\r\n\r\n' + + '\r\n--d1bf46b3-aa33-4061-b28d-6c5ced8b08ee--' + ], + boundary: 'd1bf46b3-aa33-4061-b28d-6c5ced8b08ee', + expected: [ + { + type: 'file', + name: 'batch-1', + data: Buffer.alloc(0), + info: { + filename: 'batch-1', + encoding: '7bit', + mimeType: 'application/gzip' + } + } + ], + what: 'Empty part' + } +] + +describe('FormData parsing tests', async (t) => { + for (const test of tests) { + active.set(test, 1) + + const { what, boundary, source } = test + + const body = source.reduce((a, b) => a + b, '') + const response = new Response(body, { + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}` + } + }) + + let fd + const results = [] + + try { + fd = await response.formData() + } catch (e) { + results.push({ error: e.message }) + + if (test.expected.length === 1 && test.expected[0].error) { + active.delete(test) + } + + continue + } + + for (const [name, value] of fd) { + if (typeof value === 'string') { // field + results.push({ + type: 'field', + name, + val: value, + info: { + encoding: '7bit', + mimeType: 'text/plain' + } + }) + } else { // File + results.push({ + type: 'file', + name, + data: Buffer.from(await value.arrayBuffer()), + info: { + filename: value.name, + encoding: '7bit', + mimeType: value.type + } + }) + } + } + + active.delete(test) + + assert.deepStrictEqual( + results, + test.expected, + `[${what}] Results mismatch.\n` + + `Parsed: ${inspect(results)}\n` + + `Expected: ${inspect(test.expected)}` + ) + } +}) diff --git a/test/cache-interceptor/cache-store-test-utils.js b/test/cache-interceptor/cache-store-test-utils.js new file mode 100644 index 0000000..c956902 --- /dev/null +++ b/test/cache-interceptor/cache-store-test-utils.js @@ -0,0 +1,354 @@ +'use strict' + +const { equal, notEqual, deepStrictEqual } = require('node:assert') +const { describe, test, after } = require('node:test') +const { Readable } = require('node:stream') +const { once } = require('node:events') +const FakeTimers = require('@sinonjs/fake-timers') + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore + * + * @param {{ new(...any): CacheStore }} CacheStore + */ +function cacheStoreTests (CacheStore) { + describe(CacheStore.prototype.constructor.name, () => { + test('matches interface', () => { + equal(typeof CacheStore.prototype.get, 'function') + equal(typeof CacheStore.prototype.createWriteStream, 'function') + equal(typeof CacheStore.prototype.delete, 'function') + }) + + test('caches request', async () => { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cacheControlDirectives: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + const body = [Buffer.from('asd'), Buffer.from('123')] + + const store = new CacheStore() + + // Sanity check + equal(await store.get(key), undefined) + + // Write response to store + { + const writable = store.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } + + // Now let's try fetching the response from the store + { + const result = await store.get(structuredClone(key)) + notEqual(result, undefined) + await compareGetResults(result, value, body) + } + + /** + * Let's try out a request to a different resource to make sure it can + * differentiate between the two + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const anotherKey = { + origin: 'localhost', + path: '/asd', + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const anotherValue = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cacheControlDirectives: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + const anotherBody = [Buffer.from('asd'), Buffer.from('123')] + + equal(store.get(anotherKey), undefined) + + { + const writable = store.createWriteStream(anotherKey, anotherValue) + notEqual(writable, undefined) + writeBody(writable, anotherBody) + } + + { + const result = await store.get(structuredClone(anotherKey)) + notEqual(result, undefined) + await compareGetResults(result, anotherValue, anotherBody) + } + }) + + test('returns stale response before deleteAt', async () => { + const clock = FakeTimers.install({ + shouldClearNativeTimers: true + }) + + after(() => clock.uninstall()) + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cacheControlDirectives: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + // deleteAt is different because stale-while-revalidate, stale-if-error, ... + deleteAt: Date.now() + 5000 + } + + const body = [Buffer.from('asd'), Buffer.from('123')] + + const store = new CacheStore() + + // Sanity check + equal(store.get(key), undefined) + + { + const writable = store.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } + + clock.tick(1500) + + { + const result = await store.get(structuredClone(key)) + notEqual(result, undefined) + await compareGetResults(result, value, body) + } + + clock.tick(6000) + + // Past deleteAt, shouldn't be returned + equal(await store.get(key), undefined) + }) + + test('vary directives used to decide which response to use', async () => { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: { + 'some-header': 'hello world' + } + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + vary: { + 'some-header': 'hello world' + }, + cacheControlDirectives: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 1000 + } + + const body = [Buffer.from('asd'), Buffer.from('123')] + + const store = new CacheStore() + + // Sanity check + equal(store.get(key), undefined) + + { + const writable = store.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } + + { + const result = await store.get(structuredClone(key)) + notEqual(result, undefined) + await compareGetResults(result, value, body) + } + + /** + * Let's make another key to the same resource but with a different vary + * header + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const anotherKey = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: { + 'some-header': 'hello world2' + } + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const anotherValue = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + vary: { + 'some-header': 'hello world2' + }, + cacheControlDirectives: {}, + cachedAt: Date.now(), + staleAt: Date.now() + 1000, + deleteAt: Date.now() + 1000 + } + + const anotherBody = [Buffer.from('asd'), Buffer.from('123')] + + equal(await store.get(anotherKey), undefined) + + { + const writable = store.createWriteStream(anotherKey, anotherValue) + notEqual(writable, undefined) + writeBody(writable, anotherBody) + } + + { + const result = await store.get(structuredClone(key)) + notEqual(result, undefined) + await compareGetResults(result, value, body) + } + + { + const result = await store.get(structuredClone(anotherKey)) + notEqual(result, undefined) + await compareGetResults(result, anotherValue, anotherBody) + } + }) + }) +} + +/** + * @param {import('node:stream').Writable} stream + * @param {Buffer[]} body + */ +function writeBody (stream, body) { + for (const chunk of body) { + stream.write(chunk) + } + + stream.end() + return stream +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} param0 + * @returns {Promise} + */ +async function readBody ({ body }) { + if (!body) { + return undefined + } + + if (typeof body === 'string') { + return [Buffer.from(body)] + } + + if (body.constructor.name === 'Buffer') { + return [body] + } + + const stream = Readable.from(body) + + /** + * @type {Buffer[]} + */ + const streamedBody = [] + + stream.on('data', chunk => { + streamedBody.push(Buffer.from(chunk)) + }) + + await once(stream, 'end') + + return streamedBody +} + +/** + * @param {Buffer[]} buffers + * @returns {Buffer} + */ +function joinBufferArray (buffers) { + const data = [] + + for (const buffer of buffers) { + buffer.forEach((chunk) => { + data.push(chunk) + }) + } + + return Buffer.from(data) +} + +/** + * @param {import('../../types/cache-interceptor.d.ts').default.GetResult} actual + * @param {import('../../types/cache-interceptor.d.ts').default.CacheValue} expected + * @param {Buffer[]} expectedBody +*/ +async function compareGetResults (actual, expected, expectedBody) { + const actualBody = await readBody(actual) + deepStrictEqual( + actualBody ? joinBufferArray(actualBody) : undefined, + joinBufferArray(expectedBody) + ) + + for (const key of Object.keys(expected)) { + deepStrictEqual(actual[key], expected[key]) + } +} + +module.exports = { + cacheStoreTests, + writeBody, + readBody, + compareGetResults +} diff --git a/test/cache-interceptor/cache-tests-worker.mjs b/test/cache-interceptor/cache-tests-worker.mjs new file mode 100644 index 0000000..285c33f --- /dev/null +++ b/test/cache-interceptor/cache-tests-worker.mjs @@ -0,0 +1,219 @@ +'use strict' + +import { styleText } from 'node:util' +import { exit } from 'node:process' +import { getResults, runTests as runTestSuite } from '../fixtures/cache-tests/test-engine/client/runner.mjs' +import { determineTestResult, testLookup } from '../fixtures/cache-tests/test-engine/lib/results.mjs' +import tests from '../fixtures/cache-tests/tests/index.mjs' +import { Agent, fetch, interceptors, setGlobalDispatcher } from '../../index.js' +import MemoryCacheStore from '../../lib/cache/memory-cache-store.js' + +if (!process.env.TEST_ENVIRONMENT) { + throw new Error('missing TEST_ENVIRONMENT') +} + +if (!process.env.BASE_URL) { + throw new Error('missing BASE_URL') +} + +/** + * @type {import('./cache-tests.mjs').TestEnvironment} + */ +const environment = JSON.parse(process.env.TEST_ENVIRONMENT) +if (environment.cacheStore) { + environment.opts.store = await makeCacheStore(environment.cacheStore) +} + +// Start the test server +await import('../fixtures/cache-tests/test-engine/server/server.mjs') + +// Output the testing setup +console.log('TEST ENVIRONMENT') +console.log(` BASE_URL: ${styleText('gray', process.env.BASE_URL)}`) +if (environment.opts.store) { + console.log(` store: ${styleText('gray', environment.opts.store?.constructor.name ?? 'undefined')}`) +} +if (environment.opts.methods) { + console.log(` methods: ${styleText('gray', JSON.stringify(environment.opts.methods) ?? 'undefined')}`) +} +if (environment.opts.cacheByDefault) { + console.log(` cacheByDefault: ${styleText('gray', `${environment.opts.cacheByDefault}`)}`) +} +if (environment.opts.type) { + console.log(` type: ${styleText('gray', environment.opts.type)}`) +} +if (environment.ignoredTests) { + console.log(` ignored tests: ${styleText('gray', JSON.stringify(environment.ignoredTests))}`) +} + +// Setup the client +const client = new Agent().compose(interceptors.cache(environment.opts)) +setGlobalDispatcher(client) + +// Run the suite +await runTestSuite(tests, fetch, true, process.env.BASE_URL) + +let exitCode = 0 + +// Print the results +const stats = printResults(environment, getResults()) +printStats(stats) + +exit(exitCode) + +/** + * @param {import('./cache-tests.mjs').TestEnvironment['cacheStore']} type + * @returns {Promise} + */ +async function makeCacheStore (type) { + const stores = { + MemoryCacheStore + } + + try { + await import('node:sqlite') + + const { default: SqliteCacheStore } = await import('../../lib/cache/sqlite-cache-store.js') + stores.SqliteCacheStore = SqliteCacheStore + } catch (err) { + // Do nothing + } + + const Store = stores[type] + if (!Store) { + throw new TypeError(`unknown cache store: ${type}`) + } + + return new Store() +} + +/** + * @param {import('./cache-tests.mjs').TestEnvironment} environment + * @param {any} results + * @returns {import('./cache-tests.mjs').TestStats} + */ +function printResults (environment, results) { + /** + * @type {import('./cache-tests.mjs').TestStats} + */ + const stats = { + total: Object.keys(results).length - (environment.ignoredTests?.length || 0), + skipped: 0, + passed: 0, + failed: 0, + optionalFailed: 0, + setupFailed: 0, + testHarnessFailed: 0, + dependencyFailed: 0, + retried: 0 + } + + for (const testId in results) { + if (environment.ignoredTests?.includes(testId)) { + continue + } + + const test = testLookup(tests, testId) + // eslint-disable-next-line no-unused-vars + const [code, _, icon] = determineTestResult(tests, testId, results, false) + + let status + let color + switch (code) { + case '-': + status = 'skipped' + color = 'gray' + stats.skipped++ + break + case '\uf058': + status = 'pass' + color = 'green' + stats.passed++ + break + case '\uf057': + status = 'failed' + color = 'red' + stats.failed++ + exitCode = 1 + break + case '\uf05a': + status = 'failed (optional)' + color = 'yellow' + stats.optionalFailed++ + break + case '\uf055': + status = 'yes' + color = 'green' + stats.passed++ + break + case '\uf056': + status = 'no' + color = 'yellow' + stats.optionalFailed++ + break + case '\uf059': + status = 'setup failure' + color = 'red' + stats.setupFailed++ + break + case '\uf06a': + status = 'test harness failure' + color = 'red' + stats.testHarnessFailed++ + break + case '\uf192': + status = 'dependency failure' + color = 'red' + stats.dependencyFailed++ + break + case '\uf01e': + status = 'retry' + color = 'yellow' + stats.retried++ + break + default: + status = 'unknown' + color = ['strikethrough', 'white'] + break + } + + if (process.env.CI && status !== 'failed') { + continue + } + + console.log(`${icon} ${styleText(color, `${status} - ${test.name}`)} (${styleText('gray', testId)})`) + if (results[testId] !== true) { + const [type, message] = results[testId] + console.log(` ${styleText(color, `${type}: ${message}`)}`) + } + } + + return stats +} + +/** + * @param {import('./cache-tests.mjs').TestStats} stats + */ +function printStats (stats) { + const { + total, + skipped, + passed, + failed, + optionalFailed, + setupFailed, + testHarnessFailed, + dependencyFailed, + retried + } = stats + + console.log(`\n Total tests: ${total}`) + console.log(` ${styleText('gray', 'Skipped')}: ${skipped} (${((skipped / total) * 100).toFixed(1)}%)`) + console.log(` ${styleText('green', 'Passed')}: ${passed} (${((passed / total) * 100).toFixed(1)}%)`) + console.log(` ${styleText('red', 'Failed')}: ${failed} (${((failed / total) * 100).toFixed(1)}%)`) + console.log(` ${styleText('yellow', 'Failed (optional)')}: ${optionalFailed} (${((optionalFailed / total) * 100).toFixed(1)}%)`) + console.log(` ${styleText('red', 'Setup failed')}: ${setupFailed} (${((setupFailed / total) * 100).toFixed(1)}%)`) + console.log(`${styleText('red', 'Test Harness Failed')}: ${testHarnessFailed} (${((testHarnessFailed / total) * 100).toFixed(1)}%)`) + console.log(` ${styleText('red', 'Dependency Failed')}: ${dependencyFailed} (${((dependencyFailed / total) * 100).toFixed(1)}%)`) + console.log(` ${styleText('yellow', 'Retried')}: ${retried} (${((retried / total) * 100).toFixed(1)}%)`) +} diff --git a/test/cache-interceptor/cache-tests.mjs b/test/cache-interceptor/cache-tests.mjs new file mode 100644 index 0000000..8844e0a --- /dev/null +++ b/test/cache-interceptor/cache-tests.mjs @@ -0,0 +1,273 @@ +'use strict' + +import { parseArgs, styleText } from 'node:util' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { exit } from 'node:process' +import { fork } from 'node:child_process' + +/** + * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheOptions} CacheOptions + * + * @typedef {{ + * opts: CacheOptions, + * ignoredTests?: string[], + * cacheStore?: 'MemoryCacheStore' | 'SqliteCacheStore' + * }} TestEnvironment + * + * @typedef {{ + * total: number, + * skipped: number, + * passed: number, + * failed: number, + * optionalFailed: number, + * setupFailed: number, + * testHarnessFailed: number, + * dependencyFailed: number, + * retried: number + * }} TestStats + */ + +const CLI_OPTIONS = parseArgs({ + options: { + // Cache type(s) to test + type: { + type: 'string', + multiple: true, + short: 't' + }, + // Cache store(s) to test + store: { + type: 'string', + multiple: true, + short: 's' + }, + // Only shows errors + ci: { + type: 'boolean' + } + } +}) + +/** + * @type {TestEnvironment} + */ +const BASE_TEST_ENVIRONMENT = { + opts: { methods: ['GET', 'HEAD'] }, + ignoredTests: [ + // Tests for invalid etags, goes against the spec + 'conditional-etag-forward-unquoted', + 'conditional-etag-strong-generate-unquoted', + + // Responses with no-cache can be reused if they're revalidated (which is + // what we're doing) + 'cc-resp-no-cache', + 'cc-resp-no-cache-case-insensitive', + + // We're not caching 304s currently + '304-etag-update-response-Cache-Control', + '304-etag-update-response-Content-Foo', + '304-etag-update-response-Test-Header', + '304-etag-update-response-X-Content-Foo', + '304-etag-update-response-X-Test-Header', + + // We just trim whatever's in the decimal place off (i.e. 7200.0 -> 7200) + 'age-parse-float', + + // Broken? + 'head-200-update', + 'head-200-retain', + 'head-410-update', + 'stale-close-must-revalidate', + 'stale-close-no-cache' + ] +} + +/** + * @type {TestEnvironment[]} + */ +const CACHE_TYPES = [ + { + opts: { type: 'shared' }, + ignoredTests: [ + 'freshness-max-age-s-maxage-private', + 'freshness-max-age-s-maxage-private-multiple' + ] + }, + { + opts: { type: 'private' } + } +] + +/** + * @type {TestEnvironment[]} + */ +const CACHE_STORES = [ + { opts: {}, cacheStore: 'MemoryCacheStore' } +] + +try { + await import('node:sqlite') + CACHE_STORES.push({ opts: {}, cacheStore: 'SqliteCacheStore' }) +} catch (err) { + console.warn('Skipping SqliteCacheStore, node:sqlite not present') +} + +const PROTOCOL = 'http' +const PORT = 8000 + +const testEnvironments = filterEnvironments( + buildTestEnvironments(0, [CACHE_TYPES, CACHE_STORES]) +) + +console.log(`Testing ${testEnvironments.length} environments\n`) +console.log(`PROTOCOL: ${styleText('gray', PROTOCOL)}`) +console.log('') + +/** + * @type {Array]>>} + */ +const results = [] + +// Run all the tests in child processes because the test runner is a bit finicky +for (let i = 0; i < testEnvironments.length; i++) { + const environment = testEnvironments[i] + const port = PORT + i + + const promise = new Promise((resolve) => { + const process = fork(join(import.meta.dirname, 'cache-tests-worker.mjs'), { + stdio: 'pipe', + env: { + TEST_ENVIRONMENT: JSON.stringify(environment), + BASE_URL: `${PROTOCOL}://localhost:${port}`, + CI: CLI_OPTIONS.values.ci ? 'true' : undefined, + npm_config_protocol: PROTOCOL, + npm_config_port: `${port}`, + npm_config_pidfile: join(tmpdir(), `http-cache-test-server-${i}.pid`) + } + }) + + const stdout = [] + process.stdout.on('data', chunk => { + stdout.push(chunk) + }) + + process.stderr.on('error', chunk => { + stdout.push(chunk) + }) + + process.on('close', code => { + resolve([code, stdout]) + }) + }) + + results.push(promise) +} + +// Status code so we can fail CI jobs if we need +let exitCode = 0 + +// Print the results of all the results in the order that they exist +for (const [code, stdout] of await Promise.all(results)) { + exitCode = code + + for (const line of stdout) { + process.stdout.write(line) + } + + console.log('') +} + +exit(exitCode) + +/** + * @param {number} idx + * @param {TestEnvironment[][]} testOptions + * @returns {TestEnvironment[]} + */ +function buildTestEnvironments (idx, testOptions) { + let baseEnvironments = testOptions[idx] + + if (idx === 0) { + // We're at the beginning + baseEnvironments = baseEnvironments.map( + environment => joinEnvironments(BASE_TEST_ENVIRONMENT, environment)) + } + + if (idx + 1 >= testOptions.length) { + // We're at the end, nothing more to make a matrix out of + return baseEnvironments + } + + /** + * @type {TestEnvironment[]} + */ + const environments = [] + + // Get all of the environments below us + const subEnvironments = buildTestEnvironments(idx + 1, testOptions) + + for (const baseEnvironment of baseEnvironments) { + const combinedEnvironments = subEnvironments.map( + subEnvironment => joinEnvironments(baseEnvironment, subEnvironment)) + + environments.push(...combinedEnvironments) + } + + return environments +} + +/** + * @param {TestEnvironment} base + * @param {TestEnvironment} sub + * @returns {TestEnvironment} + */ +function joinEnvironments (base, sub) { + const ignoredTests = base.ignoredTests ?? [] + if (sub.ignoredTests) { + ignoredTests.push(...sub.ignoredTests) + } + + return { + opts: { + ...base.opts, + ...sub.opts + }, + ignoredTests: ignoredTests.length > 0 ? ignoredTests : undefined, + cacheStore: sub.cacheStore + } +} + +/** + * @param {TestEnvironment[]} environments + * @returns {TestEnvironment[]} + */ +function filterEnvironments (environments) { + const { values } = CLI_OPTIONS + + if (values.type) { + environments = environments.filter(env => + env.opts.type === undefined || + values.type?.includes(env.opts.type) + ) + } + + if (values.store) { + environments = environments.filter(({ cacheStore }) => { + if (cacheStore === undefined) { + return false + } + + const storeName = cacheStore + for (const allowedStore of values.store) { + if (storeName.match(allowedStore)) { + return true + } + } + + return false + }) + } + + return environments +} diff --git a/test/cache-interceptor/memory-cache-store-tests.js b/test/cache-interceptor/memory-cache-store-tests.js new file mode 100644 index 0000000..3f2a7d8 --- /dev/null +++ b/test/cache-interceptor/memory-cache-store-tests.js @@ -0,0 +1,6 @@ +'use strict' + +const MemoryCacheStore = require('../../lib/cache/memory-cache-store') +const { cacheStoreTests } = require('./cache-store-test-utils.js') + +cacheStoreTests(MemoryCacheStore) diff --git a/test/cache-interceptor/sqlite-cache-store-tests.js b/test/cache-interceptor/sqlite-cache-store-tests.js new file mode 100644 index 0000000..c1f49d3 --- /dev/null +++ b/test/cache-interceptor/sqlite-cache-store-tests.js @@ -0,0 +1,224 @@ +'use strict' + +const { test, skip } = require('node:test') +const { notEqual, strictEqual, deepStrictEqual } = require('node:assert') +const { rm } = require('node:fs/promises') +const { cacheStoreTests, writeBody, compareGetResults } = require('./cache-store-test-utils.js') + +let hasSqlite = false +try { + require('node:sqlite') + + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + cacheStoreTests(SqliteCacheStore) + hasSqlite = true +} catch (err) { + if (err.code === 'ERR_UNKNOWN_BUILTIN_MODULE') { + skip('`node:sqlite` not present') + } else { + throw err + } +} + +test('SqliteCacheStore works nicely with multiple stores', async (t) => { + if (!hasSqlite) { + t.skip() + return + } + + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + const sqliteLocation = 'cache-interceptor.sqlite' + + const storeA = new SqliteCacheStore({ + location: sqliteLocation + }) + + const storeB = new SqliteCacheStore({ + location: sqliteLocation + }) + + t.after(async () => { + await rm(sqliteLocation) + }) + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + const body = [Buffer.from('asd'), Buffer.from('123')] + + { + const writable = storeA.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } + + // Make sure we got the expected response from store a + { + const result = storeA.get(structuredClone(key)) + notEqual(result, undefined) + await compareGetResults(result, value, body) + } + + // Make sure we got the expected response from store b + { + const result = storeB.get(structuredClone(key)) + notEqual(result, undefined) + await compareGetResults(result, value, body) + } +}) + +test('SqliteCacheStore maxEntries', async (t) => { + if (!hasSqlite) { + t.skip() + return + } + + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + + const store = new SqliteCacheStore({ + maxCount: 10 + }) + + for (let i = 0; i < 20; i++) { + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/' + i, + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + const body = ['asd', '123'] + + const writable = store.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } + + strictEqual(store.size <= 11, true) +}) + +test('SqliteCacheStore two writes', async (t) => { + if (!hasSqlite) { + t.skip() + return + } + + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + + const store = new SqliteCacheStore({ + maxCount: 10 + }) + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000 + } + + const body = ['asd', '123'] + + { + const writable = store.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } + + { + const writable = store.createWriteStream(key, value) + notEqual(writable, undefined) + writeBody(writable, body) + } +}) + +test('SqliteCacheStore write & read', async (t) => { + if (!hasSqlite) { + t.skip() + return + } + + const SqliteCacheStore = require('../../lib/cache/sqlite-cache-store.js') + + const store = new SqliteCacheStore({ + maxCount: 10 + }) + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey} + */ + const key = { + origin: 'localhost', + path: '/', + method: 'GET', + headers: {} + } + + /** + * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue & { body: Buffer }} + */ + const value = { + statusCode: 200, + statusMessage: '', + headers: { foo: 'bar' }, + cacheControlDirectives: { 'max-stale': 0 }, + cachedAt: Date.now(), + staleAt: Date.now() + 10000, + deleteAt: Date.now() + 20000, + body: Buffer.from('asd'), + etag: undefined, + vary: undefined + } + + store.set(key, value) + + deepStrictEqual(store.get(key), value) +}) diff --git a/test/cache-interceptor/utils.js b/test/cache-interceptor/utils.js new file mode 100644 index 0000000..2fc8bc1 --- /dev/null +++ b/test/cache-interceptor/utils.js @@ -0,0 +1,242 @@ +'use strict' + +const { describe, test } = require('node:test') +const { deepStrictEqual, equal } = require('node:assert') +const { parseCacheControlHeader, parseVaryHeader, isEtagUsable } = require('../../lib/util/cache') + +describe('parseCacheControlHeader', () => { + test('all directives are parsed properly when in their correct format', () => { + const directives = parseCacheControlHeader( + 'max-stale=1, min-fresh=1, max-age=1, s-maxage=1, stale-while-revalidate=1, stale-if-error=1, public, private, no-store, no-cache, must-revalidate, proxy-revalidate, immutable, no-transform, must-understand, only-if-cached' + ) + deepStrictEqual(directives, { + 'max-stale': 1, + 'min-fresh': 1, + 'max-age': 1, + 's-maxage': 1, + 'stale-while-revalidate': 1, + 'stale-if-error': 1, + public: true, + private: true, + 'no-store': true, + 'no-cache': true, + 'must-revalidate': true, + 'proxy-revalidate': true, + immutable: true, + 'no-transform': true, + 'must-understand': true, + 'only-if-cached': true + }) + }) + + test('handles weird spacings', () => { + const directives = parseCacheControlHeader( + 'max-stale=1, min-fresh=1, max-age=1,s-maxage=1, stale-while-revalidate=1,stale-if-error=1,public,private' + ) + deepStrictEqual(directives, { + 'max-stale': 1, + 'min-fresh': 1, + 'max-age': 1, + 's-maxage': 1, + 'stale-while-revalidate': 1, + 'stale-if-error': 1, + public: true, + private: true + }) + }) + + test('unknown directives are ignored', () => { + const directives = parseCacheControlHeader('max-age=123, something-else=456') + deepStrictEqual(directives, { 'max-age': 123 }) + }) + + test('directives with incorrect types are ignored', () => { + const directives = parseCacheControlHeader('max-age=true, only-if-cached=123') + deepStrictEqual(directives, {}) + }) + + test('the last instance of a directive takes precedence', () => { + const directives = parseCacheControlHeader('max-age=1, max-age=2') + deepStrictEqual(directives, { 'max-age': 2 }) + }) + + test('case insensitive', () => { + const directives = parseCacheControlHeader('Max-Age=123') + deepStrictEqual(directives, { 'max-age': 123 }) + }) + + test('no-cache with headers', () => { + let directives = parseCacheControlHeader('max-age=10, no-cache=some-header, only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'no-cache': [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, no-cache="some-header", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'no-cache': [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, no-cache="some-header, another-one", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'no-cache': [ + 'some-header', + 'another-one' + ], + 'only-if-cached': true + }) + }) + + test('private with headers', () => { + let directives = parseCacheControlHeader('max-age=10, private=some-header, only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + private: [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, private="some-header", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + private: [ + 'some-header' + ], + 'only-if-cached': true + }) + + directives = parseCacheControlHeader('max-age=10, private="some-header, another-one", only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + private: [ + 'some-header', + 'another-one' + ], + 'only-if-cached': true + }) + + // Missing ending quote, invalid & should be skipped + directives = parseCacheControlHeader('max-age=10, private="some-header, another-one, only-if-cached') + deepStrictEqual(directives, { + 'max-age': 10, + 'only-if-cached': true + }) + }) + + test('handles multiple headers correctly', () => { + // For requests like + // cache-control: max-stale=1 + // cache-control: min-fresh-1 + // ... + const directives = parseCacheControlHeader([ + 'max-stale=1', + 'min-fresh=1', + 'max-age=1', + 's-maxage=1', + 'stale-while-revalidate=1', + 'stale-if-error=1', + 'public', + 'private', + 'no-store', + 'no-cache', + 'must-revalidate', + 'proxy-revalidate', + 'immutable', + 'no-transform', + 'must-understand', + 'only-if-cached' + ]) + deepStrictEqual(directives, { + 'max-stale': 1, + 'min-fresh': 1, + 'max-age': 1, + 's-maxage': 1, + 'stale-while-revalidate': 1, + 'stale-if-error': 1, + public: true, + private: true, + 'no-store': true, + 'no-cache': true, + 'must-revalidate': true, + 'proxy-revalidate': true, + immutable: true, + 'no-transform': true, + 'must-understand': true, + 'only-if-cached': true + }) + }) +}) + +describe('parseVaryHeader', () => { + test('basic usage', () => { + const output = parseVaryHeader('some-header, another-one', { + 'some-header': 'asd', + 'another-one': '123', + 'third-header': 'cool' + }) + deepStrictEqual(output, { + 'some-header': 'asd', + 'another-one': '123' + }) + }) + + test('handles weird spacings', () => { + const output = parseVaryHeader('some-header, another-one,something-else', { + 'some-header': 'asd', + 'another-one': '123', + 'something-else': 'asd123', + 'third-header': 'cool' + }) + deepStrictEqual(output, { + 'some-header': 'asd', + 'another-one': '123', + 'something-else': 'asd123' + }) + }) + + test('handles multiple headers correctly', () => { + const output = parseVaryHeader(['some-header', 'another-one'], { + 'some-header': 'asd', + 'another-one': '123', + 'third-header': 'cool' + }) + deepStrictEqual(output, { + 'some-header': 'asd', + 'another-one': '123' + }) + }) +}) + +describe('isEtagUsable', () => { + const valuesToTest = { + // Invalid etags + '': false, + asd: false, + '"W/"asd""': false, + '""asd""': false, + + // Valid etags + '"asd"': true, + 'W/"ads"': true, + + // Spec deviations + '""': false, + 'W/""': false + } + + for (const key in valuesToTest) { + const expectedValue = valuesToTest[key] + test(`\`${key}\` = ${expectedValue}`, () => { + equal(isEtagUsable(key), expectedValue) + }) + } +}) diff --git a/test/cache/cache.js b/test/cache/cache.js new file mode 100644 index 0000000..4a6b1ba --- /dev/null +++ b/test/cache/cache.js @@ -0,0 +1,12 @@ +'use strict' + +const { throws } = require('node:assert') +const { test } = require('node:test') +const { Cache } = require('../../lib/web/cache/cache') + +test('constructor', () => { + throws(() => new Cache(null), { + name: 'TypeError', + message: 'TypeError: Illegal constructor' + }) +}) diff --git a/test/cache/cachestorage.js b/test/cache/cachestorage.js new file mode 100644 index 0000000..ba648f1 --- /dev/null +++ b/test/cache/cachestorage.js @@ -0,0 +1,12 @@ +'use strict' + +const { throws } = require('node:assert') +const { test } = require('node:test') +const { CacheStorage } = require('../../lib/web/cache/cachestorage') + +test('constructor', () => { + throws(() => new CacheStorage(null), { + name: 'TypeError', + message: 'TypeError: Illegal constructor' + }) +}) diff --git a/test/cache/get-field-values.js b/test/cache/get-field-values.js new file mode 100644 index 0000000..b6a2f60 --- /dev/null +++ b/test/cache/get-field-values.js @@ -0,0 +1,19 @@ +'use strict' + +const { deepStrictEqual, throws } = require('node:assert') +const { test } = require('node:test') +const { getFieldValues } = require('../../lib/web/cache/util') + +test('getFieldValues', () => { + throws(() => getFieldValues(null), { + name: 'AssertionError', + message: 'The expression evaluated to a falsy value:\n\n assert(header !== null)\n' + }) + deepStrictEqual(getFieldValues(''), []) + deepStrictEqual(getFieldValues('foo'), ['foo']) + deepStrictEqual(getFieldValues('invälid'), []) + deepStrictEqual(getFieldValues('foo, bar'), ['foo', 'bar']) + deepStrictEqual(getFieldValues('foo, bar, baz'), ['foo', 'bar', 'baz']) + deepStrictEqual(getFieldValues('foo, bar, baz, '), ['foo', 'bar', 'baz']) + deepStrictEqual(getFieldValues('foo, bar, baz, , '), ['foo', 'bar', 'baz']) +}) diff --git a/test/client-connect.js b/test/client-connect.js new file mode 100644 index 0000000..72e5750 --- /dev/null +++ b/test/client-connect.js @@ -0,0 +1,45 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { once } = require('node:events') +const { Client, errors } = require('..') +const http = require('node:http') +const EE = require('node:events') +const { kBusy } = require('../lib/core/symbols') + +// TODO: move to test/node-test/client-connect.js +test('connect aborted after connect', async (t) => { + t = tspl(t, { plan: 3 }) + + const signal = new EE() + const server = http.createServer((req, res) => { + t.fail() + }) + server.on('connect', (req, c, firstBodyChunk) => { + signal.emit('abort') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + after(() => client.close()) + + client.connect({ + path: '/', + signal, + opaque: 'asd', + blocking: false + }, (err, { opaque }) => { + t.strictEqual(opaque, 'asd') + t.ok(err instanceof errors.RequestAbortedError) + }) + t.strictEqual(client[kBusy], true) + + await t.completed +}) diff --git a/test/client-head-reset-override.js b/test/client-head-reset-override.js new file mode 100644 index 0000000..4582aa0 --- /dev/null +++ b/test/client-head-reset-override.js @@ -0,0 +1,68 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test, after } = require('node:test') +const { Client } = require('..') + +test('override HEAD reset', async (t) => { + t = tspl(t, { plan: 4 }) + + const expected = 'testing123' + const server = createServer((req, res) => { + if (req.method === 'GET') { + res.write(expected) + } + res.end() + }).listen(0) + + after(() => server.close()) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let done + client.on('disconnect', () => { + if (!done) { + t.fail() + } + }) + + client.request({ + path: '/', + method: 'HEAD', + reset: false + }, (err, res) => { + t.ifError(err) + res.body.resume() + }) + + client.request({ + path: '/', + method: 'HEAD', + reset: false + }, (err, res) => { + t.ifError(err) + res.body.resume() + }) + + client.request({ + path: '/', + method: 'GET', + reset: false + }, (err, res) => { + t.ifError(err) + let str = '' + res.body.on('data', (data) => { + str += data + }).on('end', () => { + t.strictEqual(str, expected) + done = true + t.end() + }) + }) + + await t.completed +}) diff --git a/test/client-idempotent-body.js b/test/client-idempotent-body.js new file mode 100644 index 0000000..6f40efd --- /dev/null +++ b/test/client-idempotent-body.js @@ -0,0 +1,47 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client } = require('..') +const { createServer } = require('node:http') + +test('idempotent retry', async (t) => { + t = tspl(t, { plan: 11 }) + + const body = 'world' + const server = createServer((req, res) => { + let buf = '' + req.on('data', data => { + buf += data + }).on('end', () => { + t.strictEqual(buf, body) + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + const _err = new Error() + + for (let n = 0; n < 4; ++n) { + client.stream({ + path: '/', + method: 'PUT', + idempotent: true, + blocking: false, + body + }, () => { + throw _err + }, (err) => { + t.strictEqual(err, _err) + }) + } + }) + + await t.completed +}) diff --git a/test/client-keep-alive.js b/test/client-keep-alive.js new file mode 100644 index 0000000..f00e4d8 --- /dev/null +++ b/test/client-keep-alive.js @@ -0,0 +1,377 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { once } = require('node:events') +const { Client } = require('..') +const timers = require('../lib/util/timers') +const { kConnect } = require('../lib/core/symbols') +const { createServer } = require('node:net') +const http = require('node:http') +const FakeTimers = require('@sinonjs/fake-timers') + +test('keep-alive header', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=2s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 4e3) + client.on('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + await t.completed +}) + +test('keep-alive header 0', async (t) => { + t = tspl(t, { plan: 2 }) + + const clock = FakeTimers.install() + after(() => clock.uninstall()) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + after(() => { Object.assign(timers, orgTimers) }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeoutThreshold: 500 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + client.on('disconnect', () => { + t.ok(true, 'pass') + }) + clock.tick(600) + }).resume() + }) + await t.completed +}) + +test('keep-alive header 1', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 0) + client.on('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + await t.completed +}) + +test('keep-alive header no postfix', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=2\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 4e3) + client.on('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + await t.completed +}) + +test('keep-alive not timeout', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeoutasdasd=1s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 1e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.on('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + await t.completed +}) + +test('keep-alive threshold', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=30s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 30e3, + keepAliveTimeoutThreshold: 29e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 5e3) + client.on('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + await t.completed +}) + +test('keep-alive max keepalive', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((socket) => { + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Keep-Alive: timeout=30s\r\n') + socket.write('Connection: keep-alive\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 30e3, + keepAliveMaxTimeout: 1e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.on('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + await t.completed +}) + +test('connection close', async (t) => { + t = tspl(t, { plan: 4 }) + + let close = false + const server = createServer((socket) => { + if (close) { + return + } + close = true + socket.write('HTTP/1.1 200 OK\r\n') + socket.write('Content-Length: 0\r\n') + socket.write('Connection: close\r\n') + socket.write('\r\n\r\n') + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.once('disconnect', () => { + close = false + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + const timeout = setTimeout(() => { + t.fail() + }, 3e3) + client.once('disconnect', () => { + t.ok(true, 'pass') + clearTimeout(timeout) + }) + }).resume() + }) + }) + await t.completed +}) + +test('Disable keep alive', async (t) => { + t = tspl(t, { plan: 7 }) + + const ports = [] + const server = http.createServer((req, res) => { + t.strictEqual(ports.includes(req.socket.remotePort), false) + ports.push(req.socket.remotePort) + t.strictEqual(req.headers.connection, 'close') + res.writeHead(200, { connection: 'close' }) + res.end() + }) + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`, { pipelining: 0 }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('end', () => { + t.ok(true, 'pass') + }).resume() + }) + }).resume() + }) + await t.completed +}) diff --git a/test/client-node-max-header-size.js b/test/client-node-max-header-size.js new file mode 100644 index 0000000..2d1d8cb --- /dev/null +++ b/test/client-node-max-header-size.js @@ -0,0 +1,63 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { once } = require('node:events') +const { exec } = require('node:child_process') +const { test, before, after, describe } = require('node:test') +const { createServer } = require('node:http') + +describe("Node.js' --max-http-header-size cli option", () => { + let server + + before(async () => { + server = createServer((req, res) => { + res.writeHead(200, 'OK', { + 'Content-Length': 2 + }) + res.write('OK') + res.end() + }).listen(0) + + await once(server, 'listening') + }) + + after(() => server.close()) + + test("respect Node.js' --max-http-header-size", async (t) => { + t = tspl(t, { plan: 6 }) + const command = 'node --disable-warning=ExperimentalWarning -e "require(\'.\').request(\'http://localhost:' + server.address().port + '\')"' + + exec(`${command} --max-http-header-size=1`, { stdio: 'pipe' }, (err, stdout, stderr) => { + t.strictEqual(err.code, 1) + t.strictEqual(stdout, '') + t.match(stderr, /UND_ERR_HEADERS_OVERFLOW/, '--max-http-header-size=1 should throw') + }) + + exec(command, { stdio: 'pipe' }, (err, stdout, stderr) => { + t.ifError(err) + t.strictEqual(stdout, '') + t.strictEqual(stderr, '', 'default max-http-header-size should not throw') + }) + + await t.completed + }) + + test('--max-http-header-size with Client API', async (t) => { + t = tspl(t, { plan: 6 }) + const command = 'node --disable-warning=ExperimentalWarning -e "new (require(\'.\').Client)(new URL(\'http://localhost:200\'))"' + + exec(`${command} --max-http-header-size=0`, { stdio: 'pipe' }, (err, stdout, stderr) => { + t.strictEqual(err.code, 1) + t.strictEqual(stdout, '') + t.match(stderr, /http module not available or http.maxHeaderSize invalid/, '--max-http-header-size=0 should result in an Error when using the Client API') + }) + + exec(command, { stdio: 'pipe' }, (err, stdout, stderr) => { + t.ifError(err) + t.strictEqual(stdout, '') + t.strictEqual(stderr, '', 'default max-http-header-size should not throw') + }) + + await t.completed + }) +}) diff --git a/test/client-pipeline.js b/test/client-pipeline.js new file mode 100644 index 0000000..bc2cd1d --- /dev/null +++ b/test/client-pipeline.js @@ -0,0 +1,1102 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const EE = require('node:events') +const { createServer } = require('node:http') +const { + pipeline, + Readable, + Transform, + Writable, + PassThrough +} = require('node:stream') + +test('pipeline get', async (t) => { + t = tspl(t, { plan: 17 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + t.strictEqual(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + { + const bufs = [] + const signal = new EE() + client.pipeline({ signal, path: '/', method: 'GET' }, ({ statusCode, headers, body }) => { + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + t.strictEqual(signal.listenerCount('abort'), 1) + return body + }) + .end() + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + .on('close', () => { + t.strictEqual(signal.listenerCount('abort'), 0) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + } + + { + const bufs = [] + client.pipeline({ path: '/', method: 'GET' }, ({ statusCode, headers, body }) => { + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + return body + }) + .end() + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + } + }) + + await t.completed +}) + +test('pipeline echo', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let res = '' + const buf1 = Buffer.alloc(1e3).toString() + const buf2 = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf1) + this.push(buf2) + this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + return pipeline(body, new PassThrough(), () => {}) + }), + new Writable({ + write (chunk, encoding, callback) { + res += chunk.toString() + callback() + }, + final (callback) { + t.strictEqual(res, buf1 + buf2) + callback() + } + }), + (err) => { + t.ifError(err) + } + ) + }) + + await t.completed +}) + +test('pipeline ignore request body', async (t) => { + t = tspl(t, { plan: 2 }) + + let done + const server = createServer((req, res) => { + res.write('asd') + res.end() + done() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let res = '' + const buf1 = Buffer.alloc(1e3).toString() + const buf2 = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf1) + this.push(buf2) + done = () => this.push(null) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + return pipeline(body, new PassThrough(), () => {}) + }), + new Writable({ + write (chunk, encoding, callback) { + res += chunk.toString() + callback() + }, + final (callback) { + t.strictEqual(res, 'asd') + callback() + } + }), + (err) => { + t.ifError(err) + } + ) + }) + + await t.completed +}) + +test('pipeline invalid handler', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:5000') + client.pipeline({}, null).on('error', (err) => { + t.ok(/handler/.test(err)) + }) + + await t.completed +}) + +test('pipeline invalid handler return after destroy should not error', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + after(() => client.destroy()) + + const dup = client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + body.on('error', (err) => { + t.strictEqual(err.message, 'asd') + }) + dup.destroy(new Error('asd')) + return {} + }) + .on('error', (err) => { + t.strictEqual(err.message, 'asd') + }) + .on('close', () => { + t.ok(true, 'pass') + }) + .end() + }) + + await t.completed +}) + +test('pipeline error body', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const buf = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + const pt = new PassThrough() + process.nextTick(() => { + pt.destroy(new Error('asd')) + }) + body.on('error', (err) => { + t.ok(err) + }) + return pipeline(body, pt, () => {}) + }), + new PassThrough(), + (err) => { + t.ok(err) + } + ) + }) + + await t.completed +}) + +test('pipeline destroy body', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const buf = Buffer.alloc(1e6).toString() + pipeline( + new Readable({ + read () { + this.push(buf) + } + }), + client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + const pt = new PassThrough() + process.nextTick(() => { + pt.destroy() + }) + body.on('error', (err) => { + t.ok(err) + }) + return pipeline(body, pt, () => {}) + }), + new PassThrough(), + (err) => { + t.ok(err) + } + ) + }) + + await t.completed +}) + +test('pipeline backpressure', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const buf = Buffer.alloc(1e6).toString() + const duplex = client.pipeline({ + path: '/', + method: 'PUT' + }, ({ body }) => { + const pt = new PassThrough() + return pipeline(body, pt, () => {}) + }) + + duplex.end(buf) + duplex.on('data', () => { + duplex.pause() + setImmediate(() => { + duplex.resume() + }) + }).on('end', () => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) + +test('pipeline invalid handler return', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + }) + .on('error', (err) => { + t.ok(err instanceof errors.InvalidReturnValueError) + }) + .end() + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + return {} + }) + .on('error', (err) => { + t.ok(err instanceof errors.InvalidReturnValueError) + }) + .end() + }) + + await t.completed +}) + +test('pipeline throw handler', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + throw new Error('asd') + }) + .on('error', (err) => { + t.strictEqual(err.message, 'asd') + }) + .end() + }) + + await t.completed +}) + +test('pipeline destroy and throw handler', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const dup = client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + dup.destroy() + // TODO: Should body cause unhandled exception? + body.on('error', () => {}) + throw new Error('asd') + }) + .end() + .on('error', (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + .on('close', () => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) + +test('pipeline abort res', async (t) => { + t = tspl(t, { plan: 2 }) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + setImmediate(() => { + body.destroy() + _res.write('asdasdadasd') + const timeout = setTimeout(() => { + t.fail() + }, 100) + client.on('disconnect', () => { + clearTimeout(timeout) + t.ok(true, 'pass') + }) + }) + return body + }) + .on('error', (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + .end() + }) + + await t.completed +}) + +test('pipeline abort server res', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.destroy() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, () => { + t.fail() + }) + .on('error', (err) => { + t.ok(err instanceof errors.SocketError) + }) + .end() + }) + + await t.completed +}) + +test('pipeline abort duplex', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'PUT' + }, (err, data) => { + t.ifError(err) + data.body.resume() + + client.pipeline({ + path: '/', + method: 'PUT' + }, () => { + t.fail() + }).destroy().on('error', (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + }) + }) + + await t.completed +}) + +test('pipeline abort piped res', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.write('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough() + setImmediate(() => { + pt.destroy() + }) + return pipeline(body, pt, () => {}) + }) + .on('error', (err) => { + t.strictEqual(err.code, 'UND_ERR_ABORTED') + }) + .end() + }) + + await t.completed +}) + +test('pipeline abort piped res 2', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.write('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough() + body.on('error', (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + setImmediate(() => { + pt.destroy() + }) + body.pipe(pt) + return pt + }) + .on('error', (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + .end() + }) + + await t.completed +}) + +test('pipeline abort piped res 3', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.write('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough() + body.on('error', (err) => { + t.strictEqual(err.message, 'asd') + }) + setImmediate(() => { + pt.destroy(new Error('asd')) + }) + body.pipe(pt) + return pt + }) + .on('error', (err) => { + t.strictEqual(err.message, 'asd') + }) + .end() + }) + + await t.completed +}) + +test('pipeline abort server res after headers', async (t) => { + t = tspl(t, { plan: 1 }) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, (data) => { + _res.destroy() + return data.body + }) + .on('error', (err) => { + t.ok(err instanceof errors.SocketError) + }) + .end() + }) + + await t.completed +}) + +test('pipeline w/ write abort server res after headers', async (t) => { + t = tspl(t, { plan: 1 }) + + let _res + const server = createServer((req, res) => { + req.pipe(res) + _res = res + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'PUT' + }, (data) => { + _res.destroy() + return data.body + }) + .on('error', (err) => { + t.ok(err instanceof errors.SocketError) + }) + .resume() + .write('asd') + }) + + await t.completed +}) + +test('destroy in push', async (t) => { + t = tspl(t, { plan: 3 }) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.pipeline({ path: '/', method: 'GET' }, ({ body }) => { + body.once('data', () => { + _res.write('asd') + body.on('data', (buf) => { + body.destroy() + _res.end() + }).on('error', (err) => { + t.ok(err) + }) + }) + return body + }).on('error', (err) => { + t.ok(err) + }).resume().end() + + client.pipeline({ path: '/', method: 'GET' }, ({ body }) => { + let buf = '' + body.on('data', (chunk) => { + buf = chunk.toString() + _res.end() + }).on('end', () => { + t.strictEqual('asd', buf) + }) + return body + }).resume().end() + }) + + await t.completed +}) + +test('pipeline args validation', async (t) => { + t = tspl(t, { plan: 2 }) + + const client = new Client('http://localhost:5000') + + const ret = client.pipeline(null, () => {}) + ret.on('error', (err) => { + t.ok(/opts/.test(err.message)) + t.ok(err instanceof errors.InvalidArgumentError) + }) + + await t.completed +}) + +test('pipeline factory throw not unhandled', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.write('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, (data) => { + throw new Error('asd') + }) + .on('error', (err) => { + t.ok(err) + }) + .end() + }) + + await t.completed +}) + +test('pipeline destroy before dispatch', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client + .pipeline({ path: '/', method: 'GET' }, ({ body }) => { + return body + }) + .on('error', (err) => { + t.ok(err) + }) + .end() + .destroy() + }) + + await t.completed +}) + +test('pipeline legacy stream', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.write(Buffer.alloc(16e3)) + setImmediate(() => { + res.end(Buffer.alloc(16e3)) + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client + .pipeline({ path: '/', method: 'GET' }, ({ body }) => { + const pt = new PassThrough() + pt.pause = null + return body.pipe(pt) + }) + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + .end() + }) + + await t.completed +}) + +test('pipeline objectMode', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end(JSON.stringify({ asd: 1 })) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client + .pipeline({ path: '/', method: 'GET', objectMode: true }, ({ body }) => { + return pipeline(body, new Transform({ + readableObjectMode: true, + transform (chunk, encoding, callback) { + callback(null, JSON.parse(chunk)) + } + }), () => {}) + }) + .on('data', data => { + t.deepStrictEqual(data, { asd: 1 }) + }) + .end() + }) + + await t.completed +}) + +test('pipeline invalid opts', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end(JSON.stringify({ asd: 1 })) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.close((err) => { + t.ifError(err) + }) + client + .pipeline({ path: '/', method: 'GET', objectMode: true }, ({ body }) => { + t.fail() + }) + .on('error', (err) => { + t.ok(err) + }) + }) + + await t.completed +}) + +test('pipeline CONNECT throw', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'CONNECT' + }, () => { + t.fail() + }).on('error', (err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + client.on('disconnect', () => { + t.fail() + }) + }) + + await t.completed +}) + +test('pipeline body without destroy', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => { + const pt = new PassThrough({ autoDestroy: false }) + pt.destroy = null + return body.pipe(pt) + }) + .end() + .on('end', () => { + t.ok(true, 'pass') + }) + .resume() + }) + + await t.completed +}) + +test('pipeline ignore 1xx', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let buf = '' + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => body) + .on('data', (chunk) => { + buf += chunk + }) + .on('end', () => { + t.strictEqual(buf, 'hello') + }) + .end() + }) + + await t.completed +}) + +test('pipeline ignore 1xx and use onInfo', async (t) => { + t = tspl(t, { plan: 3 }) + + const infos = [] + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let buf = '' + client.pipeline({ + path: '/', + method: 'GET', + onInfo: (x) => { + infos.push(x) + } + }, ({ body }) => body) + .on('data', (chunk) => { + buf += chunk + }) + .on('end', () => { + t.strictEqual(buf, 'hello') + t.strictEqual(infos.length, 1) + t.strictEqual(infos[0].statusCode, 102) + }) + .end() + }) + + await t.completed +}) + +test('pipeline backpressure', async (t) => { + t = tspl(t, { plan: 1 }) + + const expected = Buffer.alloc(1e6).toString() + + const server = createServer((req, res) => { + res.writeProcessing() + res.end(expected) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let buf = '' + client.pipeline({ + path: '/', + method: 'GET' + }, ({ body }) => body) + .end() + .pipe(new Transform({ + highWaterMark: 1, + transform (chunk, encoding, callback) { + setImmediate(() => { + callback(null, chunk) + }) + } + })) + .on('data', chunk => { + buf += chunk + }) + .on('end', () => { + t.strictEqual(buf, expected) + }) + }) + + await t.completed +}) + +test('pipeline abort after headers', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.writeProcessing() + res.write('asd') + setImmediate(() => { + res.write('asd') + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const signal = new EE() + client.pipeline({ + path: '/', + method: 'GET', + signal + }, ({ body }) => { + process.nextTick(() => { + signal.emit('abort') + }) + return body + }) + .end() + .on('error', (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + }) + + await t.completed +}) diff --git a/test/client-pipelining.js b/test/client-pipelining.js new file mode 100644 index 0000000..b62357f --- /dev/null +++ b/test/client-pipelining.js @@ -0,0 +1,777 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client } = require('..') +const { createServer } = require('node:http') +const { finished, Readable } = require('node:stream') +const { kConnect } = require('../lib/core/symbols') +const EE = require('node:events') +const { kBusy, kRunning, kSize } = require('../lib/core/symbols') +const { maybeWrapStream, consts } = require('./utils/async-iterators') + +test('20 times GET with pipelining 10', async (t) => { + const num = 20 + t = tspl(t, { plan: 3 * num + 1 }) + + let count = 0 + let countGreaterThanOne = false + const server = createServer((req, res) => { + count++ + setTimeout(function () { + countGreaterThanOne = countGreaterThanOne || count > 1 + res.end(req.url) + }, 10) + }) + after(() => server.close()) + + // needed to check for a warning on the maxListeners on the socket + function onWarning (warning) { + if (!/ExperimentalWarning/.test(warning)) { + t.fail() + } + } + process.on('warning', onWarning) + after(() => { + process.removeListener('warning', onWarning) + }) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + after(() => client.close()) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + function makeRequest (i) { + makeRequestAndExpectUrl(client, i, t, () => { + count-- + + if (i === num - 1) { + t.ok(countGreaterThanOne, 'seen more than one parallel request') + } + }) + } + }) + + await t.completed +}) + +function makeRequestAndExpectUrl (client, i, t, cb) { + return client.request({ path: '/' + i, method: 'GET', blocking: false }, (err, { statusCode, headers, body }) => { + cb() + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('/' + i, Buffer.concat(bufs).toString('utf8')) + }) + }) +} + +test('A client should enqueue as much as twice its pipelining factor', async (t) => { + const num = 10 + let sent = 0 + // x * 6 + 1 t.ok + 5 drain + t = tspl(t, { plan: num * 6 + 1 + 5 + 2 }) + + let count = 0 + let countGreaterThanOne = false + const server = createServer((req, res) => { + count++ + t.ok(count <= 5) + setTimeout(function () { + countGreaterThanOne = countGreaterThanOne || count > 1 + res.end(req.url) + }, 10) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + for (; sent < 2;) { + t.ok(client[kSize] <= client.pipelining, 'client is not full') + makeRequest() + t.ok(client[kSize] <= client.pipelining, 'we can send more requests') + } + + t.ok(client[kBusy], 'client is busy') + t.ok(client[kSize] <= client.pipelining, 'client is full') + makeRequest() + t.ok(client[kBusy], 'we must stop now') + t.ok(client[kBusy], 'client is busy') + t.ok(client[kSize] > client.pipelining, 'client is full') + + function makeRequest () { + makeRequestAndExpectUrl(client, sent++, t, () => { + count-- + setImmediate(() => { + if (client[kSize] === 0) { + t.ok(countGreaterThanOne, 'seen more than one parallel request') + const start = sent + for (; sent < start + 2 && sent < num;) { + t.ok(client[kSize] <= client.pipelining, 'client is not full') + t.ok(makeRequest()) + } + } + }) + }) + return client[kSize] <= client.pipelining + } + }) + + await t.completed +}) + +test('pipeline 1 is 1 active request', async (t) => { + t = tspl(t, { plan: 9 }) + + let res2 + const server = createServer((req, res) => { + res.write('asd') + res2 = res + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + after(() => client.destroy()) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.strictEqual(client[kSize], 1) + t.ifError(err) + t.strictEqual(client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + finished(data.body, (err) => { + t.ok(err) + client.close((err) => { + t.ifError(err) + }) + }) + data.body.destroy() + res2.end() + }), undefined) + data.body.resume() + res2.end() + }) + t.ok(client[kSize] <= client.pipelining) + t.ok(client[kBusy]) + t.strictEqual(client[kSize], 1) + }) + + await t.completed +}) + +test('pipelined chunked POST stream', async (t) => { + t = tspl(t, { plan: 4 + 8 + 8 }) + + let a = 0 + let b = 0 + + const server = createServer((req, res) => { + req.on('data', chunk => { + // Make sure a and b don't interleave. + t.ok(a === 9 || b === 0) + res.write(chunk) + }).on('end', () => { + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: new Readable({ + read () { + this.push(++a > 8 ? null : 'a') + } + }) + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: new Readable({ + read () { + this.push(++b > 8 ? null : 'b') + } + }) + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + }) + + await t.completed +}) + +test('pipelined chunked POST iterator', async (t) => { + t = tspl(t, { plan: 4 + 8 + 8 }) + + let a = 0 + let b = 0 + + const server = createServer((req, res) => { + req.on('data', chunk => { + // Make sure a and b don't interleave. + t.ok(a === 9 || b === 0) + res.write(chunk) + }).on('end', () => { + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: (async function * () { + while (++a <= 8) { + yield 'a' + } + })() + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'POST', + body: (async function * () { + while (++b <= 8) { + yield 'b' + } + })() + }, (err, { body }) => { + body.resume() + t.ifError(err) + }) + }) + + await t.completed +}) + +function errordInflightPost (bodyType) { + test(`errored POST body lets inflight complete ${bodyType}`, async (t) => { + t = tspl(t, { plan: 6 }) + + let serverRes + const server = createServer() + server.on('request', (req, res) => { + serverRes = res + res.write('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .once('data', () => { + client.request({ + path: '/', + method: 'POST', + opaque: 'asd', + body: maybeWrapStream(new Readable({ + read () { + this.destroy(new Error('kaboom')) + } + }).once('error', (err) => { + t.ok(err) + }).on('error', () => { + // Readable emits error twice... + }), bodyType) + }, (err, data) => { + t.ok(err) + t.strictEqual(data.opaque, 'asd') + }) + client.close((err) => { + t.ifError(err) + }) + serverRes.end() + }) + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + await t.completed + }) +} + +errordInflightPost(consts.STREAM) +errordInflightPost(consts.ASYNC_ITERATOR) + +test('pipelining non-idempotent', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer() + server.on('request', (req, res) => { + setTimeout(() => { + res.end('asd') + }, 10) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + let ended = false + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + ended = true + }) + }) + + client.request({ + path: '/', + method: 'GET', + idempotent: false + }, (err, data) => { + t.ifError(err) + t.strictEqual(ended, true) + data.body.resume() + }) + }) + + await t.completed +}) + +function pipeliningNonIdempotentWithBody (bodyType) { + test(`pipelining non-idempotent w body ${bodyType}`, async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer() + server.on('request', (req, res) => { + setImmediate(() => { + res.end('asd') + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.close()) + + let ended = false + let reading = false + client.request({ + path: '/', + method: 'POST', + body: maybeWrapStream(new Readable({ + read () { + if (reading) { + return + } + reading = true + this.push('asd') + setImmediate(() => { + this.push(null) + ended = true + }) + } + }), bodyType) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + + client.request({ + path: '/', + method: 'GET', + idempotent: false + }, (err, data) => { + t.ifError(err) + t.strictEqual(ended, true) + data.body.resume() + }) + }) + + await t.completed + }) +} + +pipeliningNonIdempotentWithBody(consts.STREAM) +pipeliningNonIdempotentWithBody(consts.ASYNC_ITERATOR) + +function pipeliningHeadBusy (bodyType) { + test(`pipelining HEAD busy ${bodyType}`, async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer() + server.on('request', (req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + after(() => client.close()) + + client[kConnect](() => { + let ended = false + client.once('disconnect', () => { + t.strictEqual(ended, true) + }) + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + body.push(null) + t.strictEqual(client[kBusy], true) + } + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'HEAD', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + ended = true + t.ok(true, 'pass') + }) + }) + body.push(null) + t.strictEqual(client[kBusy], true) + } + }) + }) + + await t.completed + }) +} + +pipeliningHeadBusy(consts.STREAM) +pipeliningHeadBusy(consts.ASYNC_ITERATOR) + +test('pipelining empty pipeline before reset', async (t) => { + t = tspl(t, { plan: 8 }) + + let c = 0 + const server = createServer() + server.on('request', (req, res) => { + if (c++ === 0) { + res.end('asd') + } else { + setTimeout(() => { + res.end('asd') + }, 100) + } + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + after(() => client.close()) + + client[kConnect](() => { + let ended = false + client.once('disconnect', () => { + t.strictEqual(ended, true) + }) + + client.request({ + path: '/', + method: 'GET', + blocking: false + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + t.strictEqual(client[kBusy], false) + + client.request({ + path: '/', + method: 'HEAD', + body: 'asd' + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + ended = true + t.ok(true, 'pass') + }) + }) + t.strictEqual(client[kBusy], true) + t.strictEqual(client[kRunning], 2) + }) + }) + + await t.completed +}) + +function pipeliningIdempotentBusy (bodyType) { + test(`pipelining idempotent busy ${bodyType}`, async (t) => { + t = tspl(t, { plan: 12 }) + + const server = createServer() + server.on('request', (req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + after(() => client.close()) + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + body.push(null) + t.strictEqual(client[kBusy], true) + } + + client[kConnect](() => { + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + body.push(null) + t.strictEqual(client[kBusy], true) + } + + { + const signal = new EE() + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + body: maybeWrapStream(body, bodyType), + signal + }, (err, data) => { + t.ok(err) + }) + t.strictEqual(client[kBusy], true) + signal.emit('abort') + t.strictEqual(client[kBusy], true) + } + + { + const body = new Readable({ + read () { } + }) + client.request({ + path: '/', + method: 'GET', + idempotent: false, + body: maybeWrapStream(body, bodyType) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + body.push(null) + t.strictEqual(client[kBusy], true) + } + }) + }) + + await t.completed + }) +} + +pipeliningIdempotentBusy(consts.STREAM) +pipeliningIdempotentBusy(consts.ASYNC_ITERATOR) + +test('pipelining blocked', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = createServer() + + let blocking = true + let count = 0 + + server.on('request', (req, res) => { + t.ok(!count || !blocking) + count++ + setImmediate(() => { + res.end('asd') + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 10 + }) + after(() => client.close()) + client.request({ + path: '/', + method: 'GET', + blocking: true + }, (err, data) => { + t.ifError(err) + blocking = false + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) diff --git a/test/client-post.js b/test/client-post.js new file mode 100644 index 0000000..e666faf --- /dev/null +++ b/test/client-post.js @@ -0,0 +1,83 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { once } = require('node:events') +const { Client } = require('..') +const { createServer } = require('node:http') +const { Blob } = require('node:buffer') + +test('request post blob', { skip: !Blob }, async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer(async (req, res) => { + t.strictEqual(req.headers['content-type'], 'application/json') + let str = '' + for await (const chunk of req) { + str += chunk + } + t.strictEqual(str, 'asd') + res.end() + }) + after(server.close.bind(server)) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.request({ + path: '/', + method: 'GET', + body: new Blob(['asd'], { + type: 'application/json' + }) + }, (err, data) => { + t.ifError(err) + data.body.resume().on('end', () => { + t.end() + }) + }) + await t.completed +}) + +test('request post arrayBuffer', { skip: !Blob }, async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer(async (req, res) => { + let str = '' + for await (const chunk of req) { + str += chunk + } + t.strictEqual(str, 'asd') + res.end() + }) + + after(() => server.close()) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const buf = Buffer.from('asd') + const dst = new ArrayBuffer(buf.byteLength) + buf.copy(new Uint8Array(dst)) + + client.request({ + path: '/', + method: 'GET', + body: dst + }, (err, data) => { + t.ifError(err) + data.body.resume().on('end', () => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) diff --git a/test/client-reconnect.js b/test/client-reconnect.js new file mode 100644 index 0000000..222922a --- /dev/null +++ b/test/client-reconnect.js @@ -0,0 +1,57 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { once } = require('node:events') +const { Client } = require('..') +const { createServer } = require('node:http') +const FakeTimers = require('@sinonjs/fake-timers') +const timers = require('../lib/util/timers') + +test('multiple reconnect', async (t) => { + t = tspl(t, { plan: 5 }) + + let n = 0 + const clock = FakeTimers.install() + after(() => clock.uninstall()) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + after(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + n === 0 ? res.destroy() : res.end('ok') + }) + after(() => server.close()) + + server.listen(0) + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ok(err) + t.strictEqual(err.code, 'UND_ERR_SOCKET') + }) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + + client.on('disconnect', () => { + if (++n === 1) { + t.ok(true, 'pass') + } + process.nextTick(() => { + clock.tick(1000) + }) + }) + await t.completed +}) diff --git a/test/client-request.js b/test/client-request.js new file mode 100644 index 0000000..9712820 --- /dev/null +++ b/test/client-request.js @@ -0,0 +1,1375 @@ +/* globals AbortController */ + +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after, describe, before } = require('node:test') +const { Client, errors } = require('..') +const { createServer } = require('node:http') +const EE = require('node:events') +const { kConnect } = require('../lib/core/symbols') +const { Readable } = require('node:stream') +const net = require('node:net') +const { promisify } = require('node:util') +const { NotSupportedError, InvalidArgumentError } = require('../lib/core/errors') +const { parseFormDataString } = require('./utils/formdata') + +test('request dump head', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.setHeader('content-length', 5 * 100) + res.flushHeaders() + res.write('hello'.repeat(100)) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + let dumped = false + client.on('disconnect', () => { + t.strictEqual(dumped, true) + }) + client.request({ + path: '/', + method: 'HEAD' + }, (err, { body }) => { + t.ifError(err) + body.dump({ limit: 1 }).then(() => { + dumped = true + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('request dump big', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.setHeader('content-length', 999999999) + while (res.write('asd')) { + // Do nothing... + } + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + let dumped = false + client.on('disconnect', () => { + t.strictEqual(dumped, true) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.on('data', () => t.fail()) + body.dump().then(() => { + dumped = true + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('request dump', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.shouldKeepAlive = false + res.setHeader('content-length', 5) + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + let dumped = false + client.on('disconnect', () => { + t.strictEqual(dumped, true) + }) + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + body.dump().then(() => { + dumped = true + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('request dump with abort signal', async (t) => { + t = tspl(t, { plan: 2 }) + const server = createServer((req, res) => { + res.write('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'GET' + }, (err, { body }) => { + t.ifError(err) + const ac = new AbortController() + body.dump({ signal: ac.signal }).catch((err) => { + t.strictEqual(err.name, 'AbortError') + server.close() + }) + ac.abort() + }) + }) + + await t.completed +}) + +test('request hwm', async (t) => { + t = tspl(t, { plan: 2 }) + const server = createServer((req, res) => { + res.write('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'GET', + highWaterMark: 1000 + }, (err, { body }) => { + t.ifError(err) + t.deepStrictEqual(body.readableHighWaterMark, 1000) + body.dump() + }) + }) + + await t.completed +}) + +test('request abort before headers', async (t) => { + t = tspl(t, { plan: 6 }) + + const signal = new EE() + const server = createServer((req, res) => { + res.end('hello') + signal.emit('abort') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET', + signal + }, (err) => { + t.ok(err instanceof errors.RequestAbortedError) + t.strictEqual(signal.listenerCount('abort'), 0) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + + client.request({ + path: '/', + method: 'GET', + signal + }, (err) => { + t.ok(err instanceof errors.RequestAbortedError) + t.strictEqual(signal.listenerCount('abort'), 0) + }) + t.strictEqual(signal.listenerCount('abort'), 2) + }) + }) + + await t.completed +}) + +test('request body destroyed on invalid callback', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const body = new Readable({ + read () { } + }) + try { + client.request({ + path: '/', + method: 'GET', + body + }, null) + } catch (err) { + t.strictEqual(body.destroyed, true) + } + }) + + await t.completed +}) + +test('trailers', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.writeHead(200, { Trailer: 'Content-MD5' }) + res.addTrailers({ 'Content-MD5': 'test' }) + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const { body, trailers } = await client.request({ + path: '/', + method: 'GET' + }) + + body + .on('data', () => t.fail()) + .on('end', () => { + t.deepStrictEqual(trailers, { 'content-md5': 'test' }) + }) + }) + + await t.completed +}) + +test('destroy socket abruptly', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + + // Unfortunately calling destroy synchronously might get us flaky results, + // therefore we delay it to the next event loop run. + setImmediate(socket.destroy.bind(socket)) + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const { statusCode, body } = await client.request({ + path: '/', + method: 'GET' + }) + + t.strictEqual(statusCode, 200) + + body.setEncoding('utf8') + + let actual = '' + + for await (const chunk of body) { + actual += chunk + } + + t.strictEqual(actual, 'the body') +}) + +test('destroy socket abruptly with keep-alive', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: keep-alive', + 'Content-Length: 42', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + + // Unfortunately calling destroy synchronously might get us flaky results, + // therefore we delay it to the next event loop run. + setImmediate(socket.destroy.bind(socket)) + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const { statusCode, body } = await client.request({ + path: '/', + method: 'GET' + }) + + t.strictEqual(statusCode, 200) + + body.setEncoding('utf8') + + try { + /* eslint-disable */ + for await (const _ of body) { + // empty on purpose + } + /* eslint-enable */ + t.fail('no error') + } catch (err) { + t.ok(true, 'error happened') + } +}) + +test('request json', async (t) => { + t = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.deepStrictEqual(obj, await body.json()) + }) + + await t.completed +}) + +test('request long multibyte json', async (t) => { + t = tspl(t, { plan: 1 }) + + const obj = { asd: 'あ'.repeat(100000) } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.deepStrictEqual(obj, await body.json()) + }) + + await t.completed +}) + +test('request text', async (t) => { + t = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.strictEqual(JSON.stringify(obj), await body.text()) + }) + + await t.completed +}) + +describe('headers', () => { + describe('invalid headers', () => { + test('invalid header value - array with string with invalid character', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:8080') + after(() => client.destroy()) + + t.rejects(client.request({ + path: '/', + method: 'GET', + headers: { name: ['test\0'] } + }), new InvalidArgumentError('invalid name header')) + + await t.completed + }) + test('invalid header value - array with POJO', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:8080') + after(() => client.destroy()) + + t.rejects(client.request({ + path: '/', + method: 'GET', + headers: { name: [{}] } + }), new InvalidArgumentError('invalid name header')) + + await t.completed + }) + + test('invalid header value - string with invalid character', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:8080') + after(() => client.destroy()) + + t.rejects(client.request({ + path: '/', + method: 'GET', + headers: { name: 'test\0' } + }), new InvalidArgumentError('invalid name header')) + + await t.completed + }) + + test('invalid header value - object', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:8080') + after(() => client.destroy()) + + t.rejects(client.request({ + path: '/', + method: 'GET', + headers: { name: new Date() } + }), new InvalidArgumentError('invalid name header')) + + await t.completed + }) + }) + + describe('array', () => { + let serverAddress + const server = createServer((req, res) => { + res.end(JSON.stringify(req.headers)) + }) + + before(async () => { + server.listen(0) + await EE.once(server, 'listening') + serverAddress = `localhost:${server.address().port}` + }) + + after(() => server.close()) + + test('empty host header', async (t) => { + t = tspl(t, { plan: 4 }) + + const client = new Client(`http://${serverAddress}`) + after(() => client.destroy()) + + const testCase = async (expected, actual) => { + const { body } = await client.request({ + path: '/', + method: 'GET', + headers: expected + }) + + const result = await body.json() + t.deepStrictEqual(result, { ...result, ...actual }) + } + + await testCase({ key: [null] }, { key: '' }) + await testCase({ key: ['test'] }, { key: 'test' }) + await testCase({ key: ['test', 'true'] }, { key: 'test, true' }) + await testCase({ key: ['test', true] }, { key: 'test, true' }) + + await t.completed + }) + }) + + describe('host', () => { + let serverAddress + const server = createServer((req, res) => { + res.end(req.headers.host) + }) + + before(async () => { + server.listen(0) + await EE.once(server, 'listening') + serverAddress = `localhost:${server.address().port}` + }) + + after(() => server.close()) + + test('invalid host header', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client(`http://${serverAddress}`) + after(() => client.destroy()) + + t.rejects(client.request({ + path: '/', + method: 'GET', + headers: { + host: [ + 'www.example.com' + ] + } + }), new InvalidArgumentError('invalid host header')) + + await t.completed + }) + + test('empty host header', async (t) => { + t = tspl(t, { plan: 3 }) + + const client = new Client(`http://${serverAddress}`) + after(() => client.destroy()) + + const getWithHost = async (host, wanted) => { + const { body } = await client.request({ + path: '/', + method: 'GET', + headers: { host } + }) + t.strictEqual(await body.text(), wanted) + } + + await getWithHost('test', 'test') + await getWithHost(undefined, serverAddress) + await getWithHost('', '') + + await t.completed + }) + }) +}) + +test('request long multibyte text', async (t) => { + t = tspl(t, { plan: 1 }) + + const obj = { asd: 'あ'.repeat(100000) } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + t.strictEqual(JSON.stringify(obj), await body.text()) + }) + + await t.completed +}) + +test('request blob', async (t) => { + t = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + + const blob = await body.blob() + t.deepStrictEqual(obj, JSON.parse(await blob.text())) + t.strictEqual(blob.type, 'application/json') + }) + + await t.completed +}) + +test('request arrayBuffer', async (t) => { + t = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const ab = await body.arrayBuffer() + + t.deepStrictEqual(Buffer.from(JSON.stringify(obj)), Buffer.from(ab)) + t.ok(ab instanceof ArrayBuffer) + }) + + await t.completed +}) + +test('request bytes', async (t) => { + t = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const bytes = await body.bytes() + + t.deepStrictEqual(new TextEncoder().encode(JSON.stringify(obj)), bytes) + t.ok(bytes instanceof Uint8Array) + }) + + await t.completed +}) + +test('request body', async (t) => { + t = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + + let x = '' + for await (const chunk of body.body) { + x += Buffer.from(chunk) + } + t.strictEqual(JSON.stringify(obj), x) + }) + + await t.completed +}) + +test('request post body no missing data', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'asd') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET', + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }), + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body no extra data handler', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'asd') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const reqBody = new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + process.nextTick(() => { + t.strictEqual(reqBody.listenerCount('data'), 0) + }) + const { body } = await client.request({ + path: '/', + method: 'GET', + body: reqBody, + maxRedirections: 0 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request with onInfo callback', async (t) => { + t = tspl(t, { plan: 3 }) + const infos = [] + const server = createServer((req, res) => { + res.writeProcessing() + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ foo: 'bar' })) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + await client.request({ + path: '/', + method: 'GET', + onInfo: (x) => { infos.push(x) } + }) + t.strictEqual(infos.length, 1) + t.strictEqual(infos[0].statusCode, 102) + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request with onInfo callback but socket is destroyed before end of response', async (t) => { + t = tspl(t, { plan: 5 }) + const infos = [] + let response + const server = createServer((req, res) => { + response = res + res.writeProcessing() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + try { + await client.request({ + path: '/', + method: 'GET', + onInfo: (x) => { + infos.push(x) + response.destroy() + } + }) + t.fail() + } catch (e) { + t.ok(e) + t.strictEqual(e.message, 'other side closed') + } + t.strictEqual(infos.length, 1) + t.strictEqual(infos[0].statusCode, 102) + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request onInfo callback headers parsing', async (t) => { + t = tspl(t, { plan: 4 }) + const infos = [] + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 103 Early Hints', + 'Link: ; rel=preload; as=style', + '', + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const { body } = await client.request({ + path: '/', + method: 'GET', + onInfo: (x) => { infos.push(x) } + }) + await body.dump() + t.strictEqual(infos.length, 1) + t.strictEqual(infos[0].statusCode, 103) + t.deepStrictEqual(infos[0].headers, { link: '; rel=preload; as=style' }) + t.ok(true, 'pass') +}) + +test('request raw responseHeaders', async (t) => { + t = tspl(t, { plan: 4 }) + const infos = [] + + const server = net.createServer((socket) => { + const lines = [ + 'HTTP/1.1 103 Early Hints', + 'Link: ; rel=preload; as=style', + '', + 'HTTP/1.1 200 OK', + 'Date: Sat, 09 Oct 2010 14:28:02 GMT', + 'Connection: close', + '', + 'the body' + ] + socket.end(lines.join('\r\n')) + }) + after(() => server.close()) + + await promisify(server.listen.bind(server))(0) + + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const { body, headers } = await client.request({ + path: '/', + method: 'GET', + responseHeaders: 'raw', + onInfo: (x) => { infos.push(x) } + }) + await body.dump() + t.strictEqual(infos.length, 1) + t.deepStrictEqual(infos[0].headers, ['Link', '; rel=preload; as=style']) + t.deepStrictEqual(headers, ['Date', 'Sat, 09 Oct 2010 14:28:02 GMT', 'Connection', 'close']) + t.ok(true, 'pass') +}) + +test('request formData', async (t) => { + t = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + + try { + await body.formData() + t.fail('should throw NotSupportedError') + } catch (error) { + t.ok(error instanceof NotSupportedError) + } + }) + + await t.completed +}) + +test('request text2', async (t) => { + t = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + const p = body.text() + let ret = '' + body.on('data', chunk => { + ret += chunk + }).on('end', () => { + t.strictEqual(JSON.stringify(obj), ret) + }) + t.strictEqual(JSON.stringify(obj), await p) + }) + + await t.completed +}) + +test('request with FormData body', async (t) => { + const { FormData } = require('../') + const { Blob } = require('node:buffer') + + const fd = new FormData() + fd.set('key', 'value') + fd.set('file', new Blob(['Hello, world!']), 'hello_world.txt') + + const server = createServer(async (req, res) => { + const contentType = req.headers['content-type'] + // ensure we received a multipart/form-data header + t.ok(/^multipart\/form-data; boundary=-+formdata-undici-0\d+$/.test(contentType)) + + const chunks = [] + + for await (const chunk of req) { + chunks.push(chunk) + } + + const { fileMap, fields } = await parseFormDataString( + Buffer.concat(chunks), + contentType + ) + + t.deepStrictEqual(fields[0], { key: 'key', value: 'value' }) + t.ok(fileMap.has('file')) + t.strictEqual(fileMap.get('file').data.toString(), 'Hello, world!') + t.deepStrictEqual(fileMap.get('file').info, { + filename: 'hello_world.txt', + encoding: '7bit', + mimeType: 'application/octet-stream' + }) + + return res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + await client.request({ + path: '/', + method: 'POST', + body: fd + }) + + t.end() + }) + + await t.completed +}) + +test('request post body Buffer from string', async (t) => { + t = tspl(t, { plan: 2 }) + const requestBody = Buffer.from('abcdefghijklmnopqrstuvwxyz') + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'abcdefghijklmnopqrstuvwxyz') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body Buffer from buffer', async (t) => { + t = tspl(t, { plan: 2 }) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = Buffer.from(fullBuffer.buffer, 8, 16) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'ijklmnopqrstuvwx') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body Uint8Array', async (t) => { + t = tspl(t, { plan: 2 }) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new Uint8Array(fullBuffer.buffer, 8, 16) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'ijklmnopqrstuvwx') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body Uint32Array', async (t) => { + t = tspl(t, { plan: 2 }) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new Uint32Array(fullBuffer.buffer, 8, 4) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'ijklmnopqrstuvwx') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body Float64Array', async (t) => { + t = tspl(t, { plan: 2 }) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new Float64Array(fullBuffer.buffer, 8, 2) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'ijklmnopqrstuvwx') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body BigUint64Array', async (t) => { + t = tspl(t, { plan: 2 }) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new BigUint64Array(fullBuffer.buffer, 8, 2) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'ijklmnopqrstuvwx') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request post body DataView', async (t) => { + t = tspl(t, { plan: 2 }) + const fullBuffer = new TextEncoder().encode('abcdefghijklmnopqrstuvwxyz') + const requestBody = new DataView(fullBuffer.buffer, 8, 16) + + const server = createServer(async (req, res) => { + let ret = '' + for await (const chunk of req) { + ret += chunk + } + t.strictEqual(ret, 'ijklmnopqrstuvwx') + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + const { body } = await client.request({ + path: '/', + method: 'POST', + body: requestBody, + maxRedirections: 2 + }) + await body.text() + t.ok(true, 'pass') + }) + + await t.completed +}) + +test('request multibyte json with setEncoding', async (t) => { + t = tspl(t, { plan: 1 }) + + const asd = Buffer.from('あいうえお') + const data = JSON.stringify({ asd }) + const server = createServer((req, res) => { + res.write(data.slice(0, 1)) + setTimeout(() => { + res.write(data.slice(1)) + res.end() + }, 100) + }) + after(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('utf8') + t.deepStrictEqual(JSON.parse(data), await body.json()) + }) + + await t.completed +}) + +test('request multibyte text with setEncoding', async (t) => { + t = tspl(t, { plan: 1 }) + + const data = Buffer.from('あいうえお') + const server = createServer((req, res) => { + res.write(data.slice(0, 1)) + setTimeout(() => { + res.write(data.slice(1)) + res.end() + }, 100) + }) + after(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('utf8') + t.deepStrictEqual(data.toString('utf8'), await body.text()) + }) + + await t.completed +}) + +test('request multibyte text with setEncoding', async (t) => { + t = tspl(t, { plan: 1 }) + + const data = Buffer.from('あいうえお') + const server = createServer((req, res) => { + res.write(data.slice(0, 1)) + setTimeout(() => { + res.write(data.slice(1)) + res.end() + }, 100) + }) + after(server.close.bind(server)) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const { body } = await client.request({ + path: '/', + method: 'GET' + }) + body.setEncoding('hex') + t.deepStrictEqual(data.toString('hex'), await body.text()) + }) + + await t.completed +}) + +test('#3736 - Aborted Response (without consuming body)', async (t) => { + const plan = tspl(t, { plan: 1 }) + + const controller = new AbortController() + const server = createServer((req, res) => { + setTimeout(() => { + res.writeHead(200, 'ok', { + 'content-type': 'text/plain' + }) + res.write('hello from server') + res.end() + }, 100) + }) + + server.listen(0) + + await EE.once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + + after(server.close.bind(server)) + after(client.destroy.bind(client)) + + const { signal } = controller + const promise = client.request({ + path: '/', + method: 'GET', + signal + }) + + controller.abort() + + await plan.rejects(promise, { message: 'This operation was aborted' }) + + await plan.completed +}) diff --git a/test/client-stream.js b/test/client-stream.js new file mode 100644 index 0000000..ccdbedf --- /dev/null +++ b/test/client-stream.js @@ -0,0 +1,834 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const { createServer } = require('node:http') +const { PassThrough, Writable, Readable } = require('node:stream') +const EE = require('node:events') + +test('stream get', async (t) => { + t = tspl(t, { plan: 9 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const signal = new EE() + client.stream({ + signal, + path: '/', + method: 'GET', + opaque: new PassThrough() + }, ({ statusCode, headers, opaque: pt }) => { + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + return pt + }, (err) => { + t.strictEqual(signal.listenerCount('abort'), 0) + t.ifError(err) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + }) + + await t.completed +}) + +test('stream promise get', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + await client.stream({ + path: '/', + method: 'GET', + opaque: new PassThrough() + }, ({ statusCode, headers, opaque: pt }) => { + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + pt.on('data', (buf) => { + bufs.push(buf) + }) + pt.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + return pt + }) + }) + + await t.completed +}) + +test('stream GET destroy res', async (t) => { + t = tspl(t, { plan: 14 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.stream({ + path: '/', + method: 'GET' + }, ({ statusCode, headers }) => { + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + + const pt = new PassThrough() + .on('error', (err) => { + t.ok(err) + }) + .on('data', () => { + pt.destroy(new Error('kaboom')) + }) + + return pt + }, (err) => { + t.ok(err) + }) + + client.stream({ + path: '/', + method: 'GET' + }, ({ statusCode, headers }) => { + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + + let ret = '' + const pt = new PassThrough() + pt.on('data', chunk => { + ret += chunk + }).on('end', () => { + t.strictEqual(ret, 'hello') + }) + + return pt + }, (err) => { + t.ifError(err) + }) + }) + + await t.completed +}) + +test('stream GET remote destroy', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + res.write('asd') + setImmediate(() => { + res.destroy() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough() + pt.on('error', (err) => { + t.ok(err) + }) + return pt + }, (err) => { + t.ok(err) + }) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough() + pt.on('error', (err) => { + t.ok(err) + }) + return pt + }).catch((err) => { + t.ok(err) + }) + }) + + await t.completed +}) + +test('stream response resume back pressure and non standard error', async (t) => { + t = tspl(t, { plan: 5 }) + + const server = createServer((req, res) => { + res.write(Buffer.alloc(1e3)) + setImmediate(() => { + res.write(Buffer.alloc(1e7)) + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const pt = new PassThrough() + client.stream({ + path: '/', + method: 'GET' + }, () => { + pt.on('data', () => { + pt.emit('error', new Error('kaboom')) + }).once('error', (err) => { + t.strictEqual(err.message, 'kaboom') + }) + return pt + }, (err) => { + t.ok(err) + t.strictEqual(pt.destroyed, true) + }) + + client.once('disconnect', (err) => { + t.ok(err) + }) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough() + pt.resume() + return pt + }, (err) => { + t.ifError(err) + }) + }) + + await t.completed +}) + +test('stream waits only for writable side', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(1e3)) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const pt = new PassThrough({ autoDestroy: false }) + client.stream({ + path: '/', + method: 'GET' + }, () => pt, (err) => { + t.ifError(err) + t.strictEqual(pt.destroyed, false) + }) + }) + + await t.completed +}) + +test('stream args validation', async (t) => { + t = tspl(t, { plan: 3 }) + + const client = new Client('http://localhost:5000') + client.stream({ + path: '/', + method: 'GET' + }, null, (err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + client.stream(null, null, (err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + try { + client.stream(null, null, 'asd') + } catch (err) { + t.ok(err instanceof errors.InvalidArgumentError) + } +}) + +test('stream args validation promise', async (t) => { + t = tspl(t, { plan: 2 }) + + const client = new Client('http://localhost:5000') + client.stream({ + path: '/', + method: 'GET' + }, null).catch((err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + client.stream(null, null).catch((err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + await t.completed +}) + +test('stream destroy if not readable', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + + const pt = new PassThrough() + pt.readable = false + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + return pt + }, (err) => { + t.ifError(err) + t.strictEqual(pt.destroyed, true) + }) + }) + + await t.completed +}) + +test('stream server side destroy', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.destroy() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + t.fail() + }, (err) => { + t.ok(err instanceof errors.SocketError) + }) + }) + + await t.completed +}) + +test('stream invalid return', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.write('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + return {} + }, (err) => { + t.ok(err instanceof errors.InvalidReturnValueError) + }) + }) + + await t.completed +}) + +test('stream body without destroy', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + const pt = new PassThrough({ autoDestroy: false }) + pt.destroy = null + pt.resume() + return pt + }, (err) => { + t.ifError(err) + }) + }) + + await t.completed +}) + +test('stream factory abort', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const signal = new EE() + client.stream({ + path: '/', + method: 'GET', + signal + }, () => { + signal.emit('abort') + return new PassThrough() + }, (err) => { + t.strictEqual(signal.listenerCount('abort'), 0) + t.ok(err instanceof errors.RequestAbortedError) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + }) + + await t.completed +}) + +test('stream factory throw', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'GET' + }, () => { + throw new Error('asd') + }, (err) => { + t.strictEqual(err.message, 'asd') + }) + client.stream({ + path: '/', + method: 'GET' + }, () => { + throw new Error('asd') + }, (err) => { + t.strictEqual(err.message, 'asd') + }) + client.stream({ + path: '/', + method: 'GET' + }, () => { + return new PassThrough() + }, (err) => { + t.ifError(err) + }) + }) + + await t.completed +}) + +test('stream CONNECT throw', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + client.stream({ + path: '/', + method: 'CONNECT' + }, () => { + }, (err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + }) + + await t.completed +}) + +test('stream abort after complete', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const pt = new PassThrough() + const signal = new EE() + client.stream({ + path: '/', + method: 'GET', + signal + }, () => { + return pt + }, (err) => { + t.ifError(err) + signal.emit('abort') + }) + }) + + await t.completed +}) + +test('stream abort before dispatch', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const pt = new PassThrough() + const signal = new EE() + client.stream({ + path: '/', + method: 'GET', + signal + }, () => { + return pt + }, (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + signal.emit('abort') + }) + + await t.completed +}) + +test('trailers', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.writeHead(200, { Trailer: 'Content-MD5' }) + res.addTrailers({ 'Content-MD5': 'test' }) + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.stream({ + path: '/', + method: 'GET' + }, () => new PassThrough(), (err, data) => { + t.ifError(err) + t.deepStrictEqual(data.trailers, { 'content-md5': 'test' }) + }) + }) + + await t.completed +}) + +test('stream ignore 1xx', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let buf = '' + client.stream({ + path: '/', + method: 'GET' + }, () => new Writable({ + write (chunk, encoding, callback) { + buf += chunk + callback() + } + }), (err, data) => { + t.ifError(err) + t.strictEqual(buf, 'hello') + }) + }) + + await t.completed +}) + +test('stream ignore 1xx and use onInfo', async (t) => { + t = tspl(t, { plan: 4 }) + + const infos = [] + const server = createServer((req, res) => { + res.writeProcessing() + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let buf = '' + client.stream({ + path: '/', + method: 'GET', + onInfo: (x) => { + infos.push(x) + } + }, () => new Writable({ + write (chunk, encoding, callback) { + buf += chunk + callback() + } + }), (err, data) => { + t.ifError(err) + t.strictEqual(buf, 'hello') + t.strictEqual(infos.length, 1) + t.strictEqual(infos[0].statusCode, 102) + }) + }) + + await t.completed +}) + +test('stream backpressure', async (t) => { + t = tspl(t, { plan: 2 }) + + const expected = Buffer.alloc(1e6).toString() + + const server = createServer((req, res) => { + res.writeProcessing() + res.end(expected) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + let buf = '' + client.stream({ + path: '/', + method: 'GET' + }, () => new Writable({ + highWaterMark: 1, + write (chunk, encoding, callback) { + buf += chunk + process.nextTick(callback) + } + }), (err, data) => { + t.ifError(err) + t.strictEqual(buf, expected) + }) + }) + + await t.completed +}) + +test('stream body destroyed on invalid callback', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(client.destroy.bind(client)) + + const body = new Readable({ + read () { } + }) + try { + client.stream({ + path: '/', + method: 'GET', + body + }, () => { }, null) + } catch (err) { + t.strictEqual(body.destroyed, true) + } + }) + + await t.completed +}) + +test('stream needDrain', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(4096)) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => { + client.destroy() + }) + + const dst = new PassThrough() + dst.pause() + + if (dst.writableNeedDrain === undefined) { + Object.defineProperty(dst, 'writableNeedDrain', { + get () { + return this._writableState.needDrain + } + }) + } + + while (dst.write(Buffer.alloc(4096))) { + // Do nothing. + } + + const orgWrite = dst.write + dst.write = () => t.fail() + const p = client.stream({ + path: '/', + method: 'GET' + }, () => { + t.strictEqual(dst._writableState.needDrain, true) + t.strictEqual(dst.writableNeedDrain, true) + + setImmediate(() => { + dst.write = (...args) => { + orgWrite.call(dst, ...args) + } + dst.resume() + }) + + return dst + }) + + p.then(() => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) + +test('stream legacy needDrain', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.end(Buffer.alloc(4096)) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => { + client.destroy() + }) + + const dst = new PassThrough() + dst.pause() + + if (dst.writableNeedDrain !== undefined) { + Object.defineProperty(dst, 'writableNeedDrain', { + get () { + } + }) + } + + while (dst.write(Buffer.alloc(4096))) { + // Do nothing + } + + const orgWrite = dst.write + dst.write = () => t.fail() + const p = client.stream({ + path: '/', + method: 'GET' + }, () => { + t.strictEqual(dst._writableState.needDrain, true) + t.strictEqual(dst.writableNeedDrain, undefined) + + setImmediate(() => { + dst.write = (...args) => { + orgWrite.call(dst, ...args) + } + dst.resume() + }) + + return dst + }) + + p.then(() => { + t.ok(true, 'pass') + }) + }) + await t.completed +}) diff --git a/test/client-timeout.js b/test/client-timeout.js new file mode 100644 index 0000000..c4ff9c2 --- /dev/null +++ b/test/client-timeout.js @@ -0,0 +1,206 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const { createServer } = require('node:http') +const { Readable } = require('node:stream') +const FakeTimers = require('@sinonjs/fake-timers') +const timers = require('../lib/util/timers') + +test('refresh timeout on pause', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.flushHeaders() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 500 + }) + after(() => client.destroy()) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers, resume) { + setTimeout(() => { + resume() + }, 1000) + return false + }, + onData () { + + }, + onComplete () { + + }, + onError (err) { + t.ok(err instanceof errors.BodyTimeoutError) + } + }) + }) + + await t.completed +}) + +test('start headers timeout after request body', async (t) => { + t = tspl(t, { plan: 2 }) + + const clock = FakeTimers.install({ shouldClearNativeTimers: true }) + after(() => clock.uninstall()) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + after(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0, + headersTimeout: 100 + }) + after(() => client.destroy()) + + const body = new Readable({ read () {} }) + client.dispatch({ + path: '/', + body, + method: 'GET' + }, { + onConnect () { + process.nextTick(() => { + clock.tick(200) + }) + queueMicrotask(() => { + body.push(null) + body.on('end', () => { + clock.tick(200) + }) + }) + }, + onHeaders (statusCode, headers, resume) { + }, + onData () { + + }, + onComplete () { + + }, + onError (err) { + t.equal(body.readableEnded, true) + t.ok(err instanceof errors.HeadersTimeoutError) + } + }) + }) + + await t.completed +}) + +test('start headers timeout after async iterator request body', async (t) => { + t = tspl(t, { plan: 1 }) + + const clock = FakeTimers.install({ shouldClearNativeTimers: true }) + after(() => clock.uninstall()) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + after(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0, + headersTimeout: 100 + }) + after(() => client.destroy()) + let res + const body = (async function * () { + await new Promise((resolve) => { res = resolve }) + process.nextTick(() => { + clock.tick(200) + }) + })() + client.dispatch({ + path: '/', + body, + method: 'GET' + }, { + onConnect () { + process.nextTick(() => { + clock.tick(200) + }) + queueMicrotask(() => { + res() + }) + }, + onHeaders (statusCode, headers, resume) { + }, + onData () { + + }, + onComplete () { + + }, + onError (err) { + t.ok(err instanceof errors.HeadersTimeoutError) + } + }) + }) + + await t.completed +}) + +test('parser resume with no body timeout', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.destroy()) + + client.dispatch({ + path: '/', + method: 'GET' + }, { + onConnect () { + }, + onHeaders (statusCode, headers, resume) { + setTimeout(resume, 2000) + return false + }, + onData () { + + }, + onComplete () { + t.ok(true, 'pass') + }, + onError (err) { + t.ifError(err) + } + }) + }) + + await t.completed +}) diff --git a/test/client-unref.js b/test/client-unref.js new file mode 100644 index 0000000..49df424 --- /dev/null +++ b/test/client-unref.js @@ -0,0 +1,53 @@ +'use strict' + +const { Worker, isMainThread, workerData } = require('node:worker_threads') + +if (isMainThread) { + const { tspl } = require('@matteo.collina/tspl') + const { test, after } = require('node:test') + const { once } = require('node:events') + const { createServer } = require('node:http') + + test('client automatically closes itself when idle', async t => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end() + }) + after(server.close.bind(server)) + server.keepAliveTimeout = 9999 + + server.listen(0) + + await once(server, 'listening') + const url = `http://localhost:${server.address().port}` + const worker = new Worker(__filename, { workerData: { url } }) + worker.on('exit', code => { + t.strictEqual(code, 0) + }) + await t.completed + }) + + test('client automatically closes itself if the server is not there', async t => { + t = tspl(t, { plan: 1 }) + + const url = 'http://localhost:4242' // hopefully empty port + const worker = new Worker(__filename, { workerData: { url } }) + worker.on('exit', code => { + t.strictEqual(code, 0) + }) + + await t.completed + }) +} else { + const { Client } = require('..') + + const client = new Client(workerData.url) + client.request({ path: '/', method: 'GET' }, () => { + // We do not care about Errors + + setTimeout(() => { + throw new Error() + }, 1e3).unref() + }) +} diff --git a/test/client-upgrade.js b/test/client-upgrade.js new file mode 100644 index 0000000..7d76f8e --- /dev/null +++ b/test/client-upgrade.js @@ -0,0 +1,474 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const net = require('node:net') +const http = require('node:http') +const EE = require('node:events') +const { kBusy } = require('../lib/core/symbols') + +test('basic upgrade', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = net.createServer((c) => { + c.on('data', (d) => { + t.ok(/upgrade: websocket/i.test(d)) + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + + c.on('end', () => { + c.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const signal = new EE() + client.upgrade({ + signal, + path: '/', + method: 'GET', + protocol: 'Websocket' + }, (err, data) => { + t.ifError(err) + + t.strictEqual(signal.listenerCount('abort'), 0) + + const { headers, socket } = data + + let recvData = '' + data.socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.strictEqual(recvData.toString(), 'Body') + }) + + t.deepStrictEqual(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) + t.strictEqual(signal.listenerCount('abort'), 1) + }) + + await t.completed +}) + +test('basic upgrade promise', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + + c.on('end', () => { + c.end() + }) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const { headers, socket } = await client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }) + + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.strictEqual(recvData.toString(), 'Body') + }) + + t.deepStrictEqual(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) + + await t.completed +}) + +test('upgrade error', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('\r\n') + c.write('Body') + }) + c.on('error', () => { + // Whether we get an error, end or close is undefined. + // Ignore error. + }) + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + try { + await client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }) + } catch (err) { + t.ok(err) + } + }) + + await t.completed +}) + +test('upgrade invalid opts', async (t) => { + t = tspl(t, { plan: 6 }) + + const client = new Client('http://localhost:5432') + + client.upgrade(null, err => { + t.ok(err instanceof errors.InvalidArgumentError) + t.strictEqual(err.message, 'invalid opts') + }) + + try { + client.upgrade(null, null) + t.fail() + } catch (err) { + t.ok(err instanceof errors.InvalidArgumentError) + t.strictEqual(err.message, 'invalid opts') + } + + try { + client.upgrade({ path: '/' }, null) + t.fail() + } catch (err) { + t.ok(err instanceof errors.InvalidArgumentError) + t.strictEqual(err.message, 'invalid callback') + } +}) + +test('basic upgrade2', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = http.createServer() + server.on('upgrade', (req, c, head) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + c.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }, (err, data) => { + t.ifError(err) + + const { headers, socket } = data + + let recvData = '' + data.socket.on('data', (d) => { + recvData += d + }) + + socket.on('close', () => { + t.strictEqual(recvData.toString(), 'Body') + }) + + t.deepStrictEqual(headers, { + hello: 'world', + connection: 'upgrade', + upgrade: 'websocket' + }) + socket.end() + }) + }) + + await t.completed +}) + +test('upgrade wait for empty pipeline', async (t) => { + t = tspl(t, { plan: 7 }) + + let canConnect = false + const server = http.createServer((req, res) => { + res.end() + canConnect = true + }) + server.on('upgrade', (req, c, firstBodyChunk) => { + t.strictEqual(canConnect, true) + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + c.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET', + blocking: false + }, (err) => { + t.ifError(err) + }) + client.once('connect', () => { + process.nextTick(() => { + t.strictEqual(client[kBusy], false) + + client.upgrade({ + path: '/' + }, (err, { socket }) => { + t.ifError(err) + let recvData = '' + socket.on('data', (d) => { + recvData += d + }) + + socket.on('end', () => { + t.strictEqual(recvData.toString(), 'Body') + }) + + socket.write('Body') + socket.end() + }) + t.strictEqual(client[kBusy], true) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.ifError(err) + }) + }) + }) + }) + + await t.completed +}) + +test('upgrade aborted', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = http.createServer((req, res) => { + t.fail() + }) + server.on('upgrade', (req, c, firstBodyChunk) => { + t.fail() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 3 + }) + after(() => client.destroy()) + + const signal = new EE() + client.upgrade({ + path: '/', + signal, + opaque: 'asd' + }, (err, { opaque }) => { + t.strictEqual(opaque, 'asd') + t.ok(err instanceof errors.RequestAbortedError) + t.strictEqual(signal.listenerCount('abort'), 0) + }) + t.strictEqual(client[kBusy], true) + t.strictEqual(signal.listenerCount('abort'), 1) + signal.emit('abort') + + client.close(() => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) + +test('basic aborted after res', async (t) => { + t = tspl(t, { plan: 1 }) + + const signal = new EE() + const server = http.createServer() + server.on('upgrade', (req, c, head) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + c.end() + c.on('error', () => { + + }) + signal.emit('abort') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket', + signal + }, (err) => { + t.ok(err instanceof errors.RequestAbortedError) + }) + }) + + await t.completed +}) + +test('basic upgrade error', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = net.createServer((c) => { + c.on('data', (d) => { + c.write('HTTP/1.1 101\r\n') + c.write('hello: world\r\n') + c.write('connection: upgrade\r\n') + c.write('upgrade: websocket\r\n') + c.write('\r\n') + c.write('Body') + }) + c.on('error', () => { + + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const _err = new Error() + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket' + }, (err, data) => { + t.ifError(err) + data.socket.on('error', (err) => { + t.strictEqual(err, _err) + }) + throw _err + }) + }) + + await t.completed +}) + +test('upgrade disconnect', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = net.createServer(connection => { + connection.destroy() + }) + + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.on('disconnect', (origin, [self], error) => { + t.strictEqual(client, self) + t.ok(error instanceof Error) + }) + + client + .upgrade({ path: '/', method: 'GET' }) + .then(() => { + t.fail() + }) + .catch(error => { + t.ok(error instanceof Error) + }) + }) + + await t.completed +}) + +test('upgrade invalid signal', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = net.createServer(() => { + t.fail() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.on('disconnect', () => { + t.fail() + }) + + client.upgrade({ + path: '/', + method: 'GET', + protocol: 'Websocket', + signal: 'error', + opaque: 'asd' + }, (err, { opaque }) => { + t.strictEqual(opaque, 'asd') + t.ok(err instanceof errors.InvalidArgumentError) + }) + }) + + await t.completed +}) diff --git a/test/client-wasm.js b/test/client-wasm.js new file mode 100644 index 0000000..065729e --- /dev/null +++ b/test/client-wasm.js @@ -0,0 +1,365 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { describe, test } = require('node:test') + + ;[ + ['generic', require('../lib/llhttp/llhttp-wasm.js')], + ['simd', require('../lib/llhttp/llhttp_simd-wasm.js')] +].forEach(([name, llhttp]) => { + describe(name, () => { + test('can compile the wasm code', async () => { + await WebAssembly.compile(llhttp) + }) + + test('can instantiate the wasm code', async () => { + const mod = await WebAssembly.compile(llhttp) + await WebAssembly.instantiate(mod, { + env: { + wasm_on_url: () => { }, + wasm_on_status: () => { }, + wasm_on_message_begin: () => { }, + wasm_on_header_field: () => { }, + wasm_on_header_value: () => { }, + wasm_on_headers_complete: () => { }, + wasm_on_body: () => { }, + wasm_on_message_complete: () => { } + } + }) + }) + + describe('exports', async () => { + const mod = await WebAssembly.compile(llhttp) + const instance = await WebAssembly.instantiate(mod, { + env: { + wasm_on_url: () => { }, + wasm_on_status: () => { }, + wasm_on_message_begin: () => { }, + wasm_on_header_field: () => { }, + wasm_on_header_value: () => { }, + wasm_on_headers_complete: () => { }, + wasm_on_body: () => { }, + wasm_on_message_complete: () => { } + } + }) + + test('has the right amount of exports', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(instance.exports, 'exports are present') + t.deepStrictEqual(Object.keys(instance.exports), [ + 'memory', + '_initialize', + '__indirect_function_table', + 'llhttp_init', + 'llhttp_should_keep_alive', + 'llhttp_alloc', + 'malloc', + 'llhttp_free', + 'free', + 'llhttp_get_type', + 'llhttp_get_http_major', + 'llhttp_get_http_minor', + 'llhttp_get_method', + 'llhttp_get_status_code', + 'llhttp_get_upgrade', + 'llhttp_reset', + 'llhttp_execute', + 'llhttp_settings_init', + 'llhttp_finish', + 'llhttp_pause', + 'llhttp_resume', + 'llhttp_resume_after_upgrade', + 'llhttp_get_errno', + 'llhttp_get_error_reason', + 'llhttp_set_error_reason', + 'llhttp_get_error_pos', + 'llhttp_errno_name', + 'llhttp_method_name', + 'llhttp_status_name', + 'llhttp_set_lenient_headers', + 'llhttp_set_lenient_chunked_length', + 'llhttp_set_lenient_keep_alive', + 'llhttp_set_lenient_transfer_encoding', + 'llhttp_set_lenient_version', + 'llhttp_set_lenient_data_after_close', + 'llhttp_set_lenient_optional_lf_after_cr', + 'llhttp_set_lenient_optional_crlf_after_chunk', + 'llhttp_set_lenient_optional_cr_before_lf', + 'llhttp_set_lenient_spaces_after_chunk_size', + 'llhttp_message_needs_eof' + ]) + await t.completed + }) + + test('instance.exports.memory', async (t) => { + t = tspl(t, { plan: 1 }) + + t.ok(instance.exports.memory instanceof WebAssembly.Memory, 'memory is present') + }) + + // _initialize + test('instance.exports._initialize', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports._initialize === 'function', '_initialize is present') + t.strictEqual(instance.exports._initialize.length, 0, '_initialize has the right number of arguments') + }) + + // __indirect_function_table + test('instance.exports.__indirect_function_table', async (t) => { + t = tspl(t, { plan: 1 }) + + t.ok(instance.exports.__indirect_function_table instanceof WebAssembly.Table, '__indirect_function_table is present') + }) + + // malloc + test('instance.exports.malloc', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.malloc === 'function', 'malloc is present') + t.strictEqual(instance.exports.malloc.length, 1, 'malloc has the right number of arguments') + }) + + // free + test('instance.exports.free', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.free === 'function', 'free is present') + t.strictEqual(instance.exports.free.length, 1, 'free has the right number of arguments') + }) + + // llhttp_init + test('instance.exports.llhttp_init', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_init === 'function', 'llhttp_init is present') + t.strictEqual(instance.exports.llhttp_init.length, 3, 'llhttp_init has the right number of arguments') + }) + + // llhttp_alloc + test('instance.exports.llhttp_alloc', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_alloc === 'function', 'llhttp_alloc is present') + t.strictEqual(instance.exports.llhttp_alloc.length, 1, 'llhttp_alloc has the right number of arguments') + }) + + // llhttp_free + test('instance.exports.llhttp_free', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_free === 'function', 'llhttp_free is present') + t.strictEqual(instance.exports.llhttp_free.length, 1, 'llhttp_free has the right number of arguments') + }) + + // llhttp_get_type + test('instance.exports.llhttp_get_type', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_type === 'function', 'llhttp_get_type is present') + t.strictEqual(instance.exports.llhttp_get_type.length, 1, 'llhttp_get_type has the right number of arguments') + }) + + // llhttp_should_keep_alive + test('instance.exports.llhttp_should_keep_alive', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_should_keep_alive === 'function', 'llhttp_should_keep_alive is present') + t.strictEqual(instance.exports.llhttp_should_keep_alive.length, 1, 'llhttp_should_keep_alive has the right number of arguments') + }) + + // llhttp_get_http_major + test('instance.exports.llhttp_get_http_major', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_http_major === 'function', 'llhttp_get_http_major is present') + t.strictEqual(instance.exports.llhttp_get_http_major.length, 1, 'llhttp_get_http_major has the right number of arguments') + }) + + // llhttp_get_http_minor + test('instance.exports.llhttp_get_http_minor', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_http_minor === 'function', 'llhttp_get_http_minor is present') + t.strictEqual(instance.exports.llhttp_get_http_minor.length, 1, 'llhttp_get_http_minor has the right number of arguments') + }) + + // llhttp_get_method + test('instance.exports.llhttp_get_method', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_method === 'function', 'llhttp_get_method is present') + t.strictEqual(instance.exports.llhttp_get_method.length, 1, 'llhttp_get_method has the right number of arguments') + }) + + // llhttp_get_status_code + test('instance.exports.llhttp_get_status_code', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_status_code === 'function', 'llhttp_get_status_code is present') + t.strictEqual(instance.exports.llhttp_get_status_code.length, 1, 'llhttp_get_status_code has the right number of arguments') + }) + + // llhttp_get_upgrade + test('instance.exports.llhttp_get_upgrade', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_upgrade === 'function', 'llhttp_get_upgrade is present') + t.strictEqual(instance.exports.llhttp_get_upgrade.length, 1, 'llhttp_get_upgrade has the right number of arguments') + }) + + // llhttp_reset + test('instance.exports.llhttp_reset', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_reset === 'function', 'llhttp_reset is present') + t.strictEqual(instance.exports.llhttp_reset.length, 1, 'llhttp_reset has the right number of arguments') + }) + + // llhttp_execute + test('instance.exports.llhttp_execute', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_execute === 'function', 'llhttp_execute is present') + t.strictEqual(instance.exports.llhttp_execute.length, 3, 'llhttp_execute has the right number of arguments') + }) + + // llhttp_settings_init + test('instance.exports.llhttp_settings_init', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_settings_init === 'function', 'llhttp_settings_init is present') + t.strictEqual(instance.exports.llhttp_settings_init.length, 1, 'llhttp_settings_init has the right number of arguments') + }) + + // llhttp_finish + test('instance.exports.llhttp_finish', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_finish === 'function', 'llhttp_finish is present') + t.strictEqual(instance.exports.llhttp_finish.length, 1, 'llhttp_finish has the right number of arguments') + }) + + // llhttp_pause + test('instance.exports.llhttp_pause', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_pause === 'function', 'llhttp_pause is present') + t.strictEqual(instance.exports.llhttp_pause.length, 1, 'llhttp_pause has the right number of arguments') + }) + + // llhttp_resume + test('instance.exports.llhttp_resume', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_resume === 'function', 'llhttp_resume is present') + t.strictEqual(instance.exports.llhttp_resume.length, 1, 'llhttp_resume has the right number of arguments') + }) + + // llhttp_resume_after_upgrade + test('instance.exports.llhttp_resume_after_upgrade', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_resume_after_upgrade === 'function', 'llhttp_resume_after_upgrade is present') + t.strictEqual(instance.exports.llhttp_resume_after_upgrade.length, 1, 'llhttp_resume_after_upgrade has the right number of arguments') + }) + + // llhttp_get_errno + test('instance.exports.llhttp_get_errno', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_errno === 'function', 'llhttp_get_errno is present') + t.strictEqual(instance.exports.llhttp_get_errno.length, 1, 'llhttp_get_errno has the right number of arguments') + }) + + // llhttp_get_error_reason + test('instance.exports.llhttp_get_error_reason', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_error_reason === 'function', 'llhttp_get_error_reason is present') + t.strictEqual(instance.exports.llhttp_get_error_reason.length, 1, 'llhttp_get_error_reason has the right number of arguments') + }) + + // llhttp_set_error_reason + test('instance.exports.llhttp_set_error_reason', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_set_error_reason === 'function', 'llhttp_set_error_reason is present') + t.strictEqual(instance.exports.llhttp_set_error_reason.length, 2, 'llhttp_set_error_reason has the right number of arguments') + }) + + // llhttp_get_error_pos + test('instance.exports.llhttp_get_error_pos', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_get_error_pos === 'function', 'llhttp_get_error_pos is present') + t.strictEqual(instance.exports.llhttp_get_error_pos.length, 1, 'llhttp_get_error_pos has the right number of arguments') + }) + + // llhttp_errno_name + test('instance.exports.llhttp_errno_name', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_errno_name === 'function', 'llhttp_errno_name is present') + t.strictEqual(instance.exports.llhttp_errno_name.length, 1, 'llhttp_errno_name has the right number of arguments') + }) + + // llhttp_method_name + test('instance.exports.llhttp_method_name', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_method_name === 'function', 'llhttp_method_name is present') + t.strictEqual(instance.exports.llhttp_method_name.length, 1, 'llhttp_method_name has the right number of arguments') + }) + + // llhttp_status_name + test('instance.exports.llhttp_status_name', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_status_name === 'function', 'llhttp_status_name is present') + t.strictEqual(instance.exports.llhttp_status_name.length, 1, 'llhttp_status_name has the right number of arguments') + }) + + // llhttp_set_lenient_headers + test('instance.exports.llhttp_set_lenient_headers', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_set_lenient_headers === 'function', 'llhttp_set_lenient_headers is present') + t.strictEqual(instance.exports.llhttp_set_lenient_headers.length, 2, 'llhttp_set_lenient_headers has the right number of arguments') + }) + + // llhttp_set_lenient_chunked_length + test('instance.exports.llhttp_set_lenient_chunked_length', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_set_lenient_chunked_length === 'function', 'llhttp_set_lenient_chunked_length is present') + t.strictEqual(instance.exports.llhttp_set_lenient_chunked_length.length, 2, 'llhttp_set_lenient_chunked_length has the right number of arguments') + }) + + // llhttp_set_lenient_keep_alive + test('instance.exports.llhttp_set_lenient_keep_alive', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_set_lenient_keep_alive === 'function', 'llhttp_set_lenient_keep_alive is present') + t.strictEqual(instance.exports.llhttp_set_lenient_keep_alive.length, 2, 'llhttp_set_lenient_keep_alive has the right number of arguments') + }) + + // llhttp_set_lenient_transfer_encoding + test('instance.exports.llhttp_set_lenient_transfer_encoding', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_set_lenient_transfer_encoding === 'function', 'llhttp_set_lenient_transfer_encoding is present') + t.strictEqual(instance.exports.llhttp_set_lenient_transfer_encoding.length, 2, 'llhttp_set_lenient_transfer_encoding has the right number of arguments') + }) + + // llhttp_message_needs_eof + test('instance.exports.llhttp_message_needs_eof', async (t) => { + t = tspl(t, { plan: 2 }) + + t.ok(typeof instance.exports.llhttp_message_needs_eof === 'function', 'llhttp_message_needs_eof is present') + t.strictEqual(instance.exports.llhttp_message_needs_eof.length, 1, 'llhttp_message_needs_eof has the right number of arguments') + }) + }) + }) +}) diff --git a/test/client-write-max-listeners.js b/test/client-write-max-listeners.js new file mode 100644 index 0000000..c76ce60 --- /dev/null +++ b/test/client-write-max-listeners.js @@ -0,0 +1,56 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { once } = require('node:events') +const { Client } = require('..') +const { createServer } = require('node:http') +const { Readable } = require('node:stream') + +test('socket close listener does not leak', async (t) => { + t = tspl(t, { plan: 32 }) + + const server = createServer() + + server.on('request', (req, res) => { + res.end('hello') + }) + after(() => server.close()) + + const makeBody = () => { + return new Readable({ + read () { + process.nextTick(() => { + this.push(null) + }) + } + }) + } + + const onRequest = (err, data) => { + t.ifError(err) + data.body.on('end', () => t.ok(true, 'pass')).resume() + } + + function onWarning (warning) { + if (!/ExperimentalWarning/.test(warning)) { + t.fail() + } + } + process.on('warning', onWarning) + after(() => { + process.removeListener('warning', onWarning) + }) + + server.listen(0) + + await once(server, 'listening') + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + for (let n = 0; n < 16; ++n) { + client.request({ path: '/', method: 'GET', body: makeBody() }, onRequest) + } + + await t.completed +}) diff --git a/test/client.js b/test/client.js new file mode 100644 index 0000000..680ff66 --- /dev/null +++ b/test/client.js @@ -0,0 +1,2168 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { readFileSync, createReadStream } = require('node:fs') +const { createServer } = require('node:http') +const { Readable, PassThrough } = require('node:stream') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const { kSocket } = require('../lib/core/symbols') +const { wrapWithAsyncIterable } = require('./utils/async-iterators') +const EE = require('node:events') +const { kUrl, kSize, kConnect, kBusy, kConnected, kRunning } = require('../lib/core/symbols') + +const hasIPv6 = (() => { + const iFaces = require('node:os').networkInterfaces() + const re = process.platform === 'win32' ? /Loopback Pseudo-Interface/ : /lo/ + return Object.keys(iFaces).some( + (name) => re.test(name) && iFaces[name].some(({ family }) => family === 6) + ) +})() + +test('basic get', async (t) => { + t = tspl(t, { plan: 24 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + t.strictEqual(undefined, req.headers.foo) + t.strictEqual('bar', req.headers.bar) + t.strictEqual(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + const reqHeaders = { + foo: undefined, + bar: 'bar' + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + t.strictEqual(client[kUrl].origin, `http://localhost:${server.address().port}`) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, data) => { + t.ifError(err) + const { statusCode, headers, body } = data + t.strictEqual(statusCode, 200) + t.strictEqual(signal.listenerCount('abort'), 1) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('close', () => { + t.strictEqual(signal.listenerCount('abort'), 0) + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic get with custom request.reset=true', async (t) => { + t = tspl(t, { plan: 26 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + t.strictEqual(req.headers.connection, 'close') + t.strictEqual(undefined, req.headers.foo) + t.strictEqual('bar', req.headers.bar) + t.strictEqual(undefined, req.headers['content-length']) + res.setHeader('Content-Type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + const reqHeaders = { + foo: undefined, + bar: 'bar' + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, {}) + after(() => client.close()) + + t.strictEqual(client[kUrl].origin, `http://localhost:${server.address().port}`) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + reset: true, + headers: reqHeaders + }, (err, data) => { + t.ifError(err) + const { statusCode, headers, body } = data + t.strictEqual(statusCode, 200) + t.strictEqual(signal.listenerCount('abort'), 1) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('close', () => { + t.strictEqual(signal.listenerCount('abort'), 0) + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + + client.request({ + path: '/', + reset: true, + method: 'GET', + headers: reqHeaders + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic get with query params', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + const searchParamsObject = buildParams(req.url) + t.deepStrictEqual(searchParamsObject, { + bool: 'true', + foo: '1', + bar: 'bar', + '%60~%3A%24%2C%2B%5B%5D%40%5E*()-': '%60~%3A%24%2C%2B%5B%5D%40%5E*()-', + multi: ['1', '2'], + nullVal: '', + undefinedVal: '' + }) + + res.statusCode = 200 + res.end('hello') + }) + after(() => server.close()) + + const query = { + bool: true, + foo: 1, + bar: 'bar', + nullVal: null, + undefinedVal: undefined, + '`~:$,+[]@^*()-': '`~:$,+[]@^*()-', + multi: [1, 2] + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.ifError(err) + const { statusCode } = data + t.strictEqual(statusCode, 200) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + }) + + await t.completed +}) + +test('basic get with query params fails if url includes hashmark', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + t.fail() + }) + after(() => server.close()) + + const query = { + foo: 1, + bar: 'bar', + multi: [1, 2] + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + const signal = new EE() + client.request({ + signal, + path: '/#', + method: 'GET', + query + }, (err, data) => { + t.strictEqual(err.message, 'Query params cannot be passed when url already contains "?" or "#".') + }) + }) + + await t.completed +}) + +test('basic get with empty query params', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + const searchParamsObject = buildParams(req.url) + t.deepStrictEqual(searchParamsObject, {}) + + res.statusCode = 200 + res.end('hello') + }) + after(() => server.close()) + + const query = {} + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + query + }, (err, data) => { + t.ifError(err) + const { statusCode } = data + t.strictEqual(statusCode, 200) + }) + t.strictEqual(signal.listenerCount('abort'), 1) + }) + + await t.completed +}) + +test('basic get with query params partially in path', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + t.fail() + }) + after(() => server.close()) + + const query = { + foo: 1 + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + const signal = new EE() + client.request({ + signal, + path: '/?bar=2', + method: 'GET', + query + }, (err, data) => { + t.strictEqual(err.message, 'Query params cannot be passed when url already contains "?" or "#".') + }) + }) + + await t.completed +}) + +test('using throwOnError should throw (request)', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.statusCode = 400 + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + const signal = new EE() + client.request({ + signal, + path: '/', + method: 'GET', + throwOnError: true + }, (err) => { + t.strictEqual(err.message, 'invalid throwOnError') + t.strictEqual(err.code, 'UND_ERR_INVALID_ARG') + }) + }) + + await t.completed +}) + +test('using throwOnError should throw (stream)', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.statusCode = 400 + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.stream({ + path: '/', + method: 'GET', + throwOnError: true, + opaque: new PassThrough() + }, ({ opaque: pt }) => { + pt.on('data', () => { + t.fail() + }) + return pt + }, err => { + t.strictEqual(err.message, 'invalid throwOnError') + t.strictEqual(err.code, 'UND_ERR_INVALID_ARG') + }) + }) + + await t.completed +}) + +test('basic head', async (t) => { + t = tspl(t, { plan: 14 }) + + const server = createServer((req, res) => { + t.strictEqual('/123', req.url) + t.strictEqual('HEAD', req.method) + t.strictEqual(`localhost:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('basic head (IPv6)', { skip: !hasIPv6 }, async (t) => { + t = tspl(t, { plan: 15 }) + + const server = createServer((req, res) => { + t.strictEqual('/123', req.url) + t.strictEqual('HEAD', req.method) + t.strictEqual(`[::1]:${server.address().port}`, req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, '::', () => { + const client = new Client(`http://[::1]:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + + client.request({ path: '/123', method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('get with host header', async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual('example.com', req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello from ' + req.headers.host) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'GET', headers: { host: 'example.com' } }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello from example.com', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('get with host header (IPv6)', { skip: !hasIPv6 }, async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + t.strictEqual('[::1]', req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello from ' + req.headers.host) + }) + after(() => server.close()) + + server.listen(0, '::', () => { + const client = new Client(`http://[::1]:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'GET', headers: { host: '[::1]' } }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello from [::1]', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('head with host header', async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer((req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('HEAD', req.method) + t.strictEqual('example.com', req.headers.host) + res.setHeader('content-type', 'text/plain') + res.end('hello from ' + req.headers.host) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'HEAD', headers: { host: 'example.com' } }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +function postServer (t, expected) { + return function (req, res) { + t.strictEqual(req.url, '/') + t.strictEqual(req.method, 'POST') + t.notStrictEqual(req.headers['content-length'], null) + + req.setEncoding('utf8') + let data = '' + + req.on('data', function (d) { data += d }) + + req.on('end', () => { + t.strictEqual(data, expected) + res.end('hello') + }) + } +} + +test('basic POST with string', async (t) => { + t = tspl(t, { plan: 7 }) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'POST', body: expected }, (err, data) => { + t.ifError(err) + t.strictEqual(data.statusCode, 200) + const bufs = [] + data.body + .on('data', (buf) => { + bufs.push(buf) + }) + .on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with empty string', async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer(postServer(t, '')) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'POST', body: '' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with string and content-length', async (t) => { + t = tspl(t, { plan: 7 }) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + body: expected + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with Buffer', async (t) => { + t = tspl(t, { plan: 7 }) + + const expected = readFileSync(__filename) + + const server = createServer(postServer(t, expected.toString())) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'POST', body: expected }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with stream', async (t) => { + t = tspl(t, { plan: 7 }) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + headersTimeout: 0, + body: createReadStream(__filename) + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with paused stream', async (t) => { + t = tspl(t, { plan: 7 }) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const stream = createReadStream(__filename) + stream.pause() + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + headersTimeout: 0, + body: stream + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with custom stream', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + req.resume().on('end', () => { + res.end('hello') + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const body = new EE() + body.pipe = () => {} + client.request({ + path: '/', + method: 'POST', + headersTimeout: 0, + body + }, (err, data) => { + t.ifError(err) + t.strictEqual(data.statusCode, 200) + const bufs = [] + data.body.on('data', (buf) => { + bufs.push(buf) + }) + data.body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + t.deepStrictEqual(client[kBusy], true) + + body.on('close', () => { + body.emit('end') + }) + + client.on('connect', () => { + setImmediate(() => { + body.emit('data', '') + while (!client[kSocket]._writableState.needDrain) { + body.emit('data', Buffer.alloc(4096)) + } + client[kSocket].on('drain', () => { + body.emit('data', Buffer.alloc(4096)) + body.emit('close') + }) + }) + }) + }) + + await t.completed +}) + +test('basic POST with iterator', async (t) => { + t = tspl(t, { plan: 3 }) + + const expected = 'hello' + + const server = createServer((req, res) => { + req.resume().on('end', () => { + res.end(expected) + }) + }) + after(() => server.close()) + + const iterable = { + [Symbol.iterator]: function * () { + for (let i = 0; i < expected.length - 1; i++) { + yield expected[i] + } + return expected[expected.length - 1] + } + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'POST', + requestTimeout: 0, + body: iterable + }, (err, { statusCode, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with iterator with invalid data', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer(() => {}) + after(() => server.close()) + + const iterable = { + [Symbol.iterator]: function * () { + yield 0 + } + } + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'POST', + requestTimeout: 0, + body: iterable + }, err => { + t.ok(err instanceof TypeError) + }) + }) + + await t.completed +}) + +test('basic POST with async iterator', async (t) => { + t = tspl(t, { plan: 7 }) + + const expected = readFileSync(__filename, 'utf8') + + const server = createServer(postServer(t, expected)) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'POST', + headers: { + 'content-length': Buffer.byteLength(expected) + }, + headersTimeout: 0, + body: wrapWithAsyncIterable(createReadStream(__filename)) + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with transfer encoding: chunked', async (t) => { + t = tspl(t, { plan: 8 }) + + let body + const server = createServer(function (req, res) { + t.strictEqual(req.url, '/') + t.strictEqual(req.method, 'POST') + t.strictEqual(req.headers['content-length'], undefined) + t.strictEqual(req.headers['transfer-encoding'], 'chunked') + + body.push(null) + + req.setEncoding('utf8') + let data = '' + + req.on('data', function (d) { data += d }) + + req.on('end', () => { + t.strictEqual(data, 'asd') + res.end('hello') + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + body = new Readable({ + read () { } + }) + body.push('asd') + client.request({ + path: '/', + method: 'POST', + // no content-length header + body + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('basic POST with empty stream', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer(function (req, res) { + t.deepStrictEqual(req.headers['content-length'], '0') + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const body = new Readable({ + autoDestroy: false, + read () { + }, + destroy (err, callback) { + callback(!this._readableState.endEmitted ? new Error('asd') : err) + } + }).on('end', () => { + process.nextTick(() => { + t.strictEqual(body.destroyed, true) + }) + }) + body.push(null) + client.request({ + path: '/', + method: 'POST', + body + }, (err, { statusCode, headers, body }) => { + t.ifError(err) + body + .on('data', () => { + t.fail() + }) + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('10 times GET', async (t) => { + const num = 10 + t = tspl(t, { plan: 3 * num }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + function makeRequest (i) { + client.request({ path: '/' + i, method: 'GET' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('/' + i, Buffer.concat(bufs).toString('utf8')) + }) + }) + } + }) + + await t.completed +}) + +test('10 times HEAD', async (t) => { + const num = 10 + t = tspl(t, { plan: num * 3 }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + for (let i = 0; i < num; i++) { + makeRequest(i) + } + + function makeRequest (i) { + client.request({ path: '/' + i, method: 'HEAD' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + } + }) + + await t.completed +}) + +test('Set-Cookie', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'text/plain') + res.setHeader('Set-Cookie', ['a cookie', 'another cookie', 'more cookies']) + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, headers, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + t.deepStrictEqual(headers['set-cookie'], ['a cookie', 'another cookie', 'more cookies']) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +test('ignore request header mutations', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + t.strictEqual(req.headers.test, 'test') + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const headers = { test: 'test' } + client.request({ + path: '/', + method: 'GET', + headers + }, (err, { body }) => { + t.ifError(err) + body.resume() + }) + headers.test = 'asd' + }) + + await t.completed +}) + +test('url-like url', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client({ + hostname: 'localhost', + port: server.address().port, + protocol: 'http:' + }) + after(() => client.close()) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + data.body.resume() + }) + }) + + await t.completed +}) + +test('an absolute url as path', async (t) => { + t = tspl(t, { plan: 2 }) + + const path = 'http://example.com' + + const server = createServer((req, res) => { + t.strictEqual(req.url, path) + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client({ + hostname: 'localhost', + port: server.address().port, + protocol: 'http:' + }) + after(() => client.close()) + + client.request({ path, method: 'GET' }, (err, data) => { + t.ifError(err) + data.body.resume() + }) + }) + + await t.completed +}) + +test('multiple destroy callback', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client({ + hostname: 'localhost', + port: server.address().port, + protocol: 'http:' + }) + after(() => client.destroy()) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('error', (err) => { + t.ok(err instanceof Error) + }) + client.destroy(new Error(), (err) => { + t.ifError(err) + }) + client.destroy(new Error(), (err) => { + t.ifError(err) + }) + }) + }) + + await t.completed +}) + +test('only one streaming req at a time', async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 4 + }) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body.resume() + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body.resume() + }) + + client.request({ + path: '/', + method: 'PUT', + idempotent: true, + body: new Readable({ + read () { + setImmediate(() => { + t.strictEqual(client[kBusy], true) + this.push(null) + }) + } + }).on('resume', () => { + t.strictEqual(client[kSize], 1) + }) + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + t.strictEqual(client[kBusy], true) + }) + }) + + await t.completed +}) + +test('only one async iterating req at a time', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 4 + }) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body.resume() + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body.resume() + }) + const body = wrapWithAsyncIterable(new Readable({ + read () { + setImmediate(() => { + t.strictEqual(client[kBusy], true) + this.push(null) + }) + } + })) + client.request({ + path: '/', + method: 'PUT', + idempotent: true, + body + }, (err, data) => { + t.ifError(err) + data.body + .resume() + .on('end', () => { + t.ok(true, 'pass') + }) + }) + t.strictEqual(client[kBusy], true) + }) + }) + + await t.completed +}) + +test('300 requests succeed', async (t) => { + t = tspl(t, { plan: 300 * 3 }) + + const server = createServer((req, res) => { + res.end('asd') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + for (let n = 0; n < 300; ++n) { + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body.on('data', (chunk) => { + t.strictEqual(chunk.toString(), 'asd') + }).on('end', () => { + t.ok(true, 'pass') + }) + }) + } + }) + + await t.completed +}) + +test('request args validation', async (t) => { + t = tspl(t, { plan: 2 }) + + const client = new Client('http://localhost:5000') + + client.request(null, (err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + try { + client.request(null, 'asd') + } catch (err) { + t.ok(err instanceof errors.InvalidArgumentError) + } + + await t.completed +}) + +test('request args validation promise', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:5000') + + client.request(null).catch((err) => { + t.ok(err instanceof errors.InvalidArgumentError) + }) + + await t.completed +}) + +test('increase pipelining', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + req.resume() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.request({ + path: '/', + method: 'GET', + blocking: false + }, () => { + if (!client.destroyed) { + t.fail() + } + }) + + client.request({ + path: '/', + method: 'GET', + blocking: false + }, () => { + if (!client.destroyed) { + t.fail() + } + }) + + t.strictEqual(client[kRunning], 0) + client.on('connect', () => { + t.strictEqual(client[kRunning], 0) + process.nextTick(() => { + t.strictEqual(client[kRunning], 1) + client.pipelining = 3 + t.strictEqual(client[kRunning], 2) + }) + }) + }) + + await t.completed +}) + +test('destroy in push', async (t) => { + t = tspl(t, { plan: 4 }) + + let _res + const server = createServer((req, res) => { + res.write('asd') + _res = res + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'GET' }, (err, { body }) => { + t.ifError(err) + body.once('data', () => { + _res.write('asd') + body.on('data', (buf) => { + body.destroy() + _res.end() + }).on('error', (err) => { + t.ok(err) + }) + }) + }) + + client.request({ path: '/', method: 'GET' }, (err, { body }) => { + t.ifError(err) + let buf = '' + body.on('data', (chunk) => { + buf = chunk.toString() + _res.end() + }).on('end', () => { + t.strictEqual('asd', buf) + }) + }) + }) + + await t.completed +}) + +test('non recoverable socket error fails pending request', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.strictEqual(err.message, 'kaboom') + }) + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.strictEqual(err.message, 'kaboom') + }) + client.on('connect', () => { + client[kSocket].destroy(new Error('kaboom')) + }) + }) + + await t.completed +}) + +test('POST empty with error', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + const body = new Readable({ + read () { + } + }) + body.push(null) + client.on('connect', () => { + process.nextTick(() => { + body.emit('error', new Error('asd')) + }) + }) + + client.request({ path: '/', method: 'POST', body }, (err, data) => { + t.strictEqual(err.message, 'asd') + }) + }) + + await t.completed +}) + +test('busy', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 1 + }) + after(() => client.close()) + + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.ifError(err) + }) + t.strictEqual(client[kBusy], true) + }) + }) + + await t.completed +}) + +test('connected', async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer((req, res) => { + // needed so that disconnect is emitted + res.setHeader('connection', 'close') + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const url = new URL(`http://localhost:${server.address().port}`) + const client = new Client(url, { + pipelining: 1 + }) + after(() => client.close()) + + client.on('connect', (origin, [self]) => { + t.strictEqual(origin, url) + t.strictEqual(client, self) + }) + client.on('disconnect', (origin, [self]) => { + t.strictEqual(origin, url) + t.strictEqual(client, self) + }) + + t.strictEqual(client[kConnected], false) + client[kConnect](() => { + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.ifError(err) + }) + t.strictEqual(client[kConnected], true) + }) + }) + + await t.completed +}) + +test('emit disconnect after destroy', async t => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + + server.listen(0, () => { + const url = new URL(`http://localhost:${server.address().port}`) + const client = new Client(url) + + t.strictEqual(client[kConnected], false) + client[kConnect](() => { + t.strictEqual(client[kConnected], true) + let disconnected = false + client.on('disconnect', () => { + disconnected = true + t.ok(true, 'pass') + }) + client.destroy(() => { + t.strictEqual(disconnected, true) + }) + }) + }) + + await t.completed +}) + +test('end response before request', async t => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + + server.listen(0, async () => { + const client = new Client(`http://localhost:${server.address().port}`) + const readable = new Readable({ + read () { + this.push('asd') + } + }) + const { body } = await client.request({ + method: 'GET', + path: '/', + body: readable + }) + body + .on('error', () => { + t.fail() + }) + .on('end', () => { + t.ok(true, 'pass') + }) + .resume() + client.on('disconnect', (url, targets, err) => { + t.strictEqual(err.code, 'UND_ERR_INFO') + }) + }) + + await t.completed +}) + +test('parser pause with no body timeout', async (t) => { + t = tspl(t, { plan: 2 }) + const server = createServer((req, res) => { + let counter = 0 + const t = setInterval(() => { + counter++ + const payload = Buffer.alloc(counter * 4096).fill(0) + if (counter === 3) { + clearInterval(t) + res.end(payload) + } else { + res.write(payload) + } + }, 20) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + + client.request({ path: '/', method: 'GET' }, (err, { statusCode, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + body.resume() + }) + }) + + await t.completed +}) + +test('TypedArray and DataView body', async (t) => { + t = tspl(t, { plan: 3 }) + const server = createServer((req, res) => { + t.strictEqual(req.headers['content-length'], '8') + res.end() + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + + const body = Uint8Array.from(Buffer.alloc(8)) + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + body.resume() + }) + }) + + await t.completed +}) + +test('async iterator empty chunk continues', async (t) => { + t = tspl(t, { plan: 5 }) + const serverChunks = ['hello', 'world'] + const server = createServer((req, res) => { + let str = '' + let i = 0 + req.on('data', (chunk) => { + const content = chunk.toString() + t.strictEqual(serverChunks[i++], content) + str += content + }).on('end', () => { + t.strictEqual(str, serverChunks.join('')) + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + + const body = (async function * () { + yield serverChunks[0] + yield '' + yield serverChunks[1] + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + body.resume() + }) + }) + + await t.completed +}) + +test('async iterator error from server destroys early', async (t) => { + t = tspl(t, { plan: 3 }) + const server = createServer((req, res) => { + req.on('data', (chunk) => { + res.destroy() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + let gotDestroyed + const body = (async function * () { + try { + const promise = new Promise(resolve => { + gotDestroyed = resolve + }) + yield 'hello' + await promise + yield 'inner-value' + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true, 'pass') + } + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ok(err) + t.strictEqual(statusCode, undefined) + gotDestroyed() + }) + }) + + await t.completed +}) + +test('regular iterator error from server closes early', async (t) => { + t = tspl(t, { plan: 3 }) + const server = createServer((req, res) => { + req.on('data', () => { + process.nextTick(() => { + res.destroy() + }) + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + let gotDestroyed = false + const body = (function * () { + try { + yield 'start' + while (!gotDestroyed) { + yield 'zzz' + // for eslint + gotDestroyed = gotDestroyed || false + } + yield 'zzz' + t.fail('should not get here, iterator should be destroyed') + yield 'zzz' + } finally { + t.ok(true, 'pass') + } + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ok(err) + t.strictEqual(statusCode, undefined) + gotDestroyed = true + }) + }) + await t.completed +}) + +test('async iterator early return closes early', async (t) => { + t = tspl(t, { plan: 3 }) + const server = createServer((req, res) => { + req.on('data', () => { + res.writeHead(200) + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + let gotDestroyed + const body = (async function * () { + try { + const promise = new Promise(resolve => { + gotDestroyed = resolve + }) + yield 'hello' + await promise + yield 'inner-value' + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true, 'pass') + } + })() + client.request({ path: '/', method: 'POST', body }, (err, { statusCode, body }) => { + t.ifError(err) + t.strictEqual(statusCode, 200) + gotDestroyed() + }) + }) + await t.completed +}) + +test('async iterator yield unsupported TypedArray', { + skip: !!require('stream')._isArrayBufferView +}, async (t) => { + t = tspl(t, { plan: 3 }) + const server = createServer((req, res) => { + req.on('end', () => { + res.writeHead(200) + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + const body = (async function * () { + try { + yield new Int32Array([1]) + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true, 'pass') + } + })() + client.request({ path: '/', method: 'POST', body }, (err) => { + t.ok(err) + t.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE') + }) + }) + + await t.completed +}) + +test('async iterator yield object error', async (t) => { + t = tspl(t, { plan: 3 }) + const server = createServer((req, res) => { + req.on('end', () => { + res.writeHead(200) + res.end() + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + bodyTimeout: 0 + }) + after(() => client.close()) + const body = (async function * () { + try { + yield {} + t.fail('should not get here, iterator should be destroyed') + } finally { + t.ok(true, 'pass') + } + })() + client.request({ path: '/', method: 'POST', body }, (err) => { + t.ok(err) + t.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE') + }) + }) + + await t.completed +}) + +test('Successfully get a Response when neither a Transfer-Encoding or Content-Length header is present', async (t) => { + t = tspl(t, { plan: 4 }) + const server = createServer((req, res) => { + req.on('data', (data) => { + }) + req.on('end', () => { + res.removeHeader('transfer-encoding') + res.writeHead(200, { + // Header isn't actually necessary, but tells node to close after response + connection: 'close', + foo: 'bar' + }) + res.flushHeaders() + res.end('a response body') + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ path: '/', method: 'GET' }, (err, { body, headers }) => { + t.ifError(err) + t.equal(headers['content-length'], undefined) + t.equal(headers['transfer-encoding'], undefined) + const bufs = [] + body.on('error', () => { + t.fail('Closing the connection is valid') + }) + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.equal('a response body', Buffer.concat(bufs).toString('utf8')) + }) + }) + }) + + await t.completed +}) + +function buildParams (path) { + const cleanPath = path.replace('/?', '').replace('/', '').split('&') + const builtParams = cleanPath.reduce((acc, entry) => { + const [key, value] = entry.split('=') + if (key.length === 0) { + return acc + } + + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value) + } else { + acc[key] = [acc[key], value] + } + } else { + acc[key] = value + } + return acc + }, {}) + + return builtParams +} + +test('\\r\\n in Headers', async (t) => { + t = tspl(t, { plan: 1 }) + + const reqHeaders = { + bar: '\r\nbar' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.strictEqual(err.message, 'invalid bar header') + }) +}) + +test('\\r in Headers', async (t) => { + t = tspl(t, { plan: 1 }) + + const reqHeaders = { + bar: '\rbar' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.strictEqual(err.message, 'invalid bar header') + }) +}) + +test('\\n in Headers', async (t) => { + t = tspl(t, { plan: 1 }) + + const reqHeaders = { + bar: '\nbar' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.strictEqual(err.message, 'invalid bar header') + }) +}) + +test('\\n in Headers', async (t) => { + t = tspl(t, { plan: 1 }) + + const reqHeaders = { + '\nbar': 'foo' + } + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET', + headers: reqHeaders + }, (err) => { + t.strictEqual(err.message, 'invalid header key') + }) +}) + +test('\\n in Path', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.request({ + path: '/\n', + method: 'GET' + }, (err) => { + t.strictEqual(err.message, 'invalid request path') + }) +}) + +test('\\n in Method', async (t) => { + t = tspl(t, { plan: 1 }) + + const client = new Client('http://localhost:4242', { + keepAliveTimeout: 300e3 + }) + after(() => client.close()) + + client.request({ + path: '/', + method: 'GET\n' + }, (err) => { + t.strictEqual(err.message, 'invalid request method') + }) +}) diff --git a/test/close-and-destroy.js b/test/close-and-destroy.js new file mode 100644 index 0000000..5d4f135 --- /dev/null +++ b/test/close-and-destroy.js @@ -0,0 +1,365 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const { createServer } = require('node:http') +const { kSocket, kSize } = require('../lib/core/symbols') + +test('close waits for queued requests to finish', async (t) => { + t = tspl(t, { plan: 16 }) + + const server = createServer() + + server.on('request', (req, res) => { + t.ok(true, 'request received') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + client.request({ path: '/', method: 'GET' }, function (err, data) { + onRequest(err, data) + + client.request({ path: '/', method: 'GET' }, onRequest) + client.request({ path: '/', method: 'GET' }, onRequest) + client.request({ path: '/', method: 'GET' }, onRequest) + + // needed because the next element in the queue will be called + // after the current function completes + process.nextTick(function () { + client.close() + }) + }) + }) + + function onRequest (err, { statusCode, headers, body }) { + t.ifError(err) + t.strictEqual(statusCode, 200) + const bufs = [] + body.on('data', (buf) => { + bufs.push(buf) + }) + body.on('end', () => { + t.strictEqual('hello', Buffer.concat(bufs).toString('utf8')) + }) + } + + await t.completed +}) + +test('destroy invoked all pending callbacks', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer() + + server.on('request', (req, res) => { + res.write('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.destroy()) + + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + data.body.on('error', (err) => { + t.ok(err) + }).resume() + client.destroy() + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err instanceof errors.ClientDestroyedError) + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err instanceof errors.ClientDestroyedError) + }) + }) + + await t.completed +}) + +test('destroy invoked all pending callbacks ticked', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer() + + server.on('request', (req, res) => { + res.write('hello') + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 2 + }) + after(() => client.destroy()) + + let ticked = false + client.request({ path: '/', method: 'GET' }, (err) => { + t.strictEqual(ticked, true) + t.ok(err instanceof errors.ClientDestroyedError) + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.strictEqual(ticked, true) + t.ok(err instanceof errors.ClientDestroyedError) + }) + client.destroy() + ticked = true + }) + + await t.completed +}) + +test('close waits until socket is destroyed', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + makeRequest() + + client.once('connect', () => { + let done = false + client[kSocket].on('close', () => { + done = true + }) + client.close((err) => { + t.ifError(err) + t.strictEqual(client.closed, true) + t.strictEqual(done, true) + }) + }) + + function makeRequest () { + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + }) + return client[kSize] <= client.pipelining + } + }) + + await t.completed +}) + +test('close should still reconnect', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + t.ok(makeRequest()) + t.ok(!makeRequest()) + + client.close((err) => { + t.ifError(err) + t.strictEqual(client.closed, true) + }) + client.once('connect', () => { + client[kSocket].destroy() + }) + + function makeRequest () { + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + data.body.resume() + }) + return client[kSize] <= client.pipelining + } + }) + + await t.completed +}) + +test('close should call callback once finished', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = createServer((req, res) => { + setImmediate(function () { + res.end(req.url) + }) + }) + after(() => server.close()) + + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + t.ok(makeRequest()) + t.ok(!makeRequest()) + + client.close((err) => { + t.ifError(err) + t.strictEqual(client.closed, true) + }) + + function makeRequest () { + client.request({ path: '/', method: 'GET' }, (err, data) => { + t.ifError(err) + data.body.resume() + }) + return client[kSize] <= client.pipelining + } + }) + + await t.completed +}) + +test('closed and destroyed errors', async (t) => { + t = tspl(t, { plan: 4 }) + + const client = new Client('http://localhost:4000') + after(() => client.destroy()) + + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err) + }) + client.close((err) => { + t.ifError(err) + }) + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err instanceof errors.ClientClosedError) + client.destroy() + client.request({ path: '/', method: 'GET' }, (err) => { + t.ok(err instanceof errors.ClientDestroyedError) + }) + }) + + await t.completed +}) + +test('close after and destroy should error', async (t) => { + t = tspl(t, { plan: 2 }) + + const client = new Client('http://localhost:4000') + after(() => client.destroy()) + + client.destroy() + client.close((err) => { + t.ok(err instanceof errors.ClientDestroyedError) + }) + client.close().catch((err) => { + t.ok(err instanceof errors.ClientDestroyedError) + }) + + await t.completed +}) + +test('close socket and reconnect after maxRequestsPerClient reached', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + + after(() => server.close()) + + server.listen(0, async () => { + let connections = 0 + server.on('connection', () => { + connections++ + }) + const client = new Client( + `http://localhost:${server.address().port}`, + { maxRequestsPerClient: 2 } + ) + after(() => client.destroy()) + + await makeRequest() + await makeRequest() + await makeRequest() + await makeRequest() + t.strictEqual(connections, 2) + + function makeRequest () { + return client.request({ path: '/', method: 'GET' }) + } + }) + + await t.completed +}) + +test('close socket and reconnect after maxRequestsPerClient reached (async)', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + + after(() => server.close()) + + server.listen(0, async () => { + let connections = 0 + server.on('connection', () => { + connections++ + }) + const client = new Client( + `http://localhost:${server.address().port}`, + { maxRequestsPerClient: 2 } + ) + after(() => client.destroy()) + + await Promise.all([ + makeRequest(), + makeRequest(), + makeRequest(), + makeRequest() + ]) + t.strictEqual(connections, 2) + + function makeRequest () { + return client.request({ path: '/', method: 'GET' }) + } + }) + + await t.completed +}) + +test('should not close socket when no maxRequestsPerClient is provided', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end(req.url) + }) + + after(() => server.close()) + + server.listen(0, async () => { + let connections = 0 + server.on('connection', () => { + connections++ + }) + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.destroy()) + + await makeRequest() + await makeRequest() + await makeRequest() + await makeRequest() + t.strictEqual(connections, 1) + + function makeRequest () { + return client.request({ path: '/', method: 'GET' }) + } + }) + + await t.completed +}) diff --git a/test/connect-abort.js b/test/connect-abort.js new file mode 100644 index 0000000..bf75fd8 --- /dev/null +++ b/test/connect-abort.js @@ -0,0 +1,31 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') +const { Client } = require('..') +const { PassThrough } = require('node:stream') + +test('connect-abort', async t => { + t = tspl(t, { plan: 2 }) + + const client = new Client('http://localhost:1234', { + connect: (_, cb) => { + client.destroy() + cb(null, new PassThrough({ + destroy (err, cb) { + t.strictEqual(err.name, 'ClientDestroyedError') + cb(null) + } + })) + } + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.strictEqual(err.name, 'ClientDestroyedError') + }) + + await t.completed +}) diff --git a/test/connect-errconnect.js b/test/connect-errconnect.js new file mode 100644 index 0000000..7c241c1 --- /dev/null +++ b/test/connect-errconnect.js @@ -0,0 +1,35 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client } = require('..') +const net = require('node:net') + +test('connect-connectionError', async t => { + t = tspl(t, { plan: 2 }) + + const client = new Client('http://localhost:9000') + after(() => client.close()) + + client.once('connectionError', () => { + t.ok(true, 'pass') + }) + + const _err = new Error('kaboom') + net.connect = function (options) { + const socket = new net.Socket(options) + setImmediate(() => { + socket.destroy(_err) + }) + return socket + } + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.strictEqual(err, _err) + }) + + await t.completed +}) diff --git a/test/connect-pre-shared-session.js b/test/connect-pre-shared-session.js new file mode 100644 index 0000000..5ee7b30 --- /dev/null +++ b/test/connect-pre-shared-session.js @@ -0,0 +1,50 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after, mock } = require('node:test') +const { Client } = require('..') +const { createServer } = require('node:https') +const pem = require('https-pem') +const tls = require('node:tls') + +test('custom session passed to client will be used in tls connect call', async (t) => { + t = tspl(t, { plan: 4 }) + + const mockConnect = mock.method(tls, 'connect') + + const server = createServer(pem, (req, res) => { + t.strictEqual('/', req.url) + t.strictEqual('GET', req.method) + res.setHeader('content-type', 'text/plain') + res.end('hello') + }) + after(() => server.close()) + + server.listen(0, async () => { + const session = Buffer.from('test-session') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false, + session + } + }) + after(() => client.close()) + + const { statusCode, headers, body } = await client.request({ + path: '/', + method: 'GET' + }) + + t.strictEqual(statusCode, 200) + t.strictEqual(headers['content-type'], 'text/plain') + + const responseText = await body.text() + t.strictEqual('hello', responseText) + + const connectSession = mockConnect.mock.calls[0].arguments[0].session + t.strictEqual(connectSession, session) + }) + + await t.completed +}) diff --git a/test/connect-timeout.js b/test/connect-timeout.js new file mode 100644 index 0000000..186067f --- /dev/null +++ b/test/connect-timeout.js @@ -0,0 +1,83 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after, describe } = require('node:test') +const { Client, Pool, errors } = require('..') +const net = require('node:net') +const assert = require('node:assert') + +const skip = !!process.env.CITGM + +// Using describe instead of test to avoid the timeout +describe('prioritize socket errors over timeouts', { skip }, async () => { + const t = tspl({ ...assert, after: () => {} }, { plan: 2 }) + const client = new Pool('http://foorbar.invalid:1234', { connectTimeout: 1 }) + + client.request({ method: 'GET', path: '/foobar' }) + .then(() => t.fail()) + .catch((err) => { + t.strictEqual(err.code, 'ENOTFOUND') + t.strictEqual(err.code !== 'UND_ERR_CONNECT_TIMEOUT', true) + }) + + // block for 1s which is enough for the dns lookup to complete and the + // Timeout to fire + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(1000)) + + await t.completed +}) + +// mock net.connect to avoid the dns lookup +net.connect = function (options) { + return new net.Socket(options) +} + +test('connect-timeout', { skip }, async t => { + t = tspl(t, { plan: 3 }) + + const client = new Client('http://localhost:9000', { + connectTimeout: 1e3 + }) + after(() => client.close()) + + const timeout = setTimeout(() => { + t.fail() + }, 2e3) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.ok(err instanceof errors.ConnectTimeoutError) + t.strictEqual(err.code, 'UND_ERR_CONNECT_TIMEOUT') + t.strictEqual(err.message, 'Connect Timeout Error (attempted address: localhost:9000, timeout: 1000ms)') + clearTimeout(timeout) + }) + + await t.completed +}) + +test('connect-timeout', { skip }, async t => { + t = tspl(t, { plan: 3 }) + + const client = new Pool('http://localhost:9000', { + connectTimeout: 1e3 + }) + after(() => client.close()) + + const timeout = setTimeout(() => { + t.fail() + }, 2e3) + + client.request({ + path: '/', + method: 'GET' + }, (err) => { + t.ok(err instanceof errors.ConnectTimeoutError) + t.strictEqual(err.code, 'UND_ERR_CONNECT_TIMEOUT') + t.strictEqual(err.message, 'Connect Timeout Error (attempted address: localhost:9000, timeout: 1000ms)') + clearTimeout(timeout) + }) + + await t.completed +}) diff --git a/test/content-length.js b/test/content-length.js new file mode 100644 index 0000000..ece0747 --- /dev/null +++ b/test/content-length.js @@ -0,0 +1,462 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Client, errors } = require('..') +const { createServer } = require('node:http') +const { Readable } = require('node:stream') +const { maybeWrapStream, consts } = require('./utils/async-iterators') + +test('request invalid content-length', async (t) => { + t = tspl(t, { plan: 7 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: 'asd' + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: 'asdasdasdasdasdasda' + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: Buffer.alloc(9) + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: Buffer.alloc(11) + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: ['asd'] + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: ['asasdasdasdd'] + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'DELETE', + headers: { + 'content-length': 4 + }, + body: ['asasdasdasdd'] + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + }) + + await t.completed +}) + +function invalidContentLength (bodyType) { + test(`request streaming ${bodyType} invalid content-length`, async (t) => { + t = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.once('disconnect', () => { + t.ok(true, 'pass') + client.once('disconnect', () => { + t.ok(true, 'pass') + }) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: maybeWrapStream(new Readable({ + read () { + setImmediate(() => { + this.push('asdasdasdkajsdnasdkjasnd') + this.push(null) + }) + } + }), bodyType) + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 10 + }, + body: maybeWrapStream(new Readable({ + read () { + setImmediate(() => { + this.push('asd') + this.push(null) + }) + } + }), bodyType) + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + }) + await t.completed + }) +} + +invalidContentLength(consts.STREAM) +invalidContentLength(consts.ASYNC_ITERATOR) + +function zeroContentLength (bodyType) { + test(`request ${bodyType} streaming data when content-length=0`, async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end() + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 0 + }, + body: maybeWrapStream(new Readable({ + read () { + setImmediate(() => { + this.push('asdasdasdkajsdnasdkjasnd') + this.push(null) + }) + } + }), bodyType) + }, (err, data) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + }) + await t.completed + }) +} + +zeroContentLength(consts.STREAM) +zeroContentLength(consts.ASYNC_ITERATOR) + +test('request streaming no body data when content-length=0', async (t) => { + t = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'PUT', + headers: { + 'content-length': 0 + } + }, (err, data) => { + t.ifError(err) + data.body + .on('data', () => { + t.fail() + }) + .on('end', () => { + t.ok(true, 'pass') + }) + }) + }) + + await t.completed +}) + +test('response invalid content length with close', async (t) => { + t = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.writeHead(200, { + 'content-length': 10 + }) + res.end('123') + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`, { + pipelining: 0 + }) + after(() => client.close()) + + client.on('disconnect', (origin, client, err) => { + t.strictEqual(err.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH') + }) + + client.request({ + path: '/', + method: 'GET' + }, (err, data) => { + t.ifError(err) + data.body + .on('end', () => { + t.fail() + }) + .on('error', (err) => { + t.strictEqual(err.code, 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH') + }) + .resume() + }) + }) + + await t.completed +}) + +test('request streaming with Readable.from(buf)', async (t) => { + const server = createServer((req, res) => { + req.pipe(res) + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'PUT', + body: Readable.from(Buffer.from('hello')) + }, (err, data) => { + const chunks = [] + t.ifError(err) + data.body + .on('data', (chunk) => { + chunks.push(chunk) + }) + .on('end', () => { + t.strictEqual(Buffer.concat(chunks).toString(), 'hello') + t.ok(true, 'pass') + t.end() + }) + }) + }) + + await t.completed +}) + +test('request DELETE, content-length=0, with body', async (t) => { + t = tspl(t, { plan: 5 }) + const server = createServer((req, res) => { + res.shouldKeepAlive = false + res.end() + }) + server.on('request', (req, res) => { + t.strictEqual(req.headers['content-length'], undefined) + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/', + method: 'DELETE', + headers: { + 'content-length': 0 + }, + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + }, (err) => { + t.ok(err instanceof errors.RequestContentLengthMismatchError) + }) + + client.request({ + path: '/', + method: 'DELETE', + headers: { + 'content-length': 0 + } + }, (err, resp) => { + t.strictEqual(resp.headers['content-length'], '0') + t.ifError(err) + }) + + client.on('disconnect', () => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) + +test('content-length shouldSendContentLength=false', async (t) => { + t = tspl(t, { plan: 15 }) + + const server = createServer((req, res) => { + res.end() + }) + server.on('request', (req, res) => { + switch (req.url) { + case '/put0': + t.strictEqual(req.headers['content-length'], '0') + break + case '/head': + t.strictEqual(req.headers['content-length'], undefined) + break + case '/get': + t.strictEqual(req.headers['content-length'], undefined) + break + } + }) + after(() => server.close()) + server.listen(0, () => { + const client = new Client(`http://localhost:${server.address().port}`) + after(() => client.close()) + + client.request({ + path: '/put0', + method: 'PUT', + headers: { + 'content-length': 0 + } + }, (err, resp) => { + t.strictEqual(resp.headers['content-length'], '0') + t.ifError(err) + }) + + client.request({ + path: '/head', + method: 'HEAD', + headers: { + 'content-length': 10 + } + }, (err, resp) => { + t.strictEqual(resp.headers['content-length'], undefined) + t.ifError(err) + }) + + client.request({ + path: '/get', + method: 'GET', + headers: { + 'content-length': 0 + } + }, (err) => { + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asd') + this.push(null) + } + }) + }, (err) => { + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'GET', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asasdasdasdd') + this.push(null) + } + }) + }, (err) => { + t.ifError(err) + }) + + client.request({ + path: '/', + method: 'HEAD', + headers: { + 'content-length': 4 + }, + body: new Readable({ + read () { + this.push('asasdasdasdd') + this.push(null) + } + }) + }, (err) => { + t.ifError(err) + }) + + client.on('disconnect', () => { + t.ok(true, 'pass') + }) + }) + + await t.completed +}) diff --git a/test/cookie/cookies.js b/test/cookie/cookies.js new file mode 100644 index 0000000..9e2d3cf --- /dev/null +++ b/test/cookie/cookies.js @@ -0,0 +1,711 @@ +// MIT License +// +// Copyright 2018-2022 the Deno authors. +// +// 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. + +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { + deleteCookie, + getCookies, + getSetCookies, + setCookie, + Headers +} = require('../..') + +// https://raw.githubusercontent.com/denoland/deno_std/b4239898d6c6b4cdbfd659a4ea1838cf4e656336/http/cookie_test.ts + +test('Cookie parser', () => { + let headers = new Headers() + assert.deepEqual(getCookies(headers), {}) + headers = new Headers() + headers.set('Cookie', 'foo=bar') + assert.deepEqual(getCookies(headers), { foo: 'bar' }) + + headers = new Headers() + headers.set('Cookie', 'full=of ; tasty=chocolate') + assert.deepEqual(getCookies(headers), { full: 'of ', tasty: 'chocolate' }) + + headers = new Headers() + headers.set('Cookie', 'igot=99; problems=but...') + assert.deepEqual(getCookies(headers), { igot: '99', problems: 'but...' }) + + headers = new Headers() + headers.set('Cookie', 'PREF=al=en-GB&f1=123; wide=1; SID=123') + assert.deepEqual(getCookies(headers), { + PREF: 'al=en-GB&f1=123', + wide: '1', + SID: '123' + }) +}) + +test('Cookie Name Validation', () => { + const tokens = [ + '"id"', + 'id\t', + 'i\td', + 'i d', + 'i;d', + '{id}', + '[id]', + '"', + 'id\u0091' + ] + const headers = new Headers() + tokens.forEach((name) => { + assert.throws( + () => { + setCookie(headers, { + name, + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 3 + }) + }, + new Error('Invalid cookie name') + ) + }) +}) + +test('Cookie Value Validation', () => { + const tokens = [ + '1f\tWa', + '\t', + '1f Wa', + '1f;Wa', + '"1fWa', + '1f\\Wa', + '1f"Wa', + '"', + '1fWa\u0005', + '1f\u0091Wa' + ] + + const headers = new Headers() + tokens.forEach((value) => { + assert.throws( + () => { + setCookie( + headers, + { + name: 'Space', + value, + httpOnly: true, + secure: true, + maxAge: 3 + } + ) + }, + new Error('Invalid cookie value'), + "RFC2616 cookie 'Space'" + ) + }) + + assert.throws( + () => { + setCookie(headers, { + name: 'location', + value: 'United Kingdom' + }) + }, + new Error('Invalid cookie value'), + "RFC2616 cookie 'location' cannot contain character ' '" + ) +}) + +test('Cookie Path Validation', () => { + const path = '/;domain=sub.domain.com' + const headers = new Headers() + assert.throws( + () => { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + path, + maxAge: 3 + }) + }, + new Error('Invalid cookie path'), + path + ": Invalid cookie path char ';'" + ) +}) + +test('Cookie Domain Validation', () => { + const tokens = ['-domain.com', 'domain.org.', 'domain.org-'] + const headers = new Headers() + tokens.forEach((domain) => { + assert.throws( + () => { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + domain, + maxAge: 3 + }) + }, + new Error('Invalid cookie domain'), + 'Invalid first/last char in cookie domain: ' + domain + ) + }) +}) + +test('Cookie Delete', () => { + let headers = new Headers() + deleteCookie(headers, 'deno') + assert.equal( + headers.get('Set-Cookie'), + 'deno=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + domain: 'deno.land', + path: '/' + }) + deleteCookie(headers, 'Space', { domain: '', path: '' }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Domain=deno.land; Path=/, Space=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) +}) + +test('Cookie Set', () => { + let headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat' }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat') + + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', secure: true }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat; Secure') + + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', httpOnly: true }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat; HttpOnly') + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true + }) + assert.equal(headers.get('Set-Cookie'), 'Space=Cat; Secure; HttpOnly') + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2 + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 0 + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=0' + ) + + let error = false + headers = new Headers() + try { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: -1 + }) + } catch { + error = true + } + assert.ok(error) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land' + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Strict' + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; ' + + 'SameSite=Strict' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Lax' + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/' + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + unparsed: ['unparsed=keyvalue', 'batman=Bruce'] + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'unparsed=keyvalue; batman=Bruce' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'Expires=Fri, 07 Jan 1983 15:32:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: Date.UTC(1983, 0, 7, 15, 32) + }) + assert.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Expires=Fri, 07 Jan 1983 15:32:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { name: '__Secure-Kitty', value: 'Meow' }) + assert.equal(headers.get('Set-Cookie'), '__Secure-Kitty=Meow; Secure') + + headers = new Headers() + setCookie(headers, { + name: '__Host-Kitty', + value: 'Meow', + domain: 'deno.land' + }) + assert.equal( + headers.get('Set-Cookie'), + '__Host-Kitty=Meow; Secure; Path=/' + ) + + headers = new Headers() + setCookie(headers, { name: 'cookie-1', value: 'value-1', secure: true }) + setCookie(headers, { name: 'cookie-2', value: 'value-2', maxAge: 3600 }) + assert.equal( + headers.get('Set-Cookie'), + 'cookie-1=value-1; Secure, cookie-2=value-2; Max-Age=3600' + ) + + headers = new Headers() + setCookie(headers, { name: '', value: '' }) + assert.equal(headers.get('Set-Cookie'), null) +}) + +test('Set-Cookie parser', () => { + let headers = new Headers({ 'set-cookie': 'Space=Cat' }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat' + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; Secure' }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; HttpOnly' }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + httpOnly: true + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; Secure; HttpOnly' }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=2' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2 + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=0' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 0 + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=-1' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Strict' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Strict' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Lax' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; unparsed=keyvalue; batman=Bruce' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + unparsed: ['unparsed=keyvalue', 'batman=Bruce'] + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'Expires=Fri, 07 Jan 1983 15:32:00 GMT' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }]) + + headers = new Headers({ 'set-cookie': '__Secure-Kitty=Meow; Secure' }) + assert.deepEqual(getSetCookies(headers), [{ + name: '__Secure-Kitty', + value: 'Meow', + secure: true + }]) + + headers = new Headers({ 'set-cookie': '__Secure-Kitty=Meow' }) + assert.deepEqual(getSetCookies(headers), [{ + name: '__Secure-Kitty', + value: 'Meow' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Path=/' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + path: '/' + }]) + + headers = new Headers({ 'set-cookie': '__Host-Kitty=Meow; Path=/' }) + assert.deepEqual(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Domain=deno.land; Path=/' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + domain: 'deno.land', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Path=/not-root' + }) + assert.deepEqual(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + path: '/not-root' + }]) + + headers = new Headers([ + ['set-cookie', 'cookie-1=value-1; Secure'], + ['set-cookie', 'cookie-2=value-2; Max-Age=3600'] + ]) + assert.deepEqual(getSetCookies(headers), [ + { name: 'cookie-1', value: 'value-1', secure: true }, + { name: 'cookie-2', value: 'value-2', maxAge: 3600 } + ]) + + headers = new Headers() + assert.deepEqual(getSetCookies(headers), []) +}) + +test('Cookie setCookie throws if headers is not of type Headers', () => { + class Headers { + [Symbol.toStringTag] = 'CustomHeaders' + } + const headers = new Headers() + assert.throws( + () => { + setCookie(headers, { + name: 'key', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 3 + }) + }, + new TypeError('Illegal invocation') + ) +}) + +test('Cookie setCookie does not throw if headers is an instance of undici owns Headers class', () => { + const headers = new Headers() + setCookie(headers, { + name: 'key', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 3 + }) +}) + +test('Cookie setCookie does not throw if headers is an instance of the global Headers class', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers() + setCookie(headers, { + name: 'key', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 3 + }) +}) + +test('Cookie getCookies throws if headers is not of type Headers', () => { + class Headers { + [Symbol.toStringTag] = 'CustomHeaders' + } + const headers = new Headers() + assert.throws( + () => { + getCookies(headers) + }, + new TypeError('Illegal invocation') + ) +}) + +test('Cookie getCookies does not throw if headers is an instance of undici owns Headers class', () => { + const headers = new Headers() + getCookies(headers) +}) + +test('Cookie getCookie does not throw if headers is an instance of the global Headers class', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers() + getCookies(headers) +}) + +test('Cookie getSetCookies throws if headers is not of type Headers', () => { + class Headers { + [Symbol.toStringTag] = 'CustomHeaders' + } + const headers = new Headers({ 'set-cookie': 'Space=Cat' }) + assert.throws( + () => { + getSetCookies(headers) + }, + new TypeError('Illegal invocation') + ) +}) + +test('Cookie getSetCookies does not throw if headers is an instance of undici owns Headers class', () => { + const headers = new Headers({ 'set-cookie': 'Space=Cat' }) + getSetCookies(headers) +}) + +test('Cookie setCookie does not throw if headers is an instance of the global Headers class', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers({ 'set-cookie': 'Space=Cat' }) + getSetCookies(headers) +}) + +test('Cookie deleteCookie throws if headers is not of type Headers', () => { + class Headers { + [Symbol.toStringTag] = 'CustomHeaders' + } + const headers = new Headers() + assert.throws( + () => { + deleteCookie(headers, 'deno') + }, + new TypeError('Illegal invocation') + ) +}) + +test('Cookie deleteCookie does not throw if headers is an instance of undici owns Headers class', () => { + const headers = new Headers() + deleteCookie(headers, 'deno') +}) + +test('Cookie getCookie does not throw if headers is an instance of the global Headers class', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers() + deleteCookie(headers, 'deno') +}) diff --git a/test/cookie/global-headers.js b/test/cookie/global-headers.js new file mode 100644 index 0000000..ad9d8a1 --- /dev/null +++ b/test/cookie/global-headers.js @@ -0,0 +1,68 @@ +'use strict' + +const { describe, test } = require('node:test') +const assert = require('node:assert') +const { + deleteCookie, + getCookies, + getSetCookies, + setCookie +} = require('../..') + +describe('Using global Headers', async () => { + test('deleteCookies', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers() + + assert.equal(headers.get('set-cookie'), null) + deleteCookie(headers, 'undici') + assert.equal(headers.get('set-cookie'), 'undici=; Expires=Thu, 01 Jan 1970 00:00:00 GMT') + }) + + test('getCookies', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers({ + cookie: 'get=cookies; and=attributes' + }) + + assert.deepEqual(getCookies(headers), { get: 'cookies', and: 'attributes' }) + }) + + test('getSetCookies', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers({ + 'set-cookie': 'undici=getSetCookies; Secure' + }) + + const supportsCookies = headers.getSetCookie() + + if (!supportsCookies) { + assert.deepEqual(getSetCookies(headers), []) + } else { + assert.deepEqual(getSetCookies(headers), [ + { + name: 'undici', + value: 'getSetCookies', + secure: true + } + ]) + } + }) + + test('setCookie', { skip: !globalThis.Headers }, () => { + const headers = new globalThis.Headers() + + setCookie(headers, { name: 'undici', value: 'setCookie' }) + assert.equal(headers.get('Set-Cookie'), 'undici=setCookie') + }) +}) + +describe('Headers check is not too lax', { skip: !globalThis.Headers }, () => { + class Headers { } + Object.defineProperty(globalThis.Headers.prototype, Symbol.toStringTag, { + value: 'Headers', + configurable: true + }) + + assert.throws(() => getCookies(new Headers()), { code: 'ERR_INVALID_THIS' }) + assert.throws(() => getSetCookies(new Headers()), { code: 'ERR_INVALID_THIS' }) + assert.throws(() => setCookie(new Headers(), { name: 'a', value: 'b' }), { code: 'ERR_INVALID_THIS' }) + assert.throws(() => deleteCookie(new Headers(), 'name'), { code: 'ERR_INVALID_THIS' }) +}) diff --git a/test/cookie/is-ctl-excluding-htab.js b/test/cookie/is-ctl-excluding-htab.js new file mode 100644 index 0000000..a952332 --- /dev/null +++ b/test/cookie/is-ctl-excluding-htab.js @@ -0,0 +1,85 @@ +'use strict' + +const { test, describe } = require('node:test') +const { strictEqual } = require('node:assert') + +const { + isCTLExcludingHtab +} = require('../../lib/web/cookies/util') + +describe('isCTLExcludingHtab', () => { + test('should return false for 0x00 - 0x08 characters', () => { + strictEqual(isCTLExcludingHtab('\x00'), true) + strictEqual(isCTLExcludingHtab('\x01'), true) + strictEqual(isCTLExcludingHtab('\x02'), true) + strictEqual(isCTLExcludingHtab('\x03'), true) + strictEqual(isCTLExcludingHtab('\x04'), true) + strictEqual(isCTLExcludingHtab('\x05'), true) + strictEqual(isCTLExcludingHtab('\x06'), true) + strictEqual(isCTLExcludingHtab('\x07'), true) + strictEqual(isCTLExcludingHtab('\x08'), true) + }) + + test('should return false for 0x09 HTAB character', () => { + strictEqual(isCTLExcludingHtab('\x09'), false) + }) + + test('should return false for 0x0A - 0x1F characters', () => { + strictEqual(isCTLExcludingHtab('\x0A'), true) + strictEqual(isCTLExcludingHtab('\x0B'), true) + strictEqual(isCTLExcludingHtab('\x0C'), true) + strictEqual(isCTLExcludingHtab('\x0D'), true) + strictEqual(isCTLExcludingHtab('\x0E'), true) + strictEqual(isCTLExcludingHtab('\x0F'), true) + strictEqual(isCTLExcludingHtab('\x10'), true) + strictEqual(isCTLExcludingHtab('\x11'), true) + strictEqual(isCTLExcludingHtab('\x12'), true) + strictEqual(isCTLExcludingHtab('\x13'), true) + strictEqual(isCTLExcludingHtab('\x14'), true) + strictEqual(isCTLExcludingHtab('\x15'), true) + strictEqual(isCTLExcludingHtab('\x16'), true) + strictEqual(isCTLExcludingHtab('\x17'), true) + strictEqual(isCTLExcludingHtab('\x18'), true) + strictEqual(isCTLExcludingHtab('\x19'), true) + strictEqual(isCTLExcludingHtab('\x1A'), true) + strictEqual(isCTLExcludingHtab('\x1B'), true) + strictEqual(isCTLExcludingHtab('\x1C'), true) + strictEqual(isCTLExcludingHtab('\x1D'), true) + strictEqual(isCTLExcludingHtab('\x1E'), true) + strictEqual(isCTLExcludingHtab('\x1F'), true) + }) + + test('should return false for a 0x7F character', t => { + strictEqual(isCTLExcludingHtab('\x7F'), true) + }) + + test('should return false for a 0x20 / space character', t => { + strictEqual(isCTLExcludingHtab(' '), false) + }) + + test('should return false for a printable character', t => { + strictEqual(isCTLExcludingHtab('A'), false) + strictEqual(isCTLExcludingHtab('Z'), false) + strictEqual(isCTLExcludingHtab('a'), false) + strictEqual(isCTLExcludingHtab('z'), false) + strictEqual(isCTLExcludingHtab('!'), false) + }) + + test('should return false for an empty string', () => { + strictEqual(isCTLExcludingHtab(''), false) + }) + + test('all printable characters (0x20 - 0x7E)', () => { + for (let i = 0x20; i < 0x7F; i++) { + strictEqual(isCTLExcludingHtab(String.fromCharCode(i)), false) + } + }) + + test('valid case', () => { + strictEqual(isCTLExcludingHtab('Space=Cat; Secure; HttpOnly; Max-Age=2'), false) + }) + + test('invalid case', () => { + strictEqual(isCTLExcludingHtab('Space=Cat; Secure; HttpOnly; Max-Age=2\x7F'), true) + }) +}) diff --git a/test/cookie/npm-cookie.js b/test/cookie/npm-cookie.js new file mode 100644 index 0000000..28e86c1 --- /dev/null +++ b/test/cookie/npm-cookie.js @@ -0,0 +1,113 @@ +'use strict' + +// (The MIT License) +// +// Copyright (c) 2012-2014 Roman Shtylman +// Copyright (c) 2015 Douglas Christopher Wilson +// +// 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. + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const { parseCookie } = require('../..') + +describe('parseCookie(str)', function () { + it('should parse cookie string to object', function () { + assert.deepStrictEqual(parseCookie('foo=bar'), { name: 'foo', value: 'bar' }) + assert.deepStrictEqual(parseCookie('foo=123'), { name: 'foo', value: '123' }) + }) + + it('should ignore OWS', function () { + assert.deepStrictEqual(parseCookie('FOO = bar; baz = raz'), { + name: 'FOO', + value: 'bar', + unparsed: ['baz=raz'] + }) + }) + + it('should parse cookie with empty value', function () { + assert.deepStrictEqual(parseCookie('foo=; bar='), { name: 'foo', value: '', unparsed: ['bar='] }) + }) + + it('should parse cookie with minimum length', function () { + assert.deepStrictEqual(parseCookie('f='), { name: 'f', value: '' }) + assert.deepStrictEqual(parseCookie('f=;b='), { name: 'f', value: '', unparsed: ['b='] }) + }) + + it('should URL-decode values', function () { + assert.deepStrictEqual(parseCookie('foo="bar=123456789&name=Magic+Mouse"'), { + name: 'foo', + value: '"bar=123456789&name=Magic+Mouse"' + }) + + assert.deepStrictEqual(parseCookie('email=%20%22%2c%3b%2f'), { name: 'email', value: ' ",;/' }) + }) + + it('should trim whitespace around key and value', function () { + assert.deepStrictEqual(parseCookie(' foo = "bar" '), { name: 'foo', value: '"bar"' }) + assert.deepStrictEqual(parseCookie(' foo = bar ; fizz = buzz '), { + name: 'foo', + value: 'bar', + unparsed: ['fizz=buzz'] + }) + assert.deepStrictEqual(parseCookie(' foo = " a b c " '), { name: 'foo', value: '" a b c "' }) + assert.deepStrictEqual(parseCookie(' = bar '), { name: '', value: 'bar' }) + assert.deepStrictEqual(parseCookie(' foo = '), { name: 'foo', value: '' }) + assert.deepStrictEqual(parseCookie(' = '), { name: '', value: '' }) + assert.deepStrictEqual(parseCookie('\tfoo\t=\tbar\t'), { name: 'foo', value: 'bar' }) + }) + + it('should return original value on escape error', function () { + assert.deepStrictEqual(parseCookie('foo=%1;bar=bar'), { name: 'foo', value: '%1', unparsed: ['bar=bar'] }) + }) + + it('should ignore cookies without value', function () { + assert.deepStrictEqual(parseCookie('foo=bar;fizz ; buzz'), { name: 'foo', value: 'bar', unparsed: ['fizz=', 'buzz='] }) + assert.deepStrictEqual(parseCookie(' fizz; foo= bar'), { name: '', value: 'fizz', unparsed: ['foo=bar'] }) + }) + + it('should ignore duplicate cookies', function () { + assert.deepStrictEqual(parseCookie('foo=%1;bar=bar;foo=boo'), { + name: 'foo', + value: '%1', + unparsed: ['bar=bar', 'foo=boo'] + }) + assert.deepStrictEqual(parseCookie('foo=false;bar=bar;foo=true'), { + name: 'foo', + value: 'false', + unparsed: ['bar=bar', 'foo=true'] + }) + assert.deepStrictEqual(parseCookie('foo=;bar=bar;foo=boo'), { + name: 'foo', + value: '', + unparsed: ['bar=bar', 'foo=boo'] + }) + }) + + it('should parse native properties', function () { + assert.deepStrictEqual(parseCookie('toString=foo;valueOf=bar'), { + name: 'toString', + unparsed: [ + 'valueOf=bar' + ], + value: 'foo' + }) + }) +}) diff --git a/test/cookie/to-imf-date.js b/test/cookie/to-imf-date.js new file mode 100644 index 0000000..3e48360 --- /dev/null +++ b/test/cookie/to-imf-date.js @@ -0,0 +1,21 @@ +'use strict' + +const { test, describe } = require('node:test') +const { strictEqual } = require('node:assert') + +const { + toIMFDate +} = require('../../lib/web/cookies/util') + +describe('toIMFDate', () => { + test('should return the same as Date.prototype.toGMTString()', () => { + for (let i = 1; i <= 1e6; i *= 2) { + const date = new Date(i) + strictEqual(toIMFDate(date), date.toGMTString()) + } + for (let i = 0; i <= 1e6; i++) { + const date = new Date(Math.trunc(Math.random() * 8640000000000000)) + strictEqual(toIMFDate(date), date.toGMTString()) + } + }) +}) diff --git a/test/cookie/validate-cookie-name.js b/test/cookie/validate-cookie-name.js new file mode 100644 index 0000000..32e4d9d --- /dev/null +++ b/test/cookie/validate-cookie-name.js @@ -0,0 +1,130 @@ +'use strict' + +const { test, describe } = require('node:test') +const { throws, strictEqual } = require('node:assert') + +const { + validateCookieName +} = require('../../lib/web/cookies/util') + +describe('validateCookieName', () => { + test('should throw for CTLs', () => { + throws(() => validateCookieName('\x00'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x01'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x02'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x03'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x04'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x05'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x06'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x07'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x08'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x09'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x0A'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x0B'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x0C'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x0D'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x0E'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x0F'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x10'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x11'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x12'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x13'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x14'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x15'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x16'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x17'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x18'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x19'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x1A'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x1B'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x1C'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x1D'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x1E'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x1F'), new Error('Invalid cookie name')) + throws(() => validateCookieName('\x7F'), new Error('Invalid cookie name')) + }) + + test('should throw for " " character', () => { + throws(() => validateCookieName(' '), new Error('Invalid cookie name')) + }) + + test('should throw for Horizontal Tab character', () => { + throws(() => validateCookieName('\t'), new Error('Invalid cookie name')) + }) + + test('should throw for ; character', () => { + throws(() => validateCookieName(';'), new Error('Invalid cookie name')) + }) + + test('should throw for " character', () => { + throws(() => validateCookieName('"'), new Error('Invalid cookie name')) + }) + + test('should throw for , character', () => { + throws(() => validateCookieName(','), new Error('Invalid cookie name')) + }) + + test('should throw for \\ character', () => { + throws(() => validateCookieName('\\'), new Error('Invalid cookie name')) + }) + + test('should throw for ( character', () => { + throws(() => validateCookieName('('), new Error('Invalid cookie name')) + }) + + test('should throw for ) character', () => { + throws(() => validateCookieName(')'), new Error('Invalid cookie name')) + }) + + test('should throw for < character', () => { + throws(() => validateCookieName('<'), new Error('Invalid cookie name')) + }) + + test('should throw for > character', () => { + throws(() => validateCookieName('>'), new Error('Invalid cookie name')) + }) + + test('should throw for @ character', () => { + throws(() => validateCookieName('@'), new Error('Invalid cookie name')) + }) + + test('should throw for : character', () => { + throws(() => validateCookieName(':'), new Error('Invalid cookie name')) + }) + + test('should throw for / character', () => { + throws(() => validateCookieName('/'), new Error('Invalid cookie name')) + }) + + test('should throw for [ character', () => { + throws(() => validateCookieName('['), new Error('Invalid cookie name')) + }) + + test('should throw for ] character', () => { + throws(() => validateCookieName(']'), new Error('Invalid cookie name')) + }) + + test('should throw for ? character', () => { + throws(() => validateCookieName('?'), new Error('Invalid cookie name')) + }) + + test('should throw for = character', () => { + throws(() => validateCookieName('='), new Error('Invalid cookie name')) + }) + + test('should throw for { character', () => { + throws(() => validateCookieName('{'), new Error('Invalid cookie name')) + }) + + test('should throw for } character', () => { + throws(() => validateCookieName('}'), new Error('Invalid cookie name')) + }) + + test('should pass for a printable character', t => { + strictEqual(validateCookieName('A'), undefined) + strictEqual(validateCookieName('Z'), undefined) + strictEqual(validateCookieName('a'), undefined) + strictEqual(validateCookieName('z'), undefined) + strictEqual(validateCookieName('!'), undefined) + }) +}) diff --git a/test/cookie/validate-cookie-path.js b/test/cookie/validate-cookie-path.js new file mode 100644 index 0000000..bcca0d2 --- /dev/null +++ b/test/cookie/validate-cookie-path.js @@ -0,0 +1,59 @@ +'use strict' + +const { test, describe } = require('node:test') +const { throws, strictEqual } = require('node:assert') + +const { + validateCookiePath +} = require('../../lib/web/cookies/util') + +describe('validateCookiePath', () => { + test('should throw for CTLs', () => { + throws(() => validateCookiePath('\x00')) + throws(() => validateCookiePath('\x01')) + throws(() => validateCookiePath('\x02')) + throws(() => validateCookiePath('\x03')) + throws(() => validateCookiePath('\x04')) + throws(() => validateCookiePath('\x05')) + throws(() => validateCookiePath('\x06')) + throws(() => validateCookiePath('\x07')) + throws(() => validateCookiePath('\x08')) + throws(() => validateCookiePath('\x09')) + throws(() => validateCookiePath('\x0A')) + throws(() => validateCookiePath('\x0B')) + throws(() => validateCookiePath('\x0C')) + throws(() => validateCookiePath('\x0D')) + throws(() => validateCookiePath('\x0E')) + throws(() => validateCookiePath('\x0F')) + throws(() => validateCookiePath('\x10')) + throws(() => validateCookiePath('\x11')) + throws(() => validateCookiePath('\x12')) + throws(() => validateCookiePath('\x13')) + throws(() => validateCookiePath('\x14')) + throws(() => validateCookiePath('\x15')) + throws(() => validateCookiePath('\x16')) + throws(() => validateCookiePath('\x17')) + throws(() => validateCookiePath('\x18')) + throws(() => validateCookiePath('\x19')) + throws(() => validateCookiePath('\x1A')) + throws(() => validateCookiePath('\x1B')) + throws(() => validateCookiePath('\x1C')) + throws(() => validateCookiePath('\x1D')) + throws(() => validateCookiePath('\x1E')) + throws(() => validateCookiePath('\x1F')) + throws(() => validateCookiePath('\x7F')) + }) + + test('should throw for ; character', () => { + throws(() => validateCookiePath(';')) + }) + + test('should pass for a printable character', t => { + strictEqual(validateCookiePath('A'), undefined) + strictEqual(validateCookiePath('Z'), undefined) + strictEqual(validateCookiePath('a'), undefined) + strictEqual(validateCookiePath('z'), undefined) + strictEqual(validateCookiePath('!'), undefined) + strictEqual(validateCookiePath(' '), undefined) + }) +}) diff --git a/test/cookie/validate-cookie-value.js b/test/cookie/validate-cookie-value.js new file mode 100644 index 0000000..7511121 --- /dev/null +++ b/test/cookie/validate-cookie-value.js @@ -0,0 +1,78 @@ +'use strict' + +const { test, describe } = require('node:test') +const { throws, strictEqual } = require('node:assert') + +const { + validateCookieValue +} = require('../../lib/web/cookies/util') + +describe('validateCookieValue', () => { + test('should throw for CTLs', () => { + throws(() => validateCookieValue('\x00'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x01'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x02'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x03'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x04'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x05'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x06'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x07'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x08'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x09'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x0A'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x0B'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x0C'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x0D'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x0E'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x0F'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x10'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x11'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x12'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x13'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x14'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x15'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x16'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x17'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x18'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x19'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x1A'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x1B'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x1C'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x1D'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x1E'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x1F'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('\x7F'), new Error('Invalid cookie value')) + }) + + test('should throw for ; character', () => { + throws(() => validateCookieValue(';'), new Error('Invalid cookie value')) + }) + + test('should throw for " character', () => { + throws(() => validateCookieValue('"'), new Error('Invalid cookie value')) + }) + + test('should throw for , character', () => { + throws(() => validateCookieValue(','), new Error('Invalid cookie value')) + }) + + test('should throw for \\ character', () => { + throws(() => validateCookieValue('\\'), new Error('Invalid cookie value')) + }) + + test('should pass for a printable character', t => { + strictEqual(validateCookieValue('A'), undefined) + strictEqual(validateCookieValue('Z'), undefined) + strictEqual(validateCookieValue('a'), undefined) + strictEqual(validateCookieValue('z'), undefined) + strictEqual(validateCookieValue('!'), undefined) + strictEqual(validateCookieValue('='), undefined) + }) + + test('should handle strings wrapped in DQUOTE', t => { + strictEqual(validateCookieValue('""'), undefined) + strictEqual(validateCookieValue('"helloworld"'), undefined) + throws(() => validateCookieValue('"'), new Error('Invalid cookie value')) + throws(() => validateCookieValue('"""'), new Error('Invalid cookie value')) + }) +}) diff --git a/test/decorator-handler.js b/test/decorator-handler.js new file mode 100644 index 0000000..fc3e657 --- /dev/null +++ b/test/decorator-handler.js @@ -0,0 +1,405 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { describe, test } = require('node:test') +const DecoratorHandler = require('../lib/handler/decorator-handler') + +describe('DecoratorHandler', () => { + test('should throw if provided handler is not an object', t => { + t = tspl(t, { plan: 4 }) + t.throws( + () => new DecoratorHandler(null), + new TypeError('handler must be an object') + ) + t.throws( + () => new DecoratorHandler('string'), + new TypeError('handler must be an object') + ) + + t.throws( + () => new DecoratorHandler(null), + new TypeError('handler must be an object') + ) + t.throws( + () => new DecoratorHandler('string'), + new TypeError('handler must be an object') + ) + }) + + describe('wrap', () => { + const Handler = class { + #handler = null + constructor (handler) { + this.#handler = handler + } + + onConnect (abort, context) { + return this.#handler?.onConnect?.(abort, context) + } + + onHeaders (statusCode, rawHeaders, resume, statusMessage) { + return this.#handler?.onHeaders?.(statusCode, rawHeaders, resume, statusMessage) + } + + onUpgrade (statusCode, rawHeaders, socket) { + return this.#handler?.onUpgrade?.(statusCode, rawHeaders, socket) + } + + onData (data) { + return this.#handler?.onData?.(data) + } + + onComplete (trailers) { + return this.#handler?.onComplete?.(trailers) + } + + onError (err) { + return this.#handler?.onError?.(err) + } + } + const Controller = class { + #controller = null + constructor (controller) { + this.#controller = controller + } + + abort (reason) { + return this.#controller?.abort?.(reason) + } + + resume () { + return this.#controller?.resume?.() + } + + pause () { + return this.#controller?.pause?.() + } + } + + describe('#onConnect', () => { + test('should delegate onConnect-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onConnect: (abort, ctx) => { + t.equal(typeof abort, 'function') + t.equal(typeof ctx, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestStart(new Controller(), {}) + }) + + test('should not throw if onConnect-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestStart()) + }) + }) + + describe('#onHeaders', () => { + test('should delegate onHeaders-method', t => { + t = tspl(t, { plan: 4 }) + const handler = new Handler( + { + onHeaders: (statusCode, headers, resume, statusMessage) => { + t.equal(statusCode, '200') + t.equal(`${headers[0].toString('utf-8')}: ${headers[1].toString('utf-8')}`, 'content-type: application/json') + t.equal(typeof resume, 'function') + t.equal(statusMessage, 'OK') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + }, 'OK') + }) + + test('should not throw if onHeaders-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + })) + }) + }) + + describe('#onUpgrade', () => { + test('should delegate onUpgrade-method', t => { + t = tspl(t, { plan: 3 }) + const handler = new Handler( + { + onUpgrade: (statusCode, headers, socket) => { + t.equal(statusCode, 301) + t.equal(`${headers[0].toString('utf-8')}: ${headers[1].toString('utf-8')}`, 'content-type: application/json') + t.equal(typeof socket, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + }, {}) + }) + + test('should not throw if onUpgrade-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + })) + }) + }) + + describe('#onData', () => { + test('should delegate onData-method', t => { + t = tspl(t, { plan: 1 }) + const handler = new Handler( + { + onData: (chunk) => { + t.equal('chunk', chunk) + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseData(new Controller(), 'chunk') + }) + + test('should not throw if onData-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) + }) + }) + + describe('#onComplete', () => { + test('should delegate onComplete-method', t => { + t = tspl(t, { plan: 1 }) + const handler = new Handler( + { + onComplete: (trailers) => { + t.equal(`${trailers[0].toString('utf-8')}: ${trailers[1].toString('utf-8')}`, 'x-trailer: trailer') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) + }) + + test('should not throw if onComplete-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) + }) + }) + + describe('#onError', () => { + test('should delegate onError-method', t => { + t = tspl(t, { plan: 1 }) + const handler = new Handler( + { + onError: (err) => { + t.equal(err.message, 'Oops!') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseError(new Controller(), new Error('Oops!')) + }) + + test('should throw if onError-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.throws(() => decorator.onResponseError(new Controller(), new Error('Oops!'))) + }) + }) + }) + + describe('no-wrap', () => { + const Handler = class { + #handler = null + constructor (handler) { + this.#handler = handler + } + + onRequestStart (controller, context) { + return this.#handler?.onRequestStart?.(controller, context) + } + + onRequestUpgrade (controller, statusCode, headers, socket) { + return this.#handler?.onRequestUpgrade?.(controller, statusCode, headers, socket) + } + + onResponseStart (controller, statusCode, headers, statusMessage) { + return this.#handler?.onResponseStart?.(controller, statusCode, headers, statusMessage) + } + + onResponseData (controller, data) { + return this.#handler?.onResponseData?.(controller, data) + } + + onResponseEnd (controller, trailers) { + return this.#handler?.onResponseEnd?.(controller, trailers) + } + + onResponseError (controller, err) { + return this.#handler?.onResponseError?.(controller, err) + } + } + const Controller = class { + #controller = null + constructor (controller) { + this.#controller = controller + } + + abort (reason) { + return this.#controller?.abort?.(reason) + } + + resume () { + return this.#controller?.resume?.() + } + + pause () { + return this.#controller?.pause?.() + } + } + + describe('#onRequestStart', () => { + test('should delegate onRequestStart-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onRequestStart: (controller, ctx) => { + t.equal(controller.constructor, Controller) + t.equal(typeof ctx, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestStart(new Controller(), {}) + }) + + test('should not throw if onRequestStart-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestStart()) + }) + }) + + describe('#onRequestUpgrade', () => { + test('should delegate onRequestUpgrade-method', t => { + t = tspl(t, { plan: 4 }) + const handler = new Handler( + { + onRequestUpgrade: (controller, statusCode, headers, socket) => { + t.equal(controller.constructor, Controller) + t.equal(statusCode, 301) + t.equal(headers['content-type'], 'application/json') + t.equal(typeof socket, 'object') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + }, {}) + }) + + test('should not throw if onRequestUpgrade-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onRequestUpgrade(new Controller(), 301, { + 'content-type': 'application/json' + }, {})) + }) + }) + + describe('#onResponseStart', () => { + test('should delegate onResponseStart-method', t => { + t = tspl(t, { plan: 4 }) + const handler = new Handler( + { + onResponseStart: (controller, statusCode, headers, message) => { + t.equal(controller.constructor, Controller) + t.equal(statusCode, 200) + t.equal(headers['content-type'], 'application/json') + t.equal(message, 'OK') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + }, 'OK') + }) + + test('should not throw if onResponseStart-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseStart(new Controller(), 200, { + 'content-type': 'application/json' + }, 'OK')) + }) + }) + + describe('#onResponseData', () => { + test('should delegate onResponseData-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onResponseData: (controller, chunk) => { + t.equal(controller.constructor, Controller) + t.equal('chunk', chunk) + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseData(new Controller(), 'chunk') + }) + + test('should not throw if onResponseData-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseData(new Controller(), 'chunk')) + }) + }) + + describe('#onResponseEnd', () => { + test('should delegate onResponseEnd-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onResponseEnd: (controller, trailers) => { + t.equal(controller.constructor, Controller) + t.equal(trailers['x-trailer'], 'trailer') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' }) + }) + + test('should not throw if onResponseEnd-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({}) + t.doesNotThrow(() => decorator.onResponseEnd(new Controller(), { 'x-trailer': 'trailer' })) + }) + }) + + describe('#onResponseError', () => { + test('should delegate onError-method', t => { + t = tspl(t, { plan: 2 }) + const handler = new Handler( + { + onResponseError: (controller, err) => { + t.equal(controller.constructor, Controller) + t.equal(err.message, 'Oops!') + } + }) + const decorator = new DecoratorHandler(handler) + decorator.onResponseError(new Controller(), new Error('Oops!')) + }) + + test('should throw if onError-method is not defined in the handler', t => { + t = tspl(t, { plan: 1 }) + const decorator = new DecoratorHandler({ + // To hin and not wrap the instance + onRequestStart: () => {} + }) + t.doesNotThrow(() => decorator.onResponseError(new Controller())) + }) + }) + }) +}) diff --git a/test/dispatcher.js b/test/dispatcher.js new file mode 100644 index 0000000..fbb56e9 --- /dev/null +++ b/test/dispatcher.js @@ -0,0 +1,39 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') + +const Dispatcher = require('../lib/dispatcher/dispatcher') + +class PoorImplementation extends Dispatcher {} + +test('dispatcher implementation', (t) => { + t = tspl(t, { plan: 6 }) + + const dispatcher = new Dispatcher() + t.throws(() => dispatcher.dispatch(), Error, 'throws on unimplemented dispatch') + t.throws(() => dispatcher.close(), Error, 'throws on unimplemented close') + t.throws(() => dispatcher.destroy(), Error, 'throws on unimplemented destroy') + + const poorImplementation = new PoorImplementation() + t.throws(() => poorImplementation.dispatch(), Error, 'throws on unimplemented dispatch') + t.throws(() => poorImplementation.close(), Error, 'throws on unimplemented close') + t.throws(() => poorImplementation.destroy(), Error, 'throws on unimplemented destroy') +}) + +test('dispatcher.compose', (t) => { + t = tspl(t, { plan: 7 }) + + const dispatcher = new Dispatcher() + const interceptor = () => (opts, handler) => {} + // Should return a new dispatcher + t.ok(dispatcher.compose(interceptor) !== dispatcher) + t.throws(() => dispatcher.dispatch({}), Error, 'invalid interceptor') + t.throws(() => dispatcher.dispatch(() => null), Error, 'invalid interceptor') + t.throws(() => dispatcher.dispatch(dispatch => dispatch, () => () => {}, Error, 'invalid interceptor')) + + const composed = dispatcher.compose(interceptor) + t.equal(typeof composed.dispatch, 'function', 'returns an object with a dispatch method') + t.equal(typeof composed.close, 'function', 'returns an object with a close method') + t.equal(typeof composed.destroy, 'function', 'returns an object with a destroy method') +}) diff --git a/test/env-http-proxy-agent.js b/test/env-http-proxy-agent.js new file mode 100644 index 0000000..1a707fa --- /dev/null +++ b/test/env-http-proxy-agent.js @@ -0,0 +1,484 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, describe, after, beforeEach } = require('node:test') +const { EnvHttpProxyAgent, ProxyAgent, Agent, fetch, MockAgent } = require('..') +const { kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent, kClosed, kDestroyed, kProxy } = require('../lib/core/symbols') + +const env = { ...process.env } + +beforeEach(() => { + ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'].forEach((varname) => { + delete process.env[varname] + }) +}) + +after(() => { + process.env = { ...env } +}) + +test('does not create any proxy agents if http_proxy and https_proxy are not set', async (t) => { + t = tspl(t, { plan: 4 }) + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.deepStrictEqual(dispatcher[kHttpProxyAgent], dispatcher[kNoProxyAgent]) + t.deepStrictEqual(dispatcher[kHttpsProxyAgent], dispatcher[kNoProxyAgent]) + return dispatcher.close() +}) + +test('creates one proxy agent for both http and https when only http_proxy is defined', async (t) => { + t = tspl(t, { plan: 5 }) + process.env.http_proxy = 'http://example.com:8080' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.deepStrictEqual(dispatcher[kHttpsProxyAgent], dispatcher[kHttpProxyAgent]) + return dispatcher.close() +}) + +test('creates separate proxy agent for http and https when http_proxy and https_proxy are set', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.http_proxy = 'http://example.com:8080' + process.env.https_proxy = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('handles uppercase HTTP_PROXY and HTTPS_PROXY', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.HTTP_PROXY = 'http://example.com:8080' + process.env.HTTPS_PROXY = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('accepts httpProxy and httpsProxy options', async (t) => { + t = tspl(t, { plan: 6 }) + const opts = { + httpProxy: 'http://example.com:8080', + httpsProxy: 'http://example.com:8443' + } + const dispatcher = new EnvHttpProxyAgent(opts) + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('prefers options over env vars', async (t) => { + t = tspl(t, { plan: 2 }) + const opts = { + httpProxy: 'http://opts.example.com:8080', + httpsProxy: 'http://opts.example.com:8443' + } + process.env.http_proxy = 'http://lower.example.com:8080' + process.env.https_proxy = 'http://lower.example.com:8443' + process.env.HTTP_PROXY = 'http://upper.example.com:8080' + process.env.HTTPS_PROXY = 'http://upper.example.com:8443' + const dispatcher = new EnvHttpProxyAgent(opts) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://opts.example.com:8080/') + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://opts.example.com:8443/') + return dispatcher.close() +}) + +test('prefers lowercase over uppercase env vars', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.HTTP_PROXY = 'http://upper.example.com:8080' + process.env.HTTPS_PROXY = 'http://upper.example.com:8443' + process.env.http_proxy = 'http://lower.example.com:8080' + process.env.https_proxy = 'http://lower.example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://lower.example.com:8080/') + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://lower.example.com:8443/') + return dispatcher.close() +}) + +test('prefers lowercase over uppercase env vars even when empty', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.HTTP_PROXY = 'http://upper.example.com:8080' + process.env.HTTP_PROXY = 'http://upper.example.com:8443' + process.env.http_proxy = '' + process.env.https_proxy = '' + const dispatcher = new EnvHttpProxyAgent() + + t.deepStrictEqual(dispatcher[kHttpProxyAgent], dispatcher[kNoProxyAgent]) + t.deepStrictEqual(dispatcher[kHttpsProxyAgent], dispatcher[kNoProxyAgent]) + return dispatcher.close() +}) + +test('creates a proxy agent only for https when only https_proxy is set', async (t) => { + t = tspl(t, { plan: 5 }) + process.env.https_proxy = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.deepStrictEqual(dispatcher[kHttpProxyAgent], dispatcher[kNoProxyAgent]) + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('closes all agents', async (t) => { + t = tspl(t, { plan: 3 }) + process.env.http_proxy = 'http://example.com:8080' + process.env.https_proxy = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + await dispatcher.close() + t.ok(dispatcher[kNoProxyAgent][kClosed]) + t.ok(dispatcher[kHttpProxyAgent][kClosed]) + t.ok(dispatcher[kHttpsProxyAgent][kClosed]) +}) + +test('destroys all agents', async (t) => { + t = tspl(t, { plan: 3 }) + process.env.http_proxy = 'http://example.com:8080' + process.env.https_proxy = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + await dispatcher.destroy() + t.ok(dispatcher[kNoProxyAgent][kDestroyed]) + t.ok(dispatcher[kHttpProxyAgent][kDestroyed]) + t.ok(dispatcher[kHttpsProxyAgent][kDestroyed]) +}) + +const createEnvHttpProxyAgentWithMocks = (plan = 1, opts = {}) => { + const factory = (origin) => { + const mockAgent = new MockAgent() + const mockPool = mockAgent.get(origin) + let i = 0 + while (i < plan) { + mockPool.intercept({ path: /.*/ }).reply(200, 'OK') + i++ + } + return mockPool + } + process.env.http_proxy = 'http://localhost:8080' + process.env.https_proxy = 'http://localhost:8443' + const dispatcher = new EnvHttpProxyAgent({ ...opts, factory }) + const agentSymbols = [kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent] + agentSymbols.forEach((agentSymbol) => { + const originalDispatch = dispatcher[agentSymbol].dispatch + dispatcher[agentSymbol].dispatch = function () { + dispatcher[agentSymbol].dispatch.called = true + return originalDispatch.apply(this, arguments) + } + dispatcher[agentSymbol].dispatch.called = false + }) + const usesProxyAgent = async (agent, url) => { + await fetch(url, { dispatcher }) + const result = agentSymbols.every((agentSymbol) => agent === agentSymbol + ? dispatcher[agentSymbol].dispatch.called === true + : dispatcher[agentSymbol].dispatch.called === false) + + agentSymbols.forEach((agentSymbol) => { + dispatcher[agentSymbol].dispatch.called = false + }) + return result + } + const doesNotProxy = usesProxyAgent.bind(this, kNoProxyAgent) + return { + dispatcher, + doesNotProxy, + usesProxyAgent + } +} + +test('uses the appropriate proxy for the protocol', async (t) => { + t = tspl(t, { plan: 2 }) + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com/')) + t.ok(await usesProxyAgent(kHttpsProxyAgent, 'https://example.com/')) + return dispatcher.close() +}) + +describe('no_proxy', () => { + test('set to *', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.no_proxy = '*' + const { dispatcher, doesNotProxy } = createEnvHttpProxyAgentWithMocks(2) + t.ok(await doesNotProxy('https://example.com')) + t.ok(await doesNotProxy('http://example.com')) + return dispatcher.close() + }) + + test('set but empty', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.no_proxy = '' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('no entries (comma)', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.no_proxy = ',' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('no entries (whitespace)', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.no_proxy = ' ' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('no entries (multiple whitespace / commas)', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.no_proxy = ',\t,,,\n, ,\r' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('single host', async (t) => { + t = tspl(t, { plan: 9 }) + process.env.no_proxy = 'example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:0')) + t.ok(await doesNotProxy('http://example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://host/example')) + return dispatcher.close() + }) + + test('as an option', async (t) => { + t = tspl(t, { plan: 9 }) + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9, { noProxy: 'example' }) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:0')) + t.ok(await doesNotProxy('http://example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://host/example')) + return dispatcher.close() + }) + + test('subdomain', async (t) => { + t = tspl(t, { plan: 8 }) + process.env.no_proxy = 'sub.example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(8) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:0')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://no.sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub-example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.sub')) + return dispatcher.close() + }) + + test('host + port', async (t) => { + t = tspl(t, { plan: 12 }) + process.env.no_proxy = 'example:80, localhost:3000' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(12) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:0')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + t.ok(await doesNotProxy('http://localhost:3000/')) + t.ok(await doesNotProxy('https://localhost:3000/')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://localhost:3001/')) + t.ok(await usesProxyAgent(kHttpsProxyAgent, 'https://localhost:3001/')) + return dispatcher.close() + }) + + test('host suffix', async (t) => { + t = tspl(t, { plan: 9 }) + process.env.no_proxy = '.example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await doesNotProxy('http://sub.example:80')) + t.ok(await doesNotProxy('http://sub.example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await doesNotProxy('http://a.b.example')) + return dispatcher.close() + }) + + test('host suffix with *.', async (t) => { + t = tspl(t, { plan: 9 }) + process.env.no_proxy = '*.example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await doesNotProxy('http://sub.example:80')) + t.ok(await doesNotProxy('http://sub.example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await doesNotProxy('http://a.b.example')) + return dispatcher.close() + }) + + test('substring suffix', async (t) => { + t = tspl(t, { plan: 10 }) + process.env.no_proxy = '*example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(10) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await doesNotProxy('http://sub.example:80')) + t.ok(await doesNotProxy('http://sub.example:1337')) + t.ok(await doesNotProxy('http://prefexample')) + t.ok(await doesNotProxy('http://a.b.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://host/example')) + return dispatcher.close() + }) + + test('arbitrary wildcards are NOT supported', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.no_proxy = '.*example' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://x.prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + return dispatcher.close() + }) + + test('IP addresses', async (t) => { + t = tspl(t, { plan: 12 }) + process.env.no_proxy = '[::1],[::2]:80,10.0.0.1,10.0.0.2:80' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(12) + t.ok(await doesNotProxy('http://[::1]/')) + t.ok(await doesNotProxy('http://[::1]:80/')) + t.ok(await doesNotProxy('http://[::1]:1337/')) + t.ok(await doesNotProxy('http://[::2]/')) + t.ok(await doesNotProxy('http://[::2]:80/')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://[::2]:1337/')) + t.ok(await doesNotProxy('http://10.0.0.1/')) + t.ok(await doesNotProxy('http://10.0.0.1:80/')) + t.ok(await doesNotProxy('http://10.0.0.1:1337/')) + t.ok(await doesNotProxy('http://10.0.0.2/')) + t.ok(await doesNotProxy('http://10.0.0.2:80/')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://10.0.0.2:1337/')) + return dispatcher.close() + }) + + test('CIDR is NOT supported', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.no_proxy = '127.0.0.1/32' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(2) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://127.0.0.1')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://127.0.0.1/32')) + return dispatcher.close() + }) + + test('127.0.0.1 does NOT match localhost', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.no_proxy = '127.0.0.1' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(2) + t.ok(await doesNotProxy('http://127.0.0.1')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://localhost')) + return dispatcher.close() + }) + + test('protocols that have a default port', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.no_proxy = 'xxx:21,xxx:70,xxx:80,xxx:443' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await doesNotProxy('http://xxx')) + t.ok(await doesNotProxy('http://xxx:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://xxx:1337')) + t.ok(await doesNotProxy('https://xxx')) + t.ok(await doesNotProxy('https://xxx:443')) + t.ok(await usesProxyAgent(kHttpsProxyAgent, 'https://xxx:1337')) + return dispatcher.close() + }) + + test('should not be case sensitive', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.NO_PROXY = 'XXX YYY ZzZ' + const { dispatcher, doesNotProxy } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await doesNotProxy('http://xxx')) + t.ok(await doesNotProxy('http://XXX')) + t.ok(await doesNotProxy('http://yyy')) + t.ok(await doesNotProxy('http://YYY')) + t.ok(await doesNotProxy('http://ZzZ')) + t.ok(await doesNotProxy('http://zZz')) + return dispatcher.close() + }) + + test('prefers lowercase over uppercase', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.NO_PROXY = 'sub.example.com' + process.env.no_proxy = 'example.com' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + return dispatcher.close() + }) + + test('prefers lowercase over uppercase even when it is empty', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.NO_PROXY = 'example.com' + process.env.no_proxy = '' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('handles env var changes', async (t) => { + t = tspl(t, { plan: 4 }) + process.env.no_proxy = 'example.com' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(4) + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + process.env.no_proxy = 'sub.example.com' + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + t.ok(await doesNotProxy('http://sub.example.com')) + return dispatcher.close() + }) + + test('ignores env var changes when set via config', async (t) => { + t = tspl(t, { plan: 4 }) + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(4, { noProxy: 'example.com' }) + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + process.env.no_proxy = 'sub.example.com' + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + return dispatcher.close() + }) +}) diff --git a/test/errors.js b/test/errors.js new file mode 100644 index 0000000..e34b406 --- /dev/null +++ b/test/errors.js @@ -0,0 +1,77 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { describe, test } = require('node:test') + +const errors = require('../lib/core/errors') + +const createScenario = (ErrorClass, defaultMessage, name, code) => ({ + ErrorClass, + defaultMessage, + name, + code +}) + +const scenarios = [ + createScenario(errors.UndiciError, '', 'UndiciError', 'UND_ERR'), + createScenario(errors.ConnectTimeoutError, 'Connect Timeout Error', 'ConnectTimeoutError', 'UND_ERR_CONNECT_TIMEOUT'), + createScenario(errors.HeadersTimeoutError, 'Headers Timeout Error', 'HeadersTimeoutError', 'UND_ERR_HEADERS_TIMEOUT'), + createScenario(errors.HeadersOverflowError, 'Headers Overflow Error', 'HeadersOverflowError', 'UND_ERR_HEADERS_OVERFLOW'), + createScenario(errors.InvalidArgumentError, 'Invalid Argument Error', 'InvalidArgumentError', 'UND_ERR_INVALID_ARG'), + createScenario(errors.InvalidReturnValueError, 'Invalid Return Value Error', 'InvalidReturnValueError', 'UND_ERR_INVALID_RETURN_VALUE'), + createScenario(errors.RequestAbortedError, 'Request aborted', 'AbortError', 'UND_ERR_ABORTED'), + createScenario(errors.InformationalError, 'Request information', 'InformationalError', 'UND_ERR_INFO'), + createScenario(errors.RequestContentLengthMismatchError, 'Request body length does not match content-length header', 'RequestContentLengthMismatchError', 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'), + createScenario(errors.ClientDestroyedError, 'The client is destroyed', 'ClientDestroyedError', 'UND_ERR_DESTROYED'), + createScenario(errors.ClientClosedError, 'The client is closed', 'ClientClosedError', 'UND_ERR_CLOSED'), + createScenario(errors.SocketError, 'Socket error', 'SocketError', 'UND_ERR_SOCKET'), + createScenario(errors.NotSupportedError, 'Not supported error', 'NotSupportedError', 'UND_ERR_NOT_SUPPORTED'), + createScenario(errors.ResponseContentLengthMismatchError, 'Response body length does not match content-length header', 'ResponseContentLengthMismatchError', 'UND_ERR_RES_CONTENT_LENGTH_MISMATCH'), + createScenario(errors.ResponseExceededMaxSizeError, 'Response content exceeded max size', 'ResponseExceededMaxSizeError', 'UND_ERR_RES_EXCEEDED_MAX_SIZE') +] + +scenarios.forEach(scenario => { + describe(scenario.name, () => { + const SAMPLE_MESSAGE = 'sample message' + + const errorWithDefaultMessage = () => new scenario.ErrorClass() + const errorWithProvidedMessage = () => new scenario.ErrorClass(SAMPLE_MESSAGE) + + test('should use default message', t => { + t = tspl(t, { plan: 1 }) + + const error = errorWithDefaultMessage() + + t.strictEqual(error.message, scenario.defaultMessage) + }) + + test('should use provided message', t => { + t = tspl(t, { plan: 1 }) + + const error = errorWithProvidedMessage() + + t.strictEqual(error.message, SAMPLE_MESSAGE) + }) + + test('should have proper fields', t => { + t = tspl(t, { plan: 6 }) + const errorInstances = [errorWithDefaultMessage(), errorWithProvidedMessage()] + errorInstances.forEach(error => { + t.strictEqual(error.name, scenario.name) + t.strictEqual(error.code, scenario.code) + t.ok(error.stack) + }) + }) + }) +}) + +describe('Default HTTPParseError Codes', () => { + test('code and data should be undefined when not set', t => { + t = tspl(t, { plan: 2 }) + + const error = new errors.HTTPParserError('HTTPParserError') + + t.strictEqual(error.code, undefined) + t.strictEqual(error.data, undefined) + }) +}) diff --git a/test/esm-wrapper.js b/test/esm-wrapper.js new file mode 100644 index 0000000..241fb3e --- /dev/null +++ b/test/esm-wrapper.js @@ -0,0 +1,14 @@ +'use strict' + +;(async () => { + try { + await import('./utils/esm-wrapper.mjs') + } catch (e) { + if (e.message === 'Not supported') { + require('node:test') // shows skipped + return + } + console.error(e.stack) + process.exitCode = 1 + } +})() diff --git a/test/eventsource/eventsource-attributes.js b/test/eventsource/eventsource-attributes.js new file mode 100644 index 0000000..0e046af --- /dev/null +++ b/test/eventsource/eventsource-attributes.js @@ -0,0 +1,91 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - eventhandler idl', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'dummy') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + let done = 0 + const eventhandlerIdl = ['onmessage', 'onerror', 'onopen'] + + eventhandlerIdl.forEach((type) => { + test(`Should properly configure the ${type} eventhandler idl`, () => { + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + + // Eventsource eventhandler idl is by default null, + assert.strictEqual(eventSourceInstance[type], null) + + // The eventhandler idl is by default not enumerable. + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(eventSourceInstance, type), false) + + // The eventhandler idl ignores non-functions. + eventSourceInstance[type] = 7 + assert.strictEqual(EventSource[type], undefined) + + // The eventhandler idl accepts functions. + function fn () { + assert.fail('Should not have called the eventhandler') + } + eventSourceInstance[type] = fn + assert.strictEqual(eventSourceInstance[type], fn) + + // The eventhandler idl can be set to another function. + function fn2 () { } + eventSourceInstance[type] = fn2 + assert.strictEqual(eventSourceInstance[type], fn2) + + // The eventhandler idl overrides the previous function. + eventSourceInstance.dispatchEvent(new Event(type)) + + eventSourceInstance.close() + done++ + + if (done === eventhandlerIdl.length) server.close() + }) + }) +}) + +describe('EventSource - constants', () => { + [ + ['CONNECTING', 0], + ['OPEN', 1], + ['CLOSED', 2] + ].forEach((config) => { + test(`Should expose the ${config[0]} constant`, () => { + const [constant, value] = config + + // EventSource exposes the constant. + assert.strictEqual(Object.hasOwn(EventSource, constant), true) + + // The value is properly set. + assert.strictEqual(EventSource[constant], value) + + // The constant is enumerable. + assert.strictEqual(Object.prototype.propertyIsEnumerable.call(EventSource, constant), true) + + // The constant is not writable. + try { + EventSource[constant] = 666 + } catch (e) { + assert.strictEqual(e instanceof TypeError, true) + } + // The constant is not configurable. + try { + delete EventSource[constant] + } catch (e) { + assert.strictEqual(e instanceof TypeError, true) + } + assert.strictEqual(EventSource[constant], value) + }) + }) +}) diff --git a/test/eventsource/eventsource-close.js b/test/eventsource/eventsource-close.js new file mode 100644 index 0000000..5b6397d --- /dev/null +++ b/test/eventsource/eventsource-close.js @@ -0,0 +1,59 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { setTimeout } = require('node:timers/promises') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - close', () => { + test('should not emit error when closing the EventSource Instance', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data: hello\n\n') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.close() + await setTimeout(1000, { ref: false }) + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should set readyState to CLOSED', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data: hello\n\n') + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.readyState, EventSource.OPEN) + eventSourceInstance.close() + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + + await setTimeout(2000, { ref: false }) + server.close() + }) +}) diff --git a/test/eventsource/eventsource-connect.js b/test/eventsource/eventsource-connect.js new file mode 100644 index 0000000..75508ee --- /dev/null +++ b/test/eventsource/eventsource-connect.js @@ -0,0 +1,184 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - sending correct request headers', () => { + test('should send request with connection keep-alive', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with sec-fetch-mode set to cors', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['sec-fetch-mode'], 'cors') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with pragma and cache-control set to no-cache', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['cache-control'], 'no-cache') + assert.strictEqual(req.headers.pragma, 'no-cache') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with accept text/event-stream', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.accept, 'text/event-stream') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) + +describe('EventSource - received response must have content-type to be text/event-stream', () => { + test('should send request with accept text/event-stream', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should send request with accept text/event-stream;', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream;' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should handle content-type text/event-stream;charset=UTF-8 properly', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream;charset=UTF-8' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('should throw if content-type is text/html properly', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/html' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } + + eventSourceInstance.onerror = () => { + eventSourceInstance.close() + server.close() + } + }) +}) diff --git a/test/eventsource/eventsource-constructor-stringify.js b/test/eventsource/eventsource-constructor-stringify.js new file mode 100644 index 0000000..aee1d02 --- /dev/null +++ b/test/eventsource/eventsource-constructor-stringify.js @@ -0,0 +1,31 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - constructor stringify', () => { + test('should stringify argument', async () => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers.connection, 'keep-alive') + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource({ toString: function () { return `http://localhost:${port}` } }) + eventSourceInstance.onopen = () => { + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) diff --git a/test/eventsource/eventsource-constructor.js b/test/eventsource/eventsource-constructor.js new file mode 100644 index 0000000..a5e25e3 --- /dev/null +++ b/test/eventsource/eventsource-constructor.js @@ -0,0 +1,53 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - withCredentials', () => { + test('withCredentials should be false by default', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, false) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) + + test('withCredentials can be set to true', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`, { withCredentials: true }) + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.withCredentials, true) + eventSourceInstance.close() + server.close() + } + + eventSourceInstance.onerror = () => { + assert.fail('Should not have errored') + } + }) +}) diff --git a/test/eventsource/eventsource-custom-dispatcher.js b/test/eventsource/eventsource-custom-dispatcher.js new file mode 100644 index 0000000..e04c2f8 --- /dev/null +++ b/test/eventsource/eventsource-custom-dispatcher.js @@ -0,0 +1,38 @@ +'use strict' + +const { createServer } = require('node:http') +const { once } = require('node:events') +const { Agent, EventSource } = require('../..') +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') + +test('EventSource allows setting custom dispatcher.', async (t) => { + const { completed, deepStrictEqual } = tspl(t, { plan: 1 }) + + const server = createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + deepStrictEqual(req.headers['x-customer-header'], 'hello world') + + res.end() + }).listen(0) + + t.after(() => { + server.close() + eventSourceInstance.close() + }) + + await once(server, 'listening') + + class CustomHeaderAgent extends Agent { + dispatch (opts) { + opts.headers['x-customer-header'] = 'hello world' + return super.dispatch(...arguments) + } + } + + const eventSourceInstance = new EventSource(`http://localhost:${server.address().port}`, { + dispatcher: new CustomHeaderAgent() + }) + + await completed +}) diff --git a/test/eventsource/eventsource-message.js b/test/eventsource/eventsource-message.js new file mode 100644 index 0000000..d7843bc --- /dev/null +++ b/test/eventsource/eventsource-message.js @@ -0,0 +1,365 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { setTimeout } = require('node:timers/promises') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - message', () => { + test('Should not emit a message if only retry field was sent', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('retry: 100\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= 100) + assert.ok(Date.now() - start < 1000) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + eventSourceInstance.onmessage = () => { + finishedPromise.reject('Should not have received a message') + eventSourceInstance.close() + server.close() + } + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should not emit a message if no data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:message\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + + eventSourceInstance.onmessage = () => { + finishedPromise.reject('Should not have received a message') + eventSourceInstance.close() + server.close() + } + + await setTimeout(500) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + + await finishedPromise.promise + }) + + test('Should emit a custom type message if data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\ndata:test\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a message event if data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data:test\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('message', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a message event if data as a field is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('message', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a custom message event if data is empty', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\ndata:\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a message event if data is empty', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('data:\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('message', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should emit a custom message event if data only as a field is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\ndata\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.resolve() + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) + + test('Should not emit a custom type message if no data is provided', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer(async (req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('event:custom\n\n') + await setTimeout(100) + + res.end() + }) + + server.listen(0) + + let reconnectionCount = 0 + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + if (++reconnectionCount === 2) { + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + } + eventSourceInstance.addEventListener('custom', () => { + finishedPromise.reject('Should not have received a message') + eventSourceInstance.close() + server.close() + }) + + await setTimeout(500) + + await finishedPromise.promise + }) +}) diff --git a/test/eventsource/eventsource-properties.js b/test/eventsource/eventsource-properties.js new file mode 100644 index 0000000..58a02a9 --- /dev/null +++ b/test/eventsource/eventsource-properties.js @@ -0,0 +1,15 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { EventSource } = require('../..') // assuming the test is in test/eventsource/ + +test('EventSource.prototype properties are configured correctly', () => { + const props = Object.entries(Object.getOwnPropertyDescriptors(EventSource.prototype)) + + for (const [key, value] of props) { + if (key !== 'constructor') { + assert(value.enumerable, `${key} is not enumerable`) + } + } +}) diff --git a/test/eventsource/eventsource-reconnect.js b/test/eventsource/eventsource-reconnect.js new file mode 100644 index 0000000..50d5f6d --- /dev/null +++ b/test/eventsource/eventsource-reconnect.js @@ -0,0 +1,155 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource, defaultReconnectionTime } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - reconnect', () => { + test('Should reconnect on connection close', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + await finishedPromise.promise + }) + + test('Should reconnect on with reconnection timeout', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= defaultReconnectionTime) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + await finishedPromise.promise + }) + + test('Should reconnect on with modified reconnection timeout', async () => { + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('retry: 100\n\n') + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= 100) + assert.ok(Date.now() - start < 1000) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + await finishedPromise.promise + }) + + test('Should reconnect and send lastEventId', async () => { + let requestCount = 0 + + const finishedPromise = { + promise: undefined, + resolve: undefined, + reject: undefined + } + + finishedPromise.promise = new Promise((resolve, reject) => { + finishedPromise.resolve = resolve + finishedPromise.reject = reject + }) + + const server = http.createServer((req, res) => { + res.writeHead(200, 'OK', { 'Content-Type': 'text/event-stream' }) + res.write('id: 1337\n\n') + if (requestCount++ !== 0) { + assert.strictEqual(req.headers['last-event-id'], '1337') + } + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const start = Date.now() + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onopen = async () => { + eventSourceInstance.onopen = () => { + assert.ok(Date.now() - start >= 3000) + server.close() + eventSourceInstance.close() + finishedPromise.resolve() + } + } + + await finishedPromise.promise + }) +}) diff --git a/test/eventsource/eventsource-redirecting.js b/test/eventsource/eventsource-redirecting.js new file mode 100644 index 0000000..07bd36e --- /dev/null +++ b/test/eventsource/eventsource-redirecting.js @@ -0,0 +1,119 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - redirecting', () => { + [301, 302, 307, 308].forEach((statusCode) => { + test(`Should redirect on ${statusCode} status code`, async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(statusCode, undefined, { Location: '/target' }) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(200, 'dummy', { 'Content-Type': 'text/event-stream' }) + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = (e) => { + assert.fail('Should not have errored') + } + eventSourceInstance.onopen = () => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) + eventSourceInstance.close() + server.close() + } + }) + }) + + test('Stop trying to connect when getting a 204 response', async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(301, undefined, { Location: '/target' }) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(204, 'OK') + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = (event) => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + server.close() + } + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } + }) + + test('Throw when missing a Location header', async () => { + const server = http.createServer((req, res) => { + if (res.req.url === '/redirect') { + res.writeHead(301, undefined) + res.end() + } else if (res.req.url === '/target') { + res.writeHead(204, 'OK') + res.end() + } + }) + + server.listen(0) + await events.once(server, 'listening') + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}/redirect`) + eventSourceInstance.onerror = () => { + assert.strictEqual(eventSourceInstance.url, `http://localhost:${port}/redirect`) + assert.strictEqual(eventSourceInstance.readyState, EventSource.CLOSED) + server.close() + } + }) + + test('Should set origin attribute of messages after redirecting', async () => { + const targetServer = http.createServer((req, res) => { + if (res.req.url === '/target') { + res.writeHead(200, undefined, { 'Content-Type': 'text/event-stream' }) + res.write('event: message\ndata: test\n\n') + } + }) + targetServer.listen(0) + await events.once(targetServer, 'listening') + const targetPort = targetServer.address().port + + const sourceServer = http.createServer((req, res) => { + res.writeHead(301, undefined, { Location: `http://127.0.0.1:${targetPort}/target` }) + res.end() + }) + sourceServer.listen(0) + await events.once(sourceServer, 'listening') + + const sourcePort = sourceServer.address().port + + const eventSourceInstance = new EventSource(`http://127.0.0.1:${sourcePort}/redirect`) + eventSourceInstance.onmessage = (event) => { + assert.strictEqual(event.origin, `http://127.0.0.1:${targetPort}`) + eventSourceInstance.close() + targetServer.close() + sourceServer.close() + } + eventSourceInstance.onerror = (e) => { + assert.fail('Should not have errored') + } + }) +}) diff --git a/test/eventsource/eventsource-request-status-error.js b/test/eventsource/eventsource-request-status-error.js new file mode 100644 index 0000000..3b73d22 --- /dev/null +++ b/test/eventsource/eventsource-request-status-error.js @@ -0,0 +1,36 @@ +'use strict' + +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - status error', () => { + [204, 205, 210, 299, 404, 410, 503].forEach((statusCode) => { + test(`Should error on ${statusCode} status code`, async () => { + const server = http.createServer((req, res) => { + res.writeHead(statusCode, 'dummy', { 'Content-Type': 'text/event-stream' }) + res.end() + }) + + server.listen(0) + await events.once(server, 'listening') + + const port = server.address().port + + const eventSourceInstance = new EventSource(`http://localhost:${port}`) + eventSourceInstance.onerror = (e) => { + assert.strictEqual(this.readyState, this.CLOSED) + eventSourceInstance.close() + server.close() + } + eventSourceInstance.onmessage = () => { + assert.fail('Should not have received a message') + } + eventSourceInstance.onopen = () => { + assert.fail('Should not have opened') + } + }) + }) +}) diff --git a/test/eventsource/eventsource-stream-bom.js b/test/eventsource/eventsource-stream-bom.js new file mode 100644 index 0000000..b447832 --- /dev/null +++ b/test/eventsource/eventsource-stream-bom.js @@ -0,0 +1,135 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/web/eventsource/eventsource-stream') + +describe('EventSourceStream - handle BOM', () => { + test('Remove BOM from the beginning of the stream. 1 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Remove BOM from the beginning of the stream. 2 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 2) { + stream.write(Buffer.from([content[i], content[i + 1]])) + } + }) + + test('Remove BOM from the beginning of the stream. 3 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 3) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2]])) + } + }) + + test('Remove BOM from the beginning of the stream. 4 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`\uFEFF${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 4) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2], content[i + 3]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 1 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 1) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 2 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 2) { + stream.write(Buffer.from([content[i], content[i + 1]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 3 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 3) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2]])) + } + }) + + test('Not containing BOM from the beginning of the stream. 4 byte chunks', () => { + const dataField = 'data: Hello' + const content = Buffer.from(`${dataField}`, 'utf8') + + const stream = new EventSourceStream() + + stream.parseLine = function (line) { + assert.strictEqual(line.byteLength, dataField.length) + assert.strictEqual(line.toString(), dataField) + } + + for (let i = 0; i < content.length; i += 4) { + stream.write(Buffer.from([content[i], content[i + 1], content[i + 2], content[i + 3]])) + } + }) +}) diff --git a/test/eventsource/eventsource-stream-parse-line.js b/test/eventsource/eventsource-stream-parse-line.js new file mode 100644 index 0000000..45069a2 --- /dev/null +++ b/test/eventsource/eventsource-stream-parse-line.js @@ -0,0 +1,281 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/web/eventsource/eventsource-stream') + +describe('EventSourceStream - parseLine', () => { + const defaultEventSourceSettings = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should push an unmodified event when line is empty', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field with empty string if not containing data', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data:', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, '') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field with empty string if not containing data (containing space after colon)', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: ', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, '') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field with a string containing space if having more than one space after colon', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: ', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, ' ') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set value properly, even if the line contains multiple colons', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: : ', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, ': ') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set the data field when containing data', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('data: Hello', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should ignore comments', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from(':comment', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set retry field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('retry: 1000', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, '1000') + }) + + test('Should set id field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('id: 1234', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, '1234') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('Should set id field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('event: custom', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, 'custom') + assert.strictEqual(event.retry, undefined) + }) + + test('Should ignore invalid field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + + stream.parseLine(Buffer.from('comment: invalid', 'utf8'), event) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 0) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('bogus retry', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + 'retry:3000\nretry:1000x\ndata:x'.split('\n').forEach((line) => { + stream.parseLine(Buffer.from(line, 'utf8'), event) + }) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 2) + assert.strictEqual(event.data, 'x') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, '3000') + }) + + test('bogus id', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + 'id:3000\nid:30\x000\ndata:x'.split('\n').forEach((line) => { + stream.parseLine(Buffer.from(line, 'utf8'), event) + }) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 2) + assert.strictEqual(event.data, 'x') + assert.strictEqual(event.id, '3000') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) + + test('empty event', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + const event = {} + 'event: \ndata:data'.split('\n').forEach((line) => { + stream.parseLine(Buffer.from(line, 'utf8'), event) + }) + + assert.strictEqual(typeof event, 'object') + assert.strictEqual(Object.keys(event).length, 1) + assert.strictEqual(event.data, 'data') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.retry, undefined) + }) +}) diff --git a/test/eventsource/eventsource-stream-process-event.js b/test/eventsource/eventsource-stream-process-event.js new file mode 100644 index 0000000..a0452ad --- /dev/null +++ b/test/eventsource/eventsource-stream-process-event.js @@ -0,0 +1,137 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/web/eventsource/eventsource-stream') + +describe('EventSourceStream - processEvent', () => { + const defaultEventSourceSettings = { + origin: 'example.com', + reconnectionTime: 1000 + } + + test('Should set the defined origin as the origin of the MessageEvent', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, undefined) + assert.strictEqual(event.options.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.on('error', (error) => { + assert.fail(error) + }) + + stream.processEvent({}) + }) + + test('Should set reconnectionTime to 4000 if event contains retry field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.processEvent({ + retry: '4000' + }) + + assert.strictEqual(stream.state.reconnectionTime, 4000) + }) + + test('Dispatches a MessageEvent with data', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.options.data, 'Hello') + assert.strictEqual(event.options.lastEventId, undefined) + assert.strictEqual(event.options.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.on('error', (error) => { + assert.fail(error) + }) + + stream.processEvent({ + data: 'Hello' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, when event contains id field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, '1234') + assert.strictEqual(event.options.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + id: '1234' + }) + }) + + test('Dispatches a MessageEvent with lastEventId, reusing the persisted', () => { + // lastEventId + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings, + lastEventId: '1234' + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'message') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, '1234') + assert.strictEqual(event.options.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({}) + }) + + test('Dispatches a MessageEvent with type custom, when event contains type field', () => { + const stream = new EventSourceStream({ + eventSourceSettings: { + ...defaultEventSourceSettings + } + }) + + stream.on('data', (event) => { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.type, 'custom') + assert.strictEqual(event.options.data, null) + assert.strictEqual(event.options.lastEventId, undefined) + assert.strictEqual(event.options.origin, 'example.com') + assert.strictEqual(stream.state.reconnectionTime, 1000) + }) + + stream.processEvent({ + event: 'custom' + }) + }) +}) diff --git a/test/eventsource/eventsource-stream.js b/test/eventsource/eventsource-stream.js new file mode 100644 index 0000000..8a74f53 --- /dev/null +++ b/test/eventsource/eventsource-stream.js @@ -0,0 +1,298 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSourceStream } = require('../../lib/web/eventsource/eventsource-stream') + +describe('EventSourceStream', () => { + test('ignore empty chunks', () => { + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.fail() + } + stream.write(Buffer.alloc(0)) + }) + + test('Simple event with data field.', () => { + const content = Buffer.from('data: Hello\n\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process CR as EOL.', () => { + const content = Buffer.from('data: Hello\r\r', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process CRLF as EOL.', () => { + const content = Buffer.from('data: Hello\r\n\r\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process mixed CR and CRLF as EOL.', () => { + const content = Buffer.from('data: Hello\r\r\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should also process mixed LF and CRLF as EOL.', () => { + const content = Buffer.from('data: Hello\n\r\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should ignore comments', () => { + const content = Buffer.from(':data: Hello\n\n', 'utf8') + + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should fire two events.', () => { + // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + const content = Buffer.from('data\n\ndata\ndata\n\ndata:', 'utf8') + const stream = new EventSourceStream() + + let count = 0 + stream.processEvent = function (event) { + switch (count) { + case 0: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, '') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + break + } + case 1: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, '\n') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + } + count++ + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('Should fire two identical events.', () => { + // @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + const content = Buffer.from('data:test\n\ndata: test\n\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'test') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + for (let i = 0; i < content.length; i++) { + stream.write(Buffer.from([content[i]])) + } + }) + + test('ignores empty comments', () => { + const content = Buffer.from('data: Hello\n\n:\n\ndata: World\n\n', 'utf8') + const stream = new EventSourceStream() + + let count = 0 + + stream.processEvent = function (event) { + switch (count) { + case 0: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'Hello') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + break + } + case 1: { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, 'World') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + break + } + default: { + assert.fail() + } + } + count++ + } + + stream.write(content) + }) + + test('comment fest', () => { + const longstring = new Array(2 * 1024 + 1).join('x') + const content = Buffer.from(`data:1\r:\0\n:\r\ndata:2\n:${longstring}\rdata:3\n:data:fail\r:${longstring}\ndata:4\n\n`, 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.data, '1\n2\n3\n4') + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + } + + stream.write(content) + }) + + test('comment fest', () => { + const content = Buffer.from('data:\n\ndata\ndata\n\ndata:test\n\n', 'utf8') + const stream = new EventSourceStream() + + let count = 0 + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + switch (count) { + case 0: { + assert.strictEqual(event.data, '') + break + } + case 1: { + assert.strictEqual(event.data, '\n') + break + } + case 2: { + assert.strictEqual(event.data, 'test') + break + } + default: { + assert.fail() + } + } + count++ + } + stream.write(content) + }) + + test('newline test', () => { + const content = Buffer.from('data:test\r\ndata\ndata:test\r\n\r\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + assert.strictEqual(event.data, 'test\n\ntest') + } + stream.write(content) + }) + + test('newline test', () => { + const content = Buffer.from('data:test\n data\ndata\nfoobar:xxx\njustsometext\n:thisisacommentyay\ndata:test\n\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + assert.strictEqual(event.data, 'test\n\ntest') + } + stream.write(content) + }) + + test('newline test', () => { + const content = Buffer.from('data:test\n data\ndata\nfoobar:xxx\njustsometext\n:thisisacommentyay\ndata:test\n\n', 'utf8') + const stream = new EventSourceStream() + + stream.processEvent = function (event) { + assert.strictEqual(typeof event, 'object') + assert.strictEqual(event.event, undefined) + assert.strictEqual(event.id, undefined) + assert.strictEqual(event.retry, undefined) + assert.strictEqual(event.data, 'test\n\ntest') + } + stream.write(content) + }) +}) diff --git a/test/eventsource/eventsource.js b/test/eventsource/eventsource.js new file mode 100644 index 0000000..f7b6283 --- /dev/null +++ b/test/eventsource/eventsource.js @@ -0,0 +1,14 @@ +'use strict' + +const assert = require('node:assert') +const { test, describe } = require('node:test') +const { EventSource } = require('../../lib/web/eventsource/eventsource') + +describe('EventSource - constructor', () => { + test('Not providing url argument should throw', () => { + assert.throws(() => new EventSource(), TypeError) + }) + test('Throw DOMException if URL is invalid', () => { + assert.throws(() => new EventSource('http:'), { message: /Invalid URL/ }) + }) +}) diff --git a/test/eventsource/util.js b/test/eventsource/util.js new file mode 100644 index 0000000..fa6c854 --- /dev/null +++ b/test/eventsource/util.js @@ -0,0 +1,18 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') +const { isASCIINumber, isValidLastEventId } = require('../../lib/web/eventsource/util') + +test('isValidLastEventId', () => { + assert.strictEqual(isValidLastEventId('valid'), true) + assert.strictEqual(isValidLastEventId('in\u0000valid'), false) + assert.strictEqual(isValidLastEventId('in\x00valid'), false) + assert.strictEqual(isValidLastEventId('…'), true) +}) + +test('isASCIINumber', () => { + assert.strictEqual(isASCIINumber('123'), true) + assert.strictEqual(isASCIINumber(''), false) + assert.strictEqual(isASCIINumber('123a'), false) +}) diff --git a/test/examples.js b/test/examples.js new file mode 100644 index 0000000..e06b2d4 --- /dev/null +++ b/test/examples.js @@ -0,0 +1,68 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { createServer } = require('node:http') +const { test, after } = require('node:test') +const { once } = require('node:events') +const examples = require('../docs/examples/request.js') + +test('request examples', async (t) => { + t = tspl(t, { plan: 7 }) + + let lastReq + const exampleServer = createServer((req, res) => { + lastReq = req + if (req.method === 'DELETE') { + res.statusCode = 204 + return res.end() + } else if (req.method === 'POST') { + res.statusCode = 200 + if (req.url === '/json') { + res.setHeader('content-type', 'application/json') + res.end('{"hello":"JSON Response"}') + } else { + res.end('hello=form') + } + } else { + res.statusCode = 200 + res.end('hello') + } + }) + + const errorServer = createServer((req, res) => { + lastReq = req + res.statusCode = 400 + res.setHeader('content-type', 'application/json') + res.end('{"error":"an error"}') + }) + + after(() => exampleServer.close()) + after(() => errorServer.close()) + + exampleServer.listen(0) + errorServer.listen(0) + + await Promise.all([ + once(exampleServer, 'listening'), + once(errorServer, 'listening') + ]) + + await examples.getRequest(exampleServer.address().port) + t.strictEqual(lastReq.method, 'GET') + + await examples.postJSONRequest(exampleServer.address().port) + t.strictEqual(lastReq.method, 'POST') + t.strictEqual(lastReq.headers['content-type'], 'application/json') + + await examples.postFormRequest(exampleServer.address().port) + t.strictEqual(lastReq.method, 'POST') + t.strictEqual(lastReq.headers['content-type'], 'application/x-www-form-urlencoded') + + await examples.deleteRequest(exampleServer.address().port) + t.strictEqual(lastReq.method, 'DELETE') + + await examples.deleteRequest(errorServer.address().port) + t.strictEqual(lastReq.method, 'DELETE') + + await t.completed +}) diff --git a/test/fetch/407-statuscode-window-null.js b/test/fetch/407-statuscode-window-null.js new file mode 100644 index 0000000..e03042e --- /dev/null +++ b/test/fetch/407-statuscode-window-null.js @@ -0,0 +1,23 @@ +'use strict' + +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { test } = require('node:test') +const assert = require('node:assert') + +const { closeServerAsPromise } = require('../utils/node-http') + +test('Receiving a 407 status code w/ a window option present should reject', async (t) => { + const server = createServer((req, res) => { + res.statusCode = 407 + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + // if init.window exists, the spec tells us to set request.window to 'no-window', + // which later causes the request to be rejected if the status code is 407 + await assert.rejects(fetch(`http://localhost:${server.address().port}`, { window: null })) +}) diff --git a/test/fetch/abort.js b/test/fetch/abort.js new file mode 100644 index 0000000..73b4764 --- /dev/null +++ b/test/fetch/abort.js @@ -0,0 +1,52 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { closeServerAsPromise } = require('../utils/node-http') + +test('allows aborting with custom errors', async (t) => { + const server = createServer().listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + await t.test('Using AbortSignal.timeout with cause', async () => { + const { strictEqual } = tspl(t, { plan: 2 }) + try { + await fetch(`http://localhost:${server.address().port}`, { + signal: AbortSignal.timeout(50) + }) + assert.fail('should throw') + } catch (err) { + if (err.name === 'TypeError') { + const cause = err.cause + strictEqual(cause.name, 'HeadersTimeoutError') + strictEqual(cause.code, 'UND_ERR_HEADERS_TIMEOUT') + } else if (err.name === 'TimeoutError') { + strictEqual(err.code, DOMException.TIMEOUT_ERR) + strictEqual(err.cause, undefined) + } else { + throw err + } + } + }) + + t.test('Error defaults to an AbortError DOMException', async () => { + const ac = new AbortController() + ac.abort() // no reason + + await assert.rejects( + fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal + }), + { + name: 'AbortError', + code: DOMException.ABORT_ERR + } + ) + }) +}) diff --git a/test/fetch/abort2.js b/test/fetch/abort2.js new file mode 100644 index 0000000..fec300b --- /dev/null +++ b/test/fetch/abort2.js @@ -0,0 +1,60 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') + +const { closeServerAsPromise } = require('../utils/node-http') + +/* global AbortController */ + +test('parallel fetch with the same AbortController works as expected', async (t) => { + const body = { + fixes: 1389, + bug: 'Ensure request is not aborted before enqueueing bytes into stream.' + } + + const server = createServer((req, res) => { + res.statusCode = 200 + res.end(JSON.stringify(body)) + }) + + t.after(closeServerAsPromise(server)) + + const abortController = new AbortController() + + async function makeRequest () { + const result = await fetch(`http://localhost:${server.address().port}`, { + signal: abortController.signal + }).then(response => response.json()) + + abortController.abort() + return result + } + + server.listen(0) + await once(server, 'listening') + + const requests = Array.from({ length: 10 }, makeRequest) + const result = await Promise.allSettled(requests) + + // since the requests are running parallel, any of them could resolve first. + // therefore we cannot rely on the order of the requests sent. + const { resolved, rejected } = result.reduce((a, b) => { + if (b.status === 'rejected') { + a.rejected.push(b) + } else { + a.resolved.push(b) + } + + return a + }, { resolved: [], rejected: [] }) + + assert.strictEqual(rejected.length, 9) // out of 10 requests, only 1 should succeed + assert.strictEqual(resolved.length, 1) + + assert.ok(rejected.every(rej => rej.reason?.code === DOMException.ABORT_ERR)) + assert.deepStrictEqual(resolved[0].value, body) +}) diff --git a/test/fetch/about-uri.js b/test/fetch/about-uri.js new file mode 100644 index 0000000..fc8ef5f --- /dev/null +++ b/test/fetch/about-uri.js @@ -0,0 +1,20 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { fetch } = require('../..') + +test('fetching about: uris', async (t) => { + await t.test('about:blank', async () => { + await assert.rejects(fetch('about:blank')) + }) + + await t.test('All other about: urls should return an error', async () => { + try { + await fetch('about:config') + assert.fail('fetching about:config should fail') + } catch (e) { + assert.ok(e, 'this error was expected') + } + }) +}) diff --git a/test/fetch/blob-uri.js b/test/fetch/blob-uri.js new file mode 100644 index 0000000..5d3d3f4 --- /dev/null +++ b/test/fetch/blob-uri.js @@ -0,0 +1,89 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { fetch } = require('../..') +const { Blob } = require('node:buffer') + +test('fetching blob: uris', async (t) => { + const blobContents = 'hello world' + /** @type {import('buffer').Blob} */ + let blob + /** @type {string} */ + let objectURL + + t.beforeEach(() => { + blob = new Blob([blobContents]) + objectURL = URL.createObjectURL(blob) + }) + + await t.test('a normal fetch request works', async () => { + const res = await fetch(objectURL) + + assert.strictEqual(blobContents, await res.text()) + assert.strictEqual(blob.type, res.headers.get('Content-Type')) + assert.strictEqual(`${blob.size}`, res.headers.get('Content-Length')) + }) + + await t.test('non-GET method to blob: fails', async () => { + try { + await fetch(objectURL, { + method: 'POST' + }) + assert.fail('expected POST to blob: uri to fail') + } catch (e) { + assert.ok(e, 'Got the expected error') + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L36-L41 + await t.test('fetching revoked URL should fail', async () => { + URL.revokeObjectURL(objectURL) + + try { + await fetch(objectURL) + assert.fail('expected revoked blob: url to fail') + } catch (e) { + assert.ok(e, 'Got the expected error') + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L28-L34 + await t.test('works with a fragment', async () => { + const res = await fetch(objectURL + '#fragment') + + assert.strictEqual(blobContents, await res.text()) + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L52-L56 + await t.test('Appending a query string to blob: url should cause fetch to fail', async () => { + try { + await fetch(objectURL + '?querystring') + assert.fail('expected ?querystring blob: url to fail') + } catch (e) { + assert.ok(e, 'Got the expected error') + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L58-L62 + await t.test('Appending a path should cause fetch to fail', async () => { + try { + await fetch(objectURL + '/path') + assert.fail('expected /path blob: url to fail') + } catch (e) { + assert.ok(e, 'Got the expected error') + } + }) + + // https://github.com/web-platform-tests/wpt/blob/7b0ebaccc62b566a1965396e5be7bb2bc06f841f/FileAPI/url/resources/fetch-tests.js#L64-L70 + await t.test('these http methods should fail', async () => { + for (const method of ['HEAD', 'POST', 'DELETE', 'OPTIONS', 'PUT', 'CUSTOM']) { + try { + await fetch(objectURL, { method }) + assert.fail(`${method} fetch should have failed`) + } catch (e) { + assert.ok(e, `${method} blob url - test succeeded`) + } + } + }) +}) diff --git a/test/fetch/bundle.js b/test/fetch/bundle.js new file mode 100644 index 0000000..f073e9e --- /dev/null +++ b/test/fetch/bundle.js @@ -0,0 +1,39 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') + +const { Response, Request, FormData, Headers, MessageEvent, CloseEvent, ErrorEvent } = require('../../undici-fetch') + +test('bundle sets constructor.name and .name properly', () => { + assert.strictEqual(new Response().constructor.name, 'Response') + assert.strictEqual(Response.name, 'Response') + + assert.strictEqual(new Request('http://a').constructor.name, 'Request') + assert.strictEqual(Request.name, 'Request') + + assert.strictEqual(new Headers().constructor.name, 'Headers') + assert.strictEqual(Headers.name, 'Headers') + + assert.strictEqual(new FormData().constructor.name, 'FormData') + assert.strictEqual(FormData.name, 'FormData') +}) + +test('regression test for https://github.com/nodejs/node/issues/50263', () => { + const request = new Request('https://a', { + headers: { + test: 'abc' + }, + method: 'POST' + }) + + const request1 = new Request(request, { body: 'does not matter' }) + + assert.strictEqual(request1.headers.get('test'), 'abc') +}) + +test('WebSocket related events are exported', (t) => { + assert.deepStrictEqual(typeof CloseEvent, 'function') + assert.deepStrictEqual(typeof MessageEvent, 'function') + assert.deepStrictEqual(typeof ErrorEvent, 'function') +}) diff --git a/test/fetch/client-error-stack-trace.js b/test/fetch/client-error-stack-trace.js new file mode 100644 index 0000000..146443b --- /dev/null +++ b/test/fetch/client-error-stack-trace.js @@ -0,0 +1,27 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { fetch, setGlobalDispatcher, Agent } = require('../..') +const { fetch: fetchIndex } = require('../../index-fetch') + +setGlobalDispatcher(new Agent({ + headersTimeout: 500, + connectTimeout: 500 +})) + +test('FETCH: request errors and prints trimmed stack trace', async (t) => { + try { + await fetch('http://a.com') + } catch (error) { + assert.ok(error.stack.includes(`at async TestContext. (${__filename}`)) + } +}) + +test('FETCH-index: request errors and prints trimmed stack trace', async (t) => { + try { + await fetchIndex('http://a.com') + } catch (error) { + assert.ok(error.stack.includes(`at async TestContext. (${__filename}`)) + } +}) diff --git a/test/fetch/client-fetch.js b/test/fetch/client-fetch.js new file mode 100644 index 0000000..3003021 --- /dev/null +++ b/test/fetch/client-fetch.js @@ -0,0 +1,711 @@ +/* globals AbortController */ + +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { createServer } = require('node:http') +const { Blob, File } = require('node:buffer') +const { fetch, Response, Request, FormData } = require('../..') +const { Client, setGlobalDispatcher, Agent } = require('../..') +const nodeFetch = require('../../index-fetch') +const { once } = require('node:events') +const { gzipSync } = require('node:zlib') +const { promisify } = require('node:util') +const { randomFillSync, createHash } = require('node:crypto') + +const { closeServerAsPromise } = require('../utils/node-http') + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 +})) + +test('function signature', (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + + strictEqual(fetch.name, 'fetch') + strictEqual(fetch.length, 1) +}) + +test('args validation', async (t) => { + const { rejects } = tspl(t, { plan: 2 }) + + await rejects(fetch(), TypeError) + await rejects(fetch('ftp://unsupported'), TypeError) +}) + +test('request json', (t, done) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + deepStrictEqual(obj, await body.json()) + done() + }) +}) + +test('request text', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + strictEqual(JSON.stringify(obj), await body.text()) + done() + }) +}) + +test('request arrayBuffer', (t, done) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + deepStrictEqual(Buffer.from(JSON.stringify(obj)), Buffer.from(await body.arrayBuffer())) + done() + }) +}) + +test('should set type of blob object to the value of the `Content-Type` header from response', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(obj)) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`) + strictEqual('application/json', (await response.blob()).type) + done() + }) +}) + +test('pre aborted with readable request body', (t, done) => { + const { strictEqual } = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const ac = new AbortController() + ac.abort() + await fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal, + method: 'POST', + body: new ReadableStream({ + async cancel (reason) { + strictEqual(reason.name, 'AbortError') + } + }), + duplex: 'half' + }).catch(err => { + strictEqual(err.name, 'AbortError') + }).finally(done) + }) +}) + +test('pre aborted with closed readable request body', (t, done) => { + const { ok, strictEqual } = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const ac = new AbortController() + ac.abort() + const body = new ReadableStream({ + async start (c) { + ok(true) + c.close() + }, + async cancel (reason) { + assert.fail() + } + }) + queueMicrotask(() => { + fetch(`http://localhost:${server.address().port}`, { + signal: ac.signal, + method: 'POST', + body, + duplex: 'half' + }).catch(err => { + strictEqual(err.name, 'AbortError') + }).finally(done) + }) + }) +}) + +test('unsupported formData 1', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'asdasdsad') + res.end() + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .catch(err => { + strictEqual(err.name, 'TypeError') + }) + .finally(done) + }) +}) + +test('multipart formdata not base64', async (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + + // Construct example form data, with text and blob fields + const formData = new FormData() + formData.append('field1', 'value1') + const blob = new Blob(['example\ntext file'], { type: 'text/plain' }) + formData.append('field2', blob, 'file.txt') + + const tempRes = new Response(formData) + const boundary = tempRes.headers.get('content-type').split('boundary=')[1] + const formRaw = await tempRes.text() + + const server = createServer((req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=' + boundary) + res.write(formRaw) + res.end() + }) + t.after(closeServerAsPromise(server)) + + const listen = promisify(server.listen.bind(server)) + await listen(0) + + const res = await fetch(`http://localhost:${server.address().port}`) + const form = await res.formData() + strictEqual(form.get('field1'), 'value1') + + const text = await form.get('field2').text() + strictEqual(text, 'example\ntext file') +}) + +test('multipart formdata base64', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + // Example form data with base64 encoding + const data = randomFillSync(Buffer.alloc(256)) + const formRaw = + '------formdata-undici-0.5786922755719377\r\n' + + 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + + 'Content-Type: application/octet-stream\r\n' + + 'Content-Transfer-Encoding: base64\r\n' + + '\r\n' + + data.toString('base64') + + '\r\n' + + '------formdata-undici-0.5786922755719377--' + + const server = createServer(async (req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=----formdata-undici-0.5786922755719377') + + for (let offset = 0; offset < formRaw.length;) { + res.write(formRaw.slice(offset, offset += 2)) + await new Promise(resolve => setTimeout(resolve)) + } + res.end() + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(form => form.get('file').arrayBuffer()) + .then(buffer => createHash('sha256').update(Buffer.from(buffer)).digest('base64')) + .then(digest => { + strictEqual(createHash('sha256').update(data).digest('base64'), digest) + }) + .finally(done) + }) +}) + +test('multipart fromdata non-ascii filed names', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=----formdata-undici-0.6204674738279623' + }, + body: + '------formdata-undici-0.6204674738279623\r\n' + + 'Content-Disposition: form-data; name="fiŝo"\r\n' + + '\r\n' + + 'value1\r\n' + + '------formdata-undici-0.6204674738279623--' + }) + + const form = await request.formData() + strictEqual(form.get('fiŝo'), 'value1') +}) + +test('busboy emit error', async (t) => { + const { rejects } = tspl(t, { plan: 1 }) + const formData = new FormData() + formData.append('field1', 'value1') + + const tempRes = new Response(formData) + const formRaw = await tempRes.text() + + const server = createServer((req, res) => { + res.setHeader('content-type', 'multipart/form-data; boundary=wrongboundary') + res.write(formRaw) + res.end() + }) + t.after(closeServerAsPromise(server)) + + const listen = promisify(server.listen.bind(server)) + await listen(0) + + const res = await fetch(`http://localhost:${server.address().port}`) + await rejects(res.formData(), 'Unexpected end of multipart data') +}) + +// https://github.com/nodejs/undici/issues/2244 +test('parsing formData preserve full path on files', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const formData = new FormData() + formData.append('field1', new File(['foo'], 'a/b/c/foo.txt')) + + const tempRes = new Response(formData) + const form = await tempRes.formData() + + strictEqual(form.get('field1').name, 'a/b/c/foo.txt') +}) + +test('urlencoded formData', (t, done) => { + const { strictEqual } = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('field1=value1&field2=value2') + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(formData => { + strictEqual(formData.get('field1'), 'value1') + strictEqual(formData.get('field2'), 'value2') + }) + .finally(done) + }) +}) + +test('text with BOM', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('\uFEFFtest=\uFEFF') + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.text()) + .then(text => { + strictEqual(text, 'test=\uFEFF') + }) + .finally(done) + }) +}) + +test('formData with BOM', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.setHeader('content-type', 'application/x-www-form-urlencoded') + res.end('\uFEFFtest=\uFEFF') + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`) + .then(res => res.formData()) + .then(formData => { + strictEqual(formData.get('\uFEFFtest'), '\uFEFF') + }) + .finally(done) + }) +}) + +test('locked blob body', (t, done) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + res.end() + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`) + const reader = res.body.getReader() + res.blob().catch(err => { + strictEqual(err.message, 'Body is unusable: Body has already been read') + reader.cancel() + }).finally(done) + }) +}) + +test('disturbed blob body', (t, done) => { + const { ok, strictEqual } = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.end() + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`) + await res.blob().then(() => { + ok(true) + }) + await res.blob().catch(err => { + strictEqual(err.message, 'Body is unusable: Body has already been read') + }) + done() + }) +}) + +test('redirect with body', (t, done) => { + const { strictEqual } = tspl(t, { plan: 3 }) + + let count = 0 + const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) { + body += chunk + } + strictEqual(body, 'asd') + if (count++ === 0) { + res.setHeader('location', 'asd') + res.statusCode = 302 + res.end() + } else { + res.end(String(count)) + } + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body: 'asd' + }) + strictEqual(await res.text(), '2') + done() + }) +}) + +test('redirect with stream', (t, done) => { + const { strictEqual } = tspl(t, { plan: 3 }) + + const location = '/asd' + const body = 'hello!' + const server = createServer(async (req, res) => { + res.writeHead(302, { location }) + let count = 0 + const l = setInterval(() => { + res.write(body[count++]) + if (count === body.length) { + res.end() + clearInterval(l) + } + }, 50) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + redirect: 'manual' + }) + strictEqual(res.status, 302) + strictEqual(res.headers.get('location'), location) + strictEqual(await res.text(), body) + done() + }) +}) + +test('fail to extract locked body', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const stream = new ReadableStream({}) + const reader = stream.getReader() + try { + // eslint-disable-next-line + new Response(stream) + } catch (err) { + strictEqual(err.name, 'TypeError') + } + reader.cancel() +}) + +test('fail to extract locked body', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const stream = new ReadableStream({}) + const reader = stream.getReader() + try { + // eslint-disable-next-line + new Request('http://asd', { + method: 'PUT', + body: stream, + keepalive: true + }) + } catch (err) { + strictEqual(err.message, 'keepalive') + } + reader.cancel() +}) + +test('post FormData with Blob', (t, done) => { + const { ok } = tspl(t, { plan: 1 }) + + const body = new FormData() + body.append('field1', new Blob(['asd1'])) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body + }) + ok(/asd1/.test(await res.text())) + done() + }) +}) + +test('post FormData with File', (t, done) => { + const { ok } = tspl(t, { plan: 2 }) + + const body = new FormData() + body.append('field1', new File(['asd1'], 'filename123')) + + const server = createServer((req, res) => { + req.pipe(res) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const res = await fetch(`http://localhost:${server.address().port}`, { + method: 'PUT', + body + }) + const result = await res.text() + ok(/asd1/.test(result)) + ok(/filename123/.test(result)) + done() + }) +}) + +test('invalid url', async (t) => { + const { match } = tspl(t, { plan: 1 }) + + try { + await fetch('http://invalid') + } catch (e) { + match(e.cause.message, /invalid/) + } +}) + +test('custom agent', (t, done) => { + const { ok, deepStrictEqual } = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const dispatcher = new Client('http://localhost:' + server.address().port, { + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 + }) + const oldDispatch = dispatcher.dispatch + dispatcher.dispatch = function (options, handler) { + ok(true) + return oldDispatch.call(this, options, handler) + } + const body = await fetch(`http://localhost:${server.address().port}`, { + dispatcher + }) + deepStrictEqual(obj, await body.json()) + done() + }) +}) + +test('custom agent node fetch', (t, done) => { + const { ok, deepStrictEqual } = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + res.end(JSON.stringify(obj)) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const dispatcher = new Client('http://localhost:' + server.address().port, { + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 + }) + const oldDispatch = dispatcher.dispatch + dispatcher.dispatch = function (options, handler) { + ok(true) + return oldDispatch.call(this, options, handler) + } + const body = await nodeFetch.fetch(`http://localhost:${server.address().port}`, { + dispatcher + }) + deepStrictEqual(obj, await body.json()) + done() + }) +}) + +test('error on redirect', (t, done) => { + const server = createServer((req, res) => { + res.statusCode = 302 + res.end() + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const errorCause = await fetch(`http://localhost:${server.address().port}`, { + redirect: 'error' + }).catch((e) => e.cause) + + assert.strictEqual(errorCause.message, 'unexpected redirect') + done() + }) +}) + +// https://github.com/nodejs/undici/issues/1527 +test('fetching with Request object - issue #1527', async (t) => { + const server = createServer((req, res) => { + assert.ok(true) + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const body = JSON.stringify({ foo: 'bar' }) + const request = new Request(`http://localhost:${server.address().port}`, { + method: 'POST', + body + }) + + await assert.doesNotReject(fetch(request)) +}) + +test('do not decode redirect body', (t, done) => { + const { ok, strictEqual } = tspl(t, { plan: 3 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + if (req.url === '/resource') { + ok(true) + res.statusCode = 301 + res.setHeader('location', '/resource/') + // Some dumb http servers set the content-encoding gzip + // even if there is no response + res.setHeader('content-encoding', 'gzip') + res.end() + return + } + ok(true) + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(JSON.stringify(obj))) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/resource`) + strictEqual(JSON.stringify(obj), await body.text()) + done() + }) +}) + +test('decode non-redirect body with location header', (t, done) => { + const { ok, strictEqual } = tspl(t, { plan: 2 }) + + const obj = { asd: true } + const server = createServer((req, res) => { + ok(true) + res.statusCode = 201 + res.setHeader('location', '/resource/') + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(JSON.stringify(obj))) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/resource`) + strictEqual(JSON.stringify(obj), await body.text()) + done() + }) +}) + +test('Receiving non-Latin1 headers', async (t) => { + const ContentDisposition = [ + 'inline; filename=rock&roll.png', + 'inline; filename="rock\'n\'roll.png"', + 'inline; filename="image â\x80\x94 copy (1).png"; filename*=UTF-8\'\'image%20%E2%80%94%20copy%20(1).png', + 'inline; filename="_å\x9C\x96ç\x89\x87_ð\x9F\x96¼_image_.png"; filename*=UTF-8\'\'_%E5%9C%96%E7%89%87_%F0%9F%96%BC_image_.png', + 'inline; filename="100 % loading&perf.png"; filename*=UTF-8\'\'100%20%25%20loading%26perf.png' + ] + + const server = createServer((req, res) => { + for (let i = 0; i < ContentDisposition.length; i++) { + res.setHeader(`Content-Disposition-${i + 1}`, ContentDisposition[i]) + } + + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const url = `http://localhost:${server.address().port}` + const response = await fetch(url, { method: 'HEAD' }) + const cdHeaders = [...response.headers] + .filter(([k]) => k.startsWith('content-disposition')) + .map(([, v]) => v) + const lengths = cdHeaders.map(h => h.length) + + assert.deepStrictEqual(cdHeaders, ContentDisposition) + assert.deepStrictEqual(lengths, [30, 34, 94, 104, 90]) +}) diff --git a/test/fetch/client-node-max-header-size.js b/test/fetch/client-node-max-header-size.js new file mode 100644 index 0000000..65d9251 --- /dev/null +++ b/test/fetch/client-node-max-header-size.js @@ -0,0 +1,45 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { exec } = require('node:child_process') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test, describe, before, after } = require('node:test') + +describe('fetch respects --max-http-header-size', () => { + let server + + before(async () => { + server = createServer((req, res) => { + res.writeHead(200, 'OK', { + 'Content-Length': 2 + }) + res.write('OK') + res.end() + }).listen(0) + + await once(server, 'listening') + }) + + after(() => server.close()) + + test("respect Node.js' --max-http-header-size", async (t) => { + t = tspl(t, { plan: 6 }) + + const command = 'node -e "require(\'./undici-fetch.js\').fetch(\'http://localhost:' + server.address().port + '\')"' + + exec(`${command} --max-http-header-size=1`, { stdio: 'pipe' }, (err, stdout, stderr) => { + t.strictEqual(err.code, 1) + t.strictEqual(stdout, '') + t.match(stderr, /UND_ERR_HEADERS_OVERFLOW/, '--max-http-header-size=1 should throw') + }) + + exec(command, { stdio: 'pipe' }, (err, stdout, stderr) => { + t.ifError(err) + t.strictEqual(stdout, '') + t.strictEqual(stderr, '', 'default max-http-header-size should not throw') + }) + + await t.completed + }) +}) diff --git a/test/fetch/content-length.js b/test/fetch/content-length.js new file mode 100644 index 0000000..637ff0b --- /dev/null +++ b/test/fetch/content-length.js @@ -0,0 +1,31 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { Blob } = require('node:buffer') +const { fetch, FormData } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') + +// https://github.com/nodejs/undici/issues/1783 +test('Content-Length is set when using a FormData body with fetch', async (t) => { + const server = createServer((req, res) => { + // TODO: check the length's value once the boundary has a fixed length + assert.ok('content-length' in req.headers) // request has content-length header + assert.ok(!Number.isNaN(Number(req.headers['content-length']))) + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(closeServerAsPromise(server)) + + const fd = new FormData() + fd.set('file', new Blob(['hello world 👋'], { type: 'text/plain' }), 'readme.md') + fd.set('string', 'some string value') + + await fetch(`http://localhost:${server.address().port}`, { + method: 'POST', + body: fd + }) +}) diff --git a/test/fetch/cookies.js b/test/fetch/cookies.js new file mode 100644 index 0000000..3bc69c6 --- /dev/null +++ b/test/fetch/cookies.js @@ -0,0 +1,111 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { Client, fetch, Headers } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') +const pem = require('https-pem') +const { createSecureServer } = require('node:http2') +const { closeClientAndServerAsPromise } = require('../utils/node-http') + +test('Can receive set-cookie headers from a server using fetch - issue #1262', async (t) => { + const server = createServer((req, res) => { + res.setHeader('set-cookie', 'name=value; Domain=example.com') + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + assert.strictEqual(response.headers.get('set-cookie'), 'name=value; Domain=example.com') + + const response2 = await fetch(`http://localhost:${server.address().port}`, { + credentials: 'include' + }) + + assert.strictEqual(response2.headers.get('set-cookie'), 'name=value; Domain=example.com') +}) + +test('Can send cookies to a server with fetch - issue #1463', async (t) => { + const server = createServer((req, res) => { + assert.strictEqual(req.headers.cookie, 'value') + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const headersInit = [ + new Headers([['cookie', 'value']]), + { cookie: 'value' }, + [['cookie', 'value']] + ] + + for (const headers of headersInit) { + await fetch(`http://localhost:${server.address().port}`, { headers }) + } +}) + +test('Cookie header is delimited with a semicolon rather than a comma - issue #1905', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + strictEqual(req.headers.cookie, 'FOO=lorem-ipsum-dolor-sit-amet; BAR=the-quick-brown-fox') + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + headers: [ + ['cookie', 'FOO=lorem-ipsum-dolor-sit-amet'], + ['cookie', 'BAR=the-quick-brown-fox'] + ] + }) +}) + +test('Can receive set-cookie headers from a http2 server using fetch - issue #2885', async (t) => { + const server = createSecureServer(pem) + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-method': headers[':method'], + 'set-cookie': 'Space=Cat; Secure; HttpOnly', + ':status': 200 + }) + + stream.end('test') + }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client, + headers: { + 'content-type': 'text-plain' + } + } + ) + + t.after(closeClientAndServerAsPromise(client, server)) + + assert.deepStrictEqual(response.headers.getSetCookie(), ['Space=Cat; Secure; HttpOnly']) +}) diff --git a/test/fetch/data-uri.js b/test/fetch/data-uri.js new file mode 100644 index 0000000..feab340 --- /dev/null +++ b/test/fetch/data-uri.js @@ -0,0 +1,194 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { + URLSerializer, + collectASequenceOfCodePoints, + stringPercentDecode, + parseMIMEType, + collectAnHTTPQuotedString +} = require('../../lib/web/fetch/data-url') +const { fetch } = require('../..') + +test('https://url.spec.whatwg.org/#concept-url-serializer', async (t) => { + await t.test('url scheme gets appended', () => { + const url = new URL('https://www.google.com/') + const serialized = URLSerializer(url) + + assert.ok(serialized.startsWith(url.protocol)) + }) + + await t.test('non-null url host with authentication', () => { + const url = new URL('https://username:password@google.com') + const serialized = URLSerializer(url) + + assert.ok(serialized.includes(`//${url.username}:${url.password}`)) + assert.ok(serialized.endsWith('@google.com/')) + }) + + await t.test('null url host', () => { + for (const url of ['web+demo:/.//not-a-host/', 'web+demo:/path/..//not-a-host/']) { + assert.strictEqual( + URLSerializer(new URL(url)), + 'web+demo:/.//not-a-host/' + ) + } + }) + + await t.test('url with query works', () => { + assert.strictEqual( + URLSerializer(new URL('https://www.google.com/?fetch=undici')), + 'https://www.google.com/?fetch=undici' + ) + }) + + await t.test('exclude fragment', () => { + assert.strictEqual( + URLSerializer(new URL('https://www.google.com/#frag')), + 'https://www.google.com/#frag' + ) + + assert.strictEqual( + URLSerializer(new URL('https://www.google.com/#frag'), true), + 'https://www.google.com/' + ) + }) +}) + +test('https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points', () => { + const input = 'text/plain;base64,' + const position = { position: 0 } + const result = collectASequenceOfCodePoints( + (char) => char !== ';', + input, + position + ) + + assert.strictEqual(result, 'text/plain') + assert.strictEqual(position.position, input.indexOf(';')) +}) + +test('https://url.spec.whatwg.org/#string-percent-decode', async (t) => { + await t.test('encodes %{2} in range properly', () => { + const input = '%FF' + const percentDecoded = stringPercentDecode(input) + + assert.deepStrictEqual(percentDecoded, new Uint8Array([255])) + }) + + await t.test('encodes %{2} not in range properly', () => { + const input = 'Hello %XD World' + const percentDecoded = stringPercentDecode(input) + const expected = [...input].map(c => c.charCodeAt(0)) + + assert.deepStrictEqual(percentDecoded, new Uint8Array(expected)) + }) + + await t.test('normal string works', () => { + const input = 'Hello world' + const percentDecoded = stringPercentDecode(input) + const expected = [...input].map(c => c.charCodeAt(0)) + + assert.deepStrictEqual(percentDecoded, Uint8Array.from(expected)) + }) +}) + +test('https://mimesniff.spec.whatwg.org/#parse-a-mime-type', () => { + assert.deepStrictEqual(parseMIMEType('text/plain'), { + type: 'text', + subtype: 'plain', + parameters: new Map(), + essence: 'text/plain' + }) + + assert.deepStrictEqual(parseMIMEType('text/html;charset="shift_jis"iso-2022-jp'), { + type: 'text', + subtype: 'html', + parameters: new Map([['charset', 'shift_jis']]), + essence: 'text/html' + }) + + assert.deepStrictEqual(parseMIMEType('application/javascript'), { + type: 'application', + subtype: 'javascript', + parameters: new Map(), + essence: 'application/javascript' + }) +}) + +test('https://fetch.spec.whatwg.org/#collect-an-http-quoted-string', async (t) => { + // https://fetch.spec.whatwg.org/#example-http-quoted-string + await t.test('first', () => { + const position = { position: 0 } + + assert.strictEqual(collectAnHTTPQuotedString('"\\', { + position: 0 + }), '"\\') + assert.strictEqual(collectAnHTTPQuotedString('"\\', position, true), '\\') + assert.strictEqual(position.position, 2) + }) + + await t.test('second', () => { + const position = { position: 0 } + const input = '"Hello" World' + + assert.strictEqual(collectAnHTTPQuotedString(input, { + position: 0 + }), '"Hello"') + assert.strictEqual(collectAnHTTPQuotedString(input, position, true), 'Hello') + assert.strictEqual(position.position, 7) + }) +}) + +// https://github.com/nodejs/undici/issues/1574 +test('too long base64 url', async () => { + const inputStr = 'a'.repeat(1 << 20) + const base64 = Buffer.from(inputStr).toString('base64') + const dataURIPrefix = 'data:application/octet-stream;base64,' + const dataURL = dataURIPrefix + base64 + try { + const res = await fetch(dataURL) + const buf = await res.arrayBuffer() + const outputStr = Buffer.from(buf).toString('ascii') + assert.strictEqual(outputStr, inputStr) + } catch (e) { + assert.fail(`failed to fetch ${dataURL}`) + } +}) + +test('https://domain.com/#', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const domain = 'https://domain.com/#a' + const serialized = URLSerializer(new URL(domain)) + strictEqual(serialized, domain) +}) + +test('https://domain.com/?', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const domain = 'https://domain.com/?a=b' + const serialized = URLSerializer(new URL(domain)) + strictEqual(serialized, domain) +}) + +// https://github.com/nodejs/undici/issues/2474 +test('hash url', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const domain = 'https://domain.com/#a#b' + const url = new URL(domain) + const serialized = URLSerializer(url, true) + strictEqual(serialized, url.href.substring(0, url.href.length - url.hash.length)) +}) + +// https://github.com/nodejs/undici/issues/2474 +test('data url that includes the hash', async (t) => { + const { strictEqual, fail } = tspl(t, { plan: 1 }) + const dataURL = 'data:,node#js#' + try { + const res = await fetch(dataURL) + strictEqual(await res.text(), 'node') + } catch (error) { + fail(`failed to fetch ${dataURL}`) + } +}) diff --git a/test/fetch/encoding.js b/test/fetch/encoding.js new file mode 100644 index 0000000..93c9827 --- /dev/null +++ b/test/fetch/encoding.js @@ -0,0 +1,60 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { fetch } = require('../..') +const { createBrotliCompress, createGzip, createDeflate } = require('node:zlib') +const { closeServerAsPromise } = require('../utils/node-http') + +test('content-encoding header is case-iNsENsITIve', async (t) => { + const contentCodings = 'GZiP, bR' + const text = 'Hello, World!' + + const server = createServer((req, res) => { + const gzip = createGzip() + const brotli = createBrotliCompress() + + res.setHeader('Content-Encoding', contentCodings) + res.setHeader('Content-Type', 'text/plain') + + gzip.pipe(brotli).pipe(res) + + gzip.write(text) + gzip.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + assert.strictEqual(await response.text(), text) + assert.strictEqual(response.headers.get('content-encoding'), contentCodings) +}) + +test('response decompression according to content-encoding should be handled in a correct order', async (t) => { + const contentCodings = 'deflate, gzip' + const text = 'Hello, World!' + + const server = createServer((req, res) => { + const gzip = createGzip() + const deflate = createDeflate() + + res.setHeader('Content-Encoding', contentCodings) + res.setHeader('Content-Type', 'text/plain') + + deflate.pipe(gzip).pipe(res) + + deflate.write(text) + deflate.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const response = await fetch(`http://localhost:${server.address().port}`) + + assert.strictEqual(await response.text(), text) +}) diff --git a/test/fetch/exiting.js b/test/fetch/exiting.js new file mode 100644 index 0000000..0fb007b --- /dev/null +++ b/test/fetch/exiting.js @@ -0,0 +1,39 @@ +'use strict' + +const { test } = require('node:test') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { closeServerAsPromise } = require('../utils/node-http') +const tspl = require('@matteo.collina/tspl') + +test('abort the request on the other side if the stream is canceled', async (t) => { + const p = tspl(t, { plan: 1 }) + const server = createServer((req, res) => { + res.writeHead(200) + res.write('hello') + req.on('aborted', () => { + p.ok('aborted') + }) + // Let's not end the response on purpose + }) + t.after(closeServerAsPromise(server)) + + await new Promise((resolve) => { + server.listen(0, resolve) + }) + + const url = new URL(`http://127.0.0.1:${server.address().port}`) + + const response = await fetch(url) + + const reader = response.body.getReader() + + try { + await reader.read() + } finally { + reader.releaseLock() + await response.body.cancel() + } + + await p.completed +}) diff --git a/test/fetch/export-env-proxy-agent.js b/test/fetch/export-env-proxy-agent.js new file mode 100644 index 0000000..933a6bb --- /dev/null +++ b/test/fetch/export-env-proxy-agent.js @@ -0,0 +1,15 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const undiciFetch = require('../../undici-fetch') + +test('EnvHttpProxyAgent should be part of Node.js bundle', () => { + assert.strictEqual(typeof undiciFetch.EnvHttpProxyAgent, 'function') + assert.strictEqual(typeof undiciFetch.getGlobalDispatcher, 'function') + assert.strictEqual(typeof undiciFetch.setGlobalDispatcher, 'function') + + const agent = new undiciFetch.EnvHttpProxyAgent() + undiciFetch.setGlobalDispatcher(agent) + assert.strictEqual(undiciFetch.getGlobalDispatcher(), agent) +}) diff --git a/test/fetch/fetch-leak.js b/test/fetch/fetch-leak.js new file mode 100644 index 0000000..caf75fe --- /dev/null +++ b/test/fetch/fetch-leak.js @@ -0,0 +1,52 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { closeServerAsPromise } = require('../utils/node-http') + +const hasGC = typeof global.gc !== 'undefined' + +test('do not leak', (t, done) => { + if (!hasGC) { + throw new Error('gc is not available. Run with \'--expose-gc\'.') + } + const { ok } = tspl(t, { plan: 1 }) + const server = createServer((req, res) => { + res.end() + }) + t.after(closeServerAsPromise(server)) + + let url + let isDone = false + server.listen(0, function attack () { + if (isDone) { + return + } + url ??= new URL(`http://127.0.0.1:${server.address().port}`) + const controller = new AbortController() + fetch(url, { signal: controller.signal }) + .then(res => res.arrayBuffer()) + .catch(() => {}) + .then(attack) + }) + + let prev = Infinity + let count = 0 + const interval = setInterval(() => { + isDone = true + global.gc() + const next = process.memoryUsage().heapUsed + if (next <= prev) { + ok(true) + done() + } else if (count++ > 20) { + assert.fail() + } else { + prev = next + } + }, 1e3) + t.after(() => clearInterval(interval)) +}) diff --git a/test/fetch/fetch-timeouts.js b/test/fetch/fetch-timeouts.js new file mode 100644 index 0000000..038c23b --- /dev/null +++ b/test/fetch/fetch-timeouts.js @@ -0,0 +1,57 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') + +const { fetch, Agent } = require('../..') +const timers = require('../../lib/util/timers') +const { createServer } = require('node:http') +const FakeTimers = require('@sinonjs/fake-timers') +const { closeServerAsPromise } = require('../utils/node-http') + +test('Fetch very long request, timeout overridden so no error', (t, done) => { + const minutes = 6 + const msToDelay = 1000 * 60 * minutes + + const { strictEqual } = tspl(t, { plan: 1 }) + + const clock = FakeTimers.install() + t.after(clock.uninstall.bind(clock)) + + const orgTimers = { ...timers } + Object.assign(timers, { setTimeout, clearTimeout }) + t.after(() => { + Object.assign(timers, orgTimers) + }) + + const server = createServer((req, res) => { + setTimeout(() => { + res.end('hello') + }, msToDelay) + clock.tick(msToDelay + 1) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0, () => { + fetch(`http://localhost:${server.address().port}`, { + path: '/', + method: 'GET', + dispatcher: new Agent({ + headersTimeout: 0, + connectTimeout: 0, + bodyTimeout: 0 + }) + }) + .then((response) => response.text()) + .then((response) => { + strictEqual('hello', response) + done() + }) + .catch((err) => { + // This should not happen, a timeout error should not occur + throw err + }) + + clock.tick(msToDelay - 1) + }) +}) diff --git a/test/fetch/fetch-url-after-redirect.js b/test/fetch/fetch-url-after-redirect.js new file mode 100644 index 0000000..e387848 --- /dev/null +++ b/test/fetch/fetch-url-after-redirect.js @@ -0,0 +1,61 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') +const { createServer } = require('node:http') +const { fetch } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') +const { promisify } = require('node:util') + +test('after redirecting the url of the response is set to the target url', async (t) => { + // redirect-1 -> redirect-2 -> target + const server = createServer((req, res) => { + switch (res.req.url) { + case '/redirect-1': + res.writeHead(302, undefined, { Location: '/redirect-2' }) + res.end() + break + case '/redirect-2': + res.writeHead(302, undefined, { Location: '/redirect-3' }) + res.end() + break + case '/redirect-3': + res.writeHead(302, undefined, { Location: '/target' }) + res.end() + break + case '/target': + res.writeHead(200, 'dummy', { 'Content-Type': 'text/plain' }) + res.end() + break + } + }) + t.after(closeServerAsPromise(server)) + + const listenAsync = promisify(server.listen.bind(server)) + await listenAsync(0) + const { port } = server.address() + const response = await fetch(`http://127.0.0.1:${port}/redirect-1`) + + assert.strictEqual(response.url, `http://127.0.0.1:${port}/target`) +}) + +test('location header with non-ASCII character redirects to a properly encoded url', async (t) => { + // redirect -> %EC%95%88%EB%85%95 (안녕), not %C3%AC%C2%95%C2%88%C3%AB%C2%85%C2%95 + const server = createServer((req, res) => { + if (res.req.url.endsWith('/redirect')) { + res.writeHead(302, undefined, { Location: `/${Buffer.from('안녕').toString('binary')}` }) + res.end() + } else { + res.writeHead(200, 'dummy', { 'Content-Type': 'text/plain' }) + res.end() + } + }) + t.after(closeServerAsPromise(server)) + + const listenAsync = promisify(server.listen.bind(server)) + await listenAsync(0) + const { port } = server.address() + const response = await fetch(`http://127.0.0.1:${port}/redirect`) + + assert.strictEqual(response.url, `http://127.0.0.1:${port}/${encodeURIComponent('안녕')}`) +}) diff --git a/test/fetch/fire-and-forget.js b/test/fetch/fire-and-forget.js new file mode 100644 index 0000000..5794f73 --- /dev/null +++ b/test/fetch/fire-and-forget.js @@ -0,0 +1,58 @@ +'use strict' + +const { randomFillSync } = require('node:crypto') +const { setTimeout: sleep } = require('timers/promises') +const { test } = require('node:test') +const { fetch, Agent, setGlobalDispatcher } = require('../..') +const { createServer } = require('node:http') +const { closeServerAsPromise } = require('../utils/node-http') + +const blob = randomFillSync(new Uint8Array(1024 * 512)) + +// Enable when/if FinalizationRegistry in Node.js 18 becomes stable again +const isNode18 = process.version.startsWith('v18') +const hasGC = typeof global.gc !== 'undefined' + +test('does not need the body to be consumed to continue', { timeout: 180_000, skip: isNode18 }, async (t) => { + if (!hasGC) { + throw new Error('gc is not available. Run with \'--expose-gc\'.') + } + const agent = new Agent({ + keepAliveMaxTimeout: 10, + keepAliveTimeoutThreshold: 10 + }) + setGlobalDispatcher(agent) + const server = createServer((req, res) => { + res.writeHead(200) + res.end(blob) + }) + t.after(closeServerAsPromise(server)) + + await new Promise((resolve) => { + server.listen(0, resolve) + }) + + const url = new URL(`http://127.0.0.1:${server.address().port}`) + + const batch = 50 + const delay = 0 + let total = 0 + while (total < 5000) { + // eslint-disable-next-line no-undef + gc(true) + const array = new Array(batch) + for (let i = 0; i < batch; i += 2) { + array[i] = fetch(url).catch(() => {}) + array[i + 1] = fetch(url).then(r => r.clone()).catch(() => {}) + } + await Promise.all(array) + await sleep(delay) + + console.log( + 'RSS', + (process.memoryUsage.rss() / 1024 / 1024) | 0, + 'MB after', + (total += batch) + ' fetch() requests' + ) + } +}) diff --git a/test/fetch/formdata-inspect-custom.js b/test/fetch/formdata-inspect-custom.js new file mode 100644 index 0000000..4fb7006 --- /dev/null +++ b/test/fetch/formdata-inspect-custom.js @@ -0,0 +1,16 @@ +'use strict' + +const { FormData } = require('../../') +const { inspect } = require('node:util') +const { test } = require('node:test') +const assert = require('node:assert') + +test('FormData class custom inspection', () => { + const formData = new FormData() + formData.append('username', 'john_doe') + formData.append('email', 'john@example.com') + + const expectedOutput = "FormData {\n username: 'john_doe',\n email: 'john@example.com'\n}" + + assert.deepStrictEqual(inspect(formData), expectedOutput) +}) diff --git a/test/fetch/formdata.js b/test/fetch/formdata.js new file mode 100644 index 0000000..fb828c2 --- /dev/null +++ b/test/fetch/formdata.js @@ -0,0 +1,388 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { FormData, Response, Request } = require('../../') +const { Blob, File } = require('node:buffer') +const { isFormDataLike } = require('../../lib/core/util') + +test('arg validation', () => { + const form = new FormData() + + // constructor + assert.throws(() => { + // eslint-disable-next-line + new FormData('asd') + }, TypeError) + + // append + assert.throws(() => { + FormData.prototype.append.call(null) + }, TypeError) + assert.throws(() => { + form.append() + }, TypeError) + assert.throws(() => { + form.append('k', 'not usv', '') + }, TypeError) + + // delete + assert.throws(() => { + FormData.prototype.delete.call(null) + }, TypeError) + assert.throws(() => { + form.delete() + }, TypeError) + + // get + assert.throws(() => { + FormData.prototype.get.call(null) + }, TypeError) + assert.throws(() => { + form.get() + }, TypeError) + + // getAll + assert.throws(() => { + FormData.prototype.getAll.call(null) + }, TypeError) + assert.throws(() => { + form.getAll() + }, TypeError) + + // has + assert.throws(() => { + FormData.prototype.has.call(null) + }, TypeError) + assert.throws(() => { + form.has() + }, TypeError) + + // set + assert.throws(() => { + FormData.prototype.set.call(null) + }, TypeError) + assert.throws(() => { + form.set('k') + }, TypeError) + assert.throws(() => { + form.set('k', 'not usv', '') + }, TypeError) + + // iterator + assert.throws(() => { + Reflect.apply(FormData.prototype[Symbol.iterator], null) + }, TypeError) + + // toStringTag + assert.doesNotThrow(() => { + FormData.prototype[Symbol.toStringTag].charAt(0) + }) +}) + +test('set blob', () => { + const form = new FormData() + + form.set('key', new Blob([]), undefined) + assert.strictEqual(form.get('key').name, 'blob') + + form.set('key1', new Blob([]), null) + assert.strictEqual(form.get('key1').name, 'null') +}) + +test('append file', () => { + const form = new FormData() + form.set('asd', new File([], 'asd1', { type: 'text/plain' }), 'asd2') + form.append('asd2', new File([], 'asd1'), 'asd2') + + assert.strictEqual(form.has('asd'), true) + assert.strictEqual(form.has('asd2'), true) + assert.strictEqual(form.get('asd').name, 'asd2') + assert.strictEqual(form.get('asd2').name, 'asd2') + assert.strictEqual(form.get('asd').type, 'text/plain') + form.delete('asd') + assert.strictEqual(form.get('asd'), null) + assert.strictEqual(form.has('asd2'), true) + assert.strictEqual(form.has('asd'), false) +}) + +test('append blob', async () => { + const form = new FormData() + form.set('asd', new Blob(['asd1'], { type: 'text/plain' })) + + assert.strictEqual(form.has('asd'), true) + assert.strictEqual(form.get('asd').type, 'text/plain') + assert.strictEqual(await form.get('asd').text(), 'asd1') + form.delete('asd') + assert.strictEqual(form.get('asd'), null) + + form.append('key', new Blob([]), undefined) + assert.strictEqual(form.get('key').name, 'blob') + + form.append('key1', new Blob([]), null) + assert.strictEqual(form.get('key1').name, 'null') +}) + +test('append string', () => { + const form = new FormData() + form.set('k1', 'v1') + form.set('k2', 'v2') + assert.deepStrictEqual([...form], [['k1', 'v1'], ['k2', 'v2']]) + assert.strictEqual(form.has('k1'), true) + assert.strictEqual(form.get('k1'), 'v1') + form.append('k1', 'v1+') + assert.deepStrictEqual(form.getAll('k1'), ['v1', 'v1+']) + form.set('k2', 'v1++') + assert.strictEqual(form.get('k2'), 'v1++') + form.delete('asd') + assert.strictEqual(form.get('asd'), null) +}) + +test('formData.entries', async (t) => { + const form = new FormData() + + await t.test('with 0 entries', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const entries = [...form.entries()] + deepStrictEqual(entries, []) + }) + + await t.test('with 1+ entries', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 2 }) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const entries = [...form.entries()] + const entries2 = [...form.entries()] + deepStrictEqual(entries, [['k1', 'v1'], ['k2', 'v2']]) + deepStrictEqual(entries, entries2) + }) +}) + +test('formData.keys', async (t) => { + const form = new FormData() + + await t.test('with 0 keys', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const keys = [...form.entries()] + deepStrictEqual(keys, []) + }) + + await t.test('with 1+ keys', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 2 }) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const keys = [...form.keys()] + const keys2 = [...form.keys()] + deepStrictEqual(keys, ['k1', 'k2']) + deepStrictEqual(keys, keys2) + }) +}) + +test('formData.values', async (t) => { + const form = new FormData() + + await t.test('with 0 values', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const values = [...form.values()] + deepStrictEqual(values, []) + }) + + await t.test('with 1+ values', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 2 }) + + form.set('k1', 'v1') + form.set('k2', 'v2') + + const values = [...form.values()] + const values2 = [...form.values()] + deepStrictEqual(values, ['v1', 'v2']) + deepStrictEqual(values, values2) + }) +}) + +test('formData forEach', async (t) => { + await t.test('invalid arguments', () => { + assert.throws(() => { + FormData.prototype.forEach.call({}) + }, TypeError('Illegal invocation')) + + assert.throws(() => { + const fd = new FormData() + + fd.forEach({}) + }, TypeError) + }) + + await t.test('with a callback', () => { + const fd = new FormData() + + fd.set('a', 'b') + fd.set('c', 'd') + + let i = 0 + fd.forEach((value, key, self) => { + if (i++ === 0) { + assert.strictEqual(value, 'b') + assert.strictEqual(key, 'a') + } else { + assert.strictEqual(value, 'd') + assert.strictEqual(key, 'c') + } + + assert.strictEqual(fd, self) + }) + }) + + await t.test('with a thisArg', () => { + const fd = new FormData() + fd.set('b', 'a') + + fd.forEach(function (value, key, self) { + assert.strictEqual(this, globalThis) + assert.strictEqual(fd, self) + assert.strictEqual(key, 'b') + assert.strictEqual(value, 'a') + }) + + const thisArg = Symbol('thisArg') + fd.forEach(function () { + assert.strictEqual(this, thisArg) + }, thisArg) + }) +}) + +test('formData toStringTag', () => { + const form = new FormData() + assert.strictEqual(form[Symbol.toStringTag], 'FormData') + assert.strictEqual(FormData.prototype[Symbol.toStringTag], 'FormData') +}) + +test('formData.constructor.name', () => { + const form = new FormData() + assert.strictEqual(form.constructor.name, 'FormData') +}) + +test('formData should be an instance of FormData', async (t) => { + await t.test('Invalid class FormData', () => { + class FormData { + constructor () { + this.data = [] + } + + append (key, value) { + this.data.push([key, value]) + } + + get (key) { + return this.data.find(([k]) => k === key) + } + } + + const form = new FormData() + assert.strictEqual(isFormDataLike(form), false) + }) + + await t.test('Invalid function FormData', () => { + function FormData () { + const data = [] + return { + append (key, value) { + data.push([key, value]) + }, + get (key) { + return data.find(([k]) => k === key) + } + } + } + + const form = new FormData() + assert.strictEqual(isFormDataLike(form), false) + }) + + await t.test('Valid FormData', () => { + const form = new FormData() + assert.strictEqual(isFormDataLike(form), true) + }) +}) + +test('FormData should be compatible with third-party libraries', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + class FormData { + constructor () { + this.data = [] + } + + get [Symbol.toStringTag] () { + return 'FormData' + } + + append () {} + delete () {} + get () {} + getAll () {} + has () {} + set () {} + entries () {} + keys () {} + values () {} + forEach () {} + } + + const form = new FormData() + strictEqual(isFormDataLike(form), true) +}) + +test('arguments', () => { + assert.strictEqual(FormData.constructor.length, 1) + assert.strictEqual(FormData.prototype.append.length, 2) + assert.strictEqual(FormData.prototype.delete.length, 1) + assert.strictEqual(FormData.prototype.get.length, 1) + assert.strictEqual(FormData.prototype.getAll.length, 1) + assert.strictEqual(FormData.prototype.has.length, 1) + assert.strictEqual(FormData.prototype.set.length, 2) +}) + +// https://github.com/nodejs/undici/pull/1814 +test('FormData returned from bodyMixin.formData is not a clone', async () => { + const fd = new FormData() + fd.set('foo', 'bar') + + const res = new Response(fd) + fd.set('foo', 'foo') + + const fd2 = await res.formData() + + assert.strictEqual(fd2.get('foo'), 'bar') + assert.strictEqual(fd.get('foo'), 'foo') + + fd2.set('foo', 'baz') + + assert.strictEqual(fd2.get('foo'), 'baz') + assert.strictEqual(fd.get('foo'), 'foo') +}) + +test('.formData() with multipart/form-data body that ends with --\r\n', async (t) => { + const request = new Request('http://localhost', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=----formdata-undici-0.6204674738279623' + }, + body: + '------formdata-undici-0.6204674738279623\r\n' + + 'Content-Disposition: form-data; name="fiŝo"\r\n' + + '\r\n' + + 'value1\r\n' + + '------formdata-undici-0.6204674738279623--\r\n' + }) + + await request.formData() +}) diff --git a/test/fetch/general.js b/test/fetch/general.js new file mode 100644 index 0000000..a23554b --- /dev/null +++ b/test/fetch/general.js @@ -0,0 +1,27 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { + FormData, + Headers, + Request, + Response +} = require('../../index') + +test('Symbol.toStringTag descriptor', () => { + for (const cls of [ + FormData, + Headers, + Request, + Response + ]) { + const desc = Object.getOwnPropertyDescriptor(cls.prototype, Symbol.toStringTag) + assert.deepStrictEqual(desc, { + value: cls.name, + writable: false, + enumerable: false, + configurable: true + }) + } +}) diff --git a/test/fetch/headers-case.js b/test/fetch/headers-case.js new file mode 100644 index 0000000..78c10b3 --- /dev/null +++ b/test/fetch/headers-case.js @@ -0,0 +1,32 @@ +'use strict' + +const { fetch, Headers, Request } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { closeServerAsPromise } = require('../utils/node-http') + +test('Headers retain keys case-sensitive', async (t) => { + const assert = tspl(t, { plan: 4 }) + + const server = createServer((req, res) => { + assert.ok(req.rawHeaders.includes('Content-Type')) + + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const url = `http://localhost:${server.address().port}` + for (const headers of [ + new Headers([['Content-Type', 'text/plain']]), + { 'Content-Type': 'text/plain' }, + [['Content-Type', 'text/plain']] + ]) { + await fetch(url, { headers }) + } + // see https://github.com/nodejs/undici/pull/3183 + await fetch(new Request(url, { headers: [['Content-Type', 'text/plain']] }), { method: 'GET' }) +}) diff --git a/test/fetch/headers-inspect-custom.js b/test/fetch/headers-inspect-custom.js new file mode 100644 index 0000000..8145e9d --- /dev/null +++ b/test/fetch/headers-inspect-custom.js @@ -0,0 +1,17 @@ +'use strict' + +const { Headers } = require('../../lib/web/fetch/headers') +const { test } = require('node:test') +const assert = require('node:assert') +const util = require('node:util') + +test('Headers class custom inspection', () => { + const headers = new Headers() + headers.set('Content-Type', 'application/json') + headers.set('Authorization', 'Bearer token') + + const inspectedOutput = util.inspect(headers, { depth: 1 }) + + const expectedOutput = "Headers { 'Content-Type': 'application/json', Authorization: 'Bearer token' }" + assert.strictEqual(inspectedOutput, expectedOutput) +}) diff --git a/test/fetch/headers.js b/test/fetch/headers.js new file mode 100644 index 0000000..02d500d --- /dev/null +++ b/test/fetch/headers.js @@ -0,0 +1,782 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { Headers, fill, setHeadersGuard } = require('../../lib/web/fetch/headers') +const { once } = require('node:events') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { closeServerAsPromise } = require('../utils/node-http') + +test('Headers initialization', async (t) => { + await t.test('allows undefined', () => { + const { doesNotThrow } = tspl(t, { plan: 1 }) + + doesNotThrow(() => new Headers()) + }) + + await t.test('with array of header entries', async (t) => { + await t.test('fails on invalid array-based init', (t) => { + const { throws } = tspl(t, { plan: 3 }) + throws( + () => new Headers([['undici', 'fetch'], ['fetch']]), + TypeError('Headers constructor: expected name/value pair to be length 2, found 1.') + ) + throws(() => new Headers(['undici', 'fetch', 'fetch']), TypeError) + throws( + () => new Headers([0, 1, 2]), + TypeError('Headers constructor: init[0] (0) is not iterable.') + ) + }) + + await t.test('allows even length init', (t) => { + const { doesNotThrow } = tspl(t, { plan: 1 }) + const init = [['undici', 'fetch'], ['fetch', 'undici']] + doesNotThrow(() => new Headers(init)) + }) + + await t.test('fails for event flattened init', (t) => { + const { throws } = tspl(t, { plan: 1 }) + const init = ['undici', 'fetch', 'fetch', 'undici'] + throws( + () => new Headers(init), + TypeError('Headers constructor: init[0] ("undici") is not iterable.') + ) + }) + }) + + await t.test('with object of header entries', (t) => { + const { doesNotThrow } = tspl(t, { plan: 1 }) + const init = { + undici: 'fetch', + fetch: 'undici' + } + doesNotThrow(() => new Headers(init)) + }) + + await t.test('fails silently if a boxed primitive object is passed', (t) => { + const { doesNotThrow } = tspl(t, { plan: 3 }) + /* eslint-disable no-new-wrappers */ + doesNotThrow(() => new Headers(new Number())) + doesNotThrow(() => new Headers(new Boolean())) + doesNotThrow(() => new Headers(new String())) + /* eslint-enable no-new-wrappers */ + }) + + await t.test('fails if primitive is passed', (t) => { + const { throws } = tspl(t, { plan: 2 }) + const expectedTypeError = TypeError + throws(() => new Headers(1), expectedTypeError) + throws(() => new Headers('1'), expectedTypeError) + }) + + await t.test('allows some weird stuff (because of webidl)', () => { + assert.doesNotThrow(() => { + new Headers(function () {}) // eslint-disable-line no-new + }) + + assert.doesNotThrow(() => { + new Headers(Function) // eslint-disable-line no-new + }) + }) + + await t.test('allows a myriad of header values to be passed', (t) => { + const { doesNotThrow, throws } = tspl(t, { plan: 4 }) + + // Headers constructor uses Headers.append + + doesNotThrow(() => new Headers([ + ['a', ['b', 'c']], + ['d', ['e', 'f']] + ]), 'allows any array values') + doesNotThrow(() => new Headers([ + ['key', null] + ]), 'allows null values') + throws(() => new Headers([ + ['key'] + ]), 'throws when 2 arguments are not passed') + throws(() => new Headers([ + ['key', 'value', 'value2'] + ]), 'throws when too many arguments are passed') + }) + + await t.test('accepts headers as objects with array values', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + const headers = new Headers({ + c: '5', + b: ['3', '4'], + a: ['1', '2'] + }) + + deepStrictEqual([...headers.entries()], [ + ['a', '1,2'], + ['b', '3,4'], + ['c', '5'] + ]) + }) +}) + +test('Headers append', async (t) => { + await t.test('adds valid header entry to instance', (t) => { + const { doesNotThrow, strictEqual } = tspl(t, { plan: 2 }) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + doesNotThrow(() => headers.append(name, value)) + strictEqual(headers.get(name), value) + }) + + await t.test('adds valid header to existing entry', (t) => { + const { strictEqual, doesNotThrow } = tspl(t, { plan: 4 }) + const headers = new Headers() + + const name = 'undici' + const value1 = 'fetch1' + const value2 = 'fetch2' + const value3 = 'fetch3' + headers.append(name, value1) + strictEqual(headers.get(name), value1) + doesNotThrow(() => headers.append(name, value2)) + doesNotThrow(() => headers.append(name, value3)) + strictEqual(headers.get(name), [value1, value2, value3].join(', ')) + }) + + await t.test('throws on invalid entry', (t) => { + const { throws } = tspl(t, { plan: 3 }) + const headers = new Headers() + + throws(() => headers.append(), 'throws on missing name and value') + throws(() => headers.append('undici'), 'throws on missing value') + throws(() => headers.append('invalid @ header ? name', 'valid value'), 'throws on invalid name') + }) +}) + +test('Headers delete', async (t) => { + await t.test('deletes valid header entry from instance', (t) => { + const { strictEqual, doesNotThrow } = tspl(t, { plan: 3 }) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + headers.append(name, value) + strictEqual(headers.get(name), value) + doesNotThrow(() => headers.delete(name)) + strictEqual(headers.get(name), null) + }) + + await t.test('does not mutate internal list when no match is found', (t) => { + const { strictEqual, doesNotThrow } = tspl(t, { plan: 3 }) + + const headers = new Headers() + const name = 'undici' + const value = 'fetch' + headers.append(name, value) + strictEqual(headers.get(name), value) + doesNotThrow(() => headers.delete('not-undici')) + strictEqual(headers.get(name), value) + }) + + await t.test('throws on invalid entry', (t) => { + const { throws } = tspl(t, { plan: 2 }) + const headers = new Headers() + + throws(() => headers.delete(), 'throws on missing namee') + throws(() => headers.delete('invalid @ header ? name'), 'throws on invalid name') + }) + + // https://github.com/nodejs/undici/issues/2429 + await t.test('`Headers#delete` returns undefined', (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + const headers = new Headers({ test: 'test' }) + + strictEqual(headers.delete('test'), undefined) + strictEqual(headers.delete('test2'), undefined) + }) +}) + +test('Headers get', async (t) => { + await t.test('returns null if not found in instance', (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const headers = new Headers() + headers.append('undici', 'fetch') + + strictEqual(headers.get('not-undici'), null) + }) + + await t.test('returns header values from valid header name', (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + const headers = new Headers() + + const name = 'undici'; const value1 = 'fetch1'; const value2 = 'fetch2' + headers.append(name, value1) + strictEqual(headers.get(name), value1) + headers.append(name, value2) + strictEqual(headers.get(name), [value1, value2].join(', ')) + }) + + await t.test('throws on invalid entry', (t) => { + const { throws } = tspl(t, { plan: 2 }) + const headers = new Headers() + + throws(() => headers.get(), 'throws on missing name') + throws(() => headers.get('invalid @ header ? name'), 'throws on invalid name') + }) +}) + +test('Headers has', async (t) => { + await t.test('returns boolean existence for a header name', (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + const headers = new Headers() + + const name = 'undici' + headers.append('not-undici', 'fetch') + strictEqual(headers.has(name), false) + headers.append(name, 'fetch') + strictEqual(headers.has(name), true) + }) + + await t.test('throws on invalid entry', (t) => { + const { throws } = tspl(t, { plan: 2 }) + const headers = new Headers() + + throws(() => headers.has(), 'throws on missing name') + throws(() => headers.has('invalid @ header ? name'), 'throws on invalid name') + }) +}) + +test('Headers set', async (t) => { + await t.test('sets valid header entry to instance', (t) => { + const { doesNotThrow, strictEqual } = tspl(t, { plan: 2 }) + const headers = new Headers() + + const name = 'undici' + const value = 'fetch' + headers.append('not-undici', 'fetch') + doesNotThrow(() => headers.set(name, value)) + strictEqual(headers.get(name), value) + }) + + await t.test('overwrites existing entry', (t) => { + const { doesNotThrow, strictEqual } = tspl(t, { plan: 4 }) + const headers = new Headers() + + const name = 'undici' + const value1 = 'fetch1' + const value2 = 'fetch2' + doesNotThrow(() => headers.set(name, value1)) + strictEqual(headers.get(name), value1) + doesNotThrow(() => headers.set(name, value2)) + strictEqual(headers.get(name), value2) + }) + + await t.test('allows setting a myriad of values', (t) => { + const { doesNotThrow, throws } = tspl(t, { plan: 4 }) + const headers = new Headers() + + doesNotThrow(() => headers.set('a', ['b', 'c']), 'sets array values properly') + doesNotThrow(() => headers.set('b', null), 'allows setting null values') + throws(() => headers.set('c'), 'throws when 2 arguments are not passed') + doesNotThrow(() => headers.set('c', 'd', 'e'), 'ignores extra arguments') + }) + + await t.test('throws on invalid entry', (t) => { + const { throws } = tspl(t, { plan: 3 }) + const headers = new Headers() + + throws(() => headers.set(), 'throws on missing name and value') + throws(() => headers.set('undici'), 'throws on missing value') + throws(() => headers.set('invalid @ header ? name', 'valid value'), 'throws on invalid name') + }) + + // https://github.com/nodejs/undici/issues/2431 + await t.test('`Headers#set` returns undefined', (t) => { + const { strictEqual, ok } = tspl(t, { plan: 2 }) + const headers = new Headers() + + strictEqual(headers.set('a', 'b'), undefined) + + ok(!(headers.set('c', 'd') instanceof Map)) + }) +}) + +test('Headers forEach', async (t) => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + + await t.test('standard', () => { + assert.strictEqual(typeof headers.forEach, 'function') + + headers.forEach((value, key, headerInstance) => { + assert.ok(value === 'b' || value === 'd') + assert.ok(key === 'a' || key === 'c') + assert.strictEqual(headers, headerInstance) + }) + }) + + await t.test('when no thisArg is set, it is globalThis', () => { + headers.forEach(function () { + assert.strictEqual(this, globalThis) + }) + }) + + await t.test('with thisArg', () => { + const thisArg = { a: Math.random() } + headers.forEach(function () { + assert.strictEqual(this, thisArg) + }, thisArg) + }) +}) + +test('Headers as Iterable', async (t) => { + await t.test('should freeze values while iterating', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + const init = [ + ['foo', '123'], + ['bar', '456'] + ] + const expected = [ + ['foo', '123'], + ['x-x-bar', '456'] + ] + const headers = new Headers(init) + for (const [key, val] of headers) { + headers.delete(key) + headers.set(`x-${key}`, val) + } + deepStrictEqual([...headers], expected) + }) + + await t.test('returns combined and sorted entries using .forEach()', (t) => { + const { deepStrictEqual, strictEqual } = tspl(t, { plan: 8 }) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'] + ] + const headers = new Headers(init) + const that = {} + let i = 0 + headers.forEach(function (value, key, _headers) { + deepStrictEqual(expected[i++], [key, value]) + strictEqual(this, that) + }, that) + }) + + await t.test('returns combined and sorted entries using .entries()', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 4 }) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'] + ] + const headers = new Headers(init) + let i = 0 + for (const header of headers.entries()) { + deepStrictEqual(header, expected[i++]) + } + }) + + await t.test('returns combined and sorted keys using .keys()', (t) => { + const { strictEqual } = tspl(t, { plan: 4 }) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = ['a', 'abc', 'b', 'c'] + const headers = new Headers(init) + let i = 0 + for (const key of headers.keys()) { + strictEqual(key, expected[i++]) + } + }) + + await t.test('returns combined and sorted values using .values()', (t) => { + const { strictEqual } = tspl(t, { plan: 4 }) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'] + ] + const expected = ['1', '4', '2, 5', '3'] + const headers = new Headers(init) + let i = 0 + for (const value of headers.values()) { + strictEqual(value, expected[i++]) + } + }) + + await t.test('returns combined and sorted entries using for...of loop', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 5 }) + const init = [ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['abc', '4'], + ['b', '5'], + ['d', ['6', '7']] + ] + const expected = [ + ['a', '1'], + ['abc', '4'], + ['b', '2, 5'], + ['c', '3'], + ['d', '6,7'] + ] + let i = 0 + for (const header of new Headers(init)) { + deepStrictEqual(header, expected[i++]) + } + }) + + await t.test('validate append ordering', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + const headers = new Headers([['b', '2'], ['c', '3'], ['e', '5']]) + headers.append('d', '4') + headers.append('a', '1') + headers.append('f', '6') + headers.append('c', '7') + headers.append('abc', '8') + + const expected = [...new Map([ + ['a', '1'], + ['abc', '8'], + ['b', '2'], + ['c', '3, 7'], + ['d', '4'], + ['e', '5'], + ['f', '6'] + ])] + + deepStrictEqual([...headers], expected) + }) + + await t.test('always use the same prototype Iterator', (t) => { + const HeadersIteratorNext = Function.call.bind(new Headers()[Symbol.iterator]().next) + + const init = [ + ['a', '1'], + ['b', '2'] + ] + + const headers = new Headers(init) + const iterator = headers[Symbol.iterator]() + assert.deepStrictEqual(HeadersIteratorNext(iterator), { value: init[0], done: false }) + assert.deepStrictEqual(HeadersIteratorNext(iterator), { value: init[1], done: false }) + assert.deepStrictEqual(HeadersIteratorNext(iterator), { value: undefined, done: true }) + }) +}) + +test('arg validation', () => { + // fill + assert.throws(() => { + fill({}, 0) + }, TypeError) + + const headers = new Headers() + + // constructor + assert.throws(() => { + // eslint-disable-next-line + new Headers(0) + }, TypeError) + + // get [Symbol.toStringTag] + assert.doesNotThrow(() => { + Object.prototype.toString.call(Headers.prototype) + }) + + // toString + assert.doesNotThrow(() => { + Headers.prototype.toString.call(null) + }) + + // append + assert.throws(() => { + Headers.prototype.append.call(null) + }, TypeError) + assert.throws(() => { + headers.append() + }, TypeError) + + // delete + assert.throws(() => { + Headers.prototype.delete.call(null) + }, TypeError) + assert.throws(() => { + headers.delete() + }, TypeError) + + // get + assert.throws(() => { + Headers.prototype.get.call(null) + }, TypeError) + assert.throws(() => { + headers.get() + }, TypeError) + + // has + assert.throws(() => { + Headers.prototype.has.call(null) + }, TypeError) + assert.throws(() => { + headers.has() + }, TypeError) + + // set + assert.throws(() => { + Headers.prototype.set.call(null) + }, TypeError) + assert.throws(() => { + headers.set() + }, TypeError) + + // forEach + assert.throws(() => { + Headers.prototype.forEach.call(null) + }, TypeError) + assert.throws(() => { + headers.forEach() + }, TypeError) + assert.throws(() => { + headers.forEach(1) + }, TypeError) + + // inspect + assert.throws(() => { + Headers.prototype[Symbol.for('nodejs.util.inspect.custom')].call(null) + }, TypeError) +}) + +test('function signature verification', async (t) => { + await t.test('function length', () => { + assert.strictEqual(Headers.prototype.append.length, 2) + assert.strictEqual(Headers.prototype.constructor.length, 0) + assert.strictEqual(Headers.prototype.delete.length, 1) + assert.strictEqual(Headers.prototype.entries.length, 0) + assert.strictEqual(Headers.prototype.forEach.length, 1) + assert.strictEqual(Headers.prototype.get.length, 1) + assert.strictEqual(Headers.prototype.has.length, 1) + assert.strictEqual(Headers.prototype.keys.length, 0) + assert.strictEqual(Headers.prototype.set.length, 2) + assert.strictEqual(Headers.prototype.values.length, 0) + assert.strictEqual(Headers.prototype[Symbol.iterator].length, 0) + assert.strictEqual(Headers.prototype.toString.length, 0) + }) + + await t.test('function equality', () => { + assert.strictEqual(Headers.prototype.entries, Headers.prototype[Symbol.iterator]) + assert.strictEqual(Headers.prototype.toString, Object.prototype.toString) + }) + + await t.test('toString and Symbol.toStringTag', () => { + assert.strictEqual(Object.prototype.toString.call(Headers.prototype), '[object Headers]') + assert.strictEqual(Headers.prototype[Symbol.toStringTag], 'Headers') + assert.strictEqual(Headers.prototype.toString.call(null), '[object Null]') + }) +}) + +test('various init paths of Headers', () => { + const h1 = new Headers() + const h2 = new Headers({}) + const h3 = new Headers(undefined) + assert.strictEqual([...h1.entries()].length, 0) + assert.strictEqual([...h2.entries()].length, 0) + assert.strictEqual([...h3.entries()].length, 0) +}) + +test('immutable guard', () => { + const headers = new Headers() + headers.set('key', 'val') + setHeadersGuard(headers, 'immutable') + + assert.throws(() => { + headers.set('asd', 'asd') + }) + assert.throws(() => { + headers.append('asd', 'asd') + }) + assert.throws(() => { + headers.delete('asd') + }) + assert.strictEqual(headers.get('key'), 'val') + assert.strictEqual(headers.has('key'), true) +}) + +test('request-no-cors guard', () => { + const headers = new Headers() + setHeadersGuard(headers, 'request-no-cors') + assert.doesNotThrow(() => { headers.set('key', 'val') }) + assert.doesNotThrow(() => { headers.append('key', 'val') }) +}) + +test('invalid headers', () => { + assert.doesNotThrow(() => new Headers({ "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~": 'test' })) + + const chars = '"(),/:;<=>?@[\\]{}'.split('') + + for (const char of chars) { + assert.throws(() => new Headers({ [char]: 'test' }), TypeError, `The string "${char}" should throw an error.`) + } + + for (const byte of ['\r', '\n', '\t', ' ', String.fromCharCode(128), '']) { + assert.throws(() => { + new Headers().set(byte, 'test') + }, TypeError, 'invalid header name') + } + + for (const byte of [ + '\0', + '\r', + '\n' + ]) { + assert.throws(() => { + new Headers().set('a', `a${byte}b`) + }, TypeError, 'not allowed at all in header value') + } + + assert.doesNotThrow(() => { + new Headers().set('a', '\r') + }) + + assert.doesNotThrow(() => { + new Headers().set('a', '\n') + }) + + assert.throws(() => { + new Headers().set('a', Symbol('symbol')) + }, TypeError, 'symbols should throw') +}) + +test('headers that might cause a ReDoS', () => { + assert.doesNotThrow(() => { + // This test will time out if the ReDoS attack is successful. + const headers = new Headers() + const attack = 'a' + '\t'.repeat(500_000) + '\ta' + headers.append('fhqwhgads', attack) + }) +}) + +test('Headers.prototype.getSetCookie', async (t) => { + await t.test('Mutating the returned list does not affect the set-cookie list', () => { + const h = new Headers([ + ['set-cookie', 'a=b'], + ['set-cookie', 'c=d'] + ]) + + const old = h.getSetCookie() + h.getSetCookie().push('oh=no') + const now = h.getSetCookie() + + assert.deepStrictEqual(old, now) + }) + + // https://github.com/nodejs/undici/issues/1935 + await t.test('When Headers are cloned, so are the cookies (single entry)', async (t) => { + const server = createServer((req, res) => { + res.setHeader('Set-Cookie', 'test=onetwo') + res.end('Hello World!') + }).listen(0) + + await once(server, 'listening') + t.after(closeServerAsPromise(server)) + + const res = await fetch(`http://localhost:${server.address().port}`) + const entries = Object.fromEntries(res.headers.entries()) + + assert.deepStrictEqual(res.headers.getSetCookie(), ['test=onetwo']) + assert.ok('set-cookie' in entries) + }) + + await t.test('When Headers are cloned, so are the cookies (multiple entries)', async (t) => { + const server = createServer((req, res) => { + res.setHeader('Set-Cookie', ['test=onetwo', 'test=onetwothree']) + res.end('Hello World!') + }).listen(0) + + await once(server, 'listening') + t.after(closeServerAsPromise(server)) + + const res = await fetch(`http://localhost:${server.address().port}`) + const entries = Object.fromEntries(res.headers.entries()) + + assert.deepStrictEqual(res.headers.getSetCookie(), ['test=onetwo', 'test=onetwothree']) + assert.ok('set-cookie' in entries) + }) + + await t.test('When Headers are cloned, so are the cookies (Headers constructor)', () => { + const headers = new Headers([['set-cookie', 'a'], ['set-cookie', 'b']]) + + assert.deepStrictEqual([...headers], [...new Headers(headers)]) + }) +}) + +test('When the value is updated, update the cache', (t) => { + const { deepStrictEqual } = tspl(t, { plan: 2 }) + const expected = [['a', 'a'], ['b', 'b'], ['c', 'c']] + const headers = new Headers(expected) + deepStrictEqual([...headers], expected) + headers.append('d', 'd') + deepStrictEqual([...headers], [...expected, ['d', 'd']]) +}) + +test('Symbol.iterator is only accessed once', (t) => { + const { ok } = tspl(t, { plan: 1 }) + + const dict = new Proxy({}, { + get () { + ok(true) + + return function * () {} + } + }) + + new Headers(dict) // eslint-disable-line no-new +}) + +test('Invalid Symbol.iterators', (t) => { + const { throws } = tspl(t, { plan: 3 }) + + throws(() => new Headers({ [Symbol.iterator]: null }), TypeError) + throws(() => new Headers({ [Symbol.iterator]: undefined }), TypeError) + throws(() => { + const obj = { [Symbol.iterator]: null } + Object.defineProperty(obj, Symbol.iterator, { enumerable: false }) + + new Headers(obj) // eslint-disable-line no-new + }, TypeError) +}) + +// https://github.com/nodejs/undici/issues/3829 +test('Invalid key/value records passed to constructor (issue #3829)', (t) => { + assert.throws( + () => new Headers({ [Symbol('x-fake-header')]: '??' }), + new TypeError('Headers constructor: Key Symbol(x-fake-header) in init is a symbol, which cannot be converted to a ByteString.') + ) + + assert.throws( + () => new Headers({ 'x-fake-header': Symbol('why is this here?') }), + new TypeError('Headers constructor: init["x-fake-header"] is a symbol, which cannot be converted to a ByteString.') + ) +}) diff --git a/test/fetch/headerslist-sortedarray.js b/test/fetch/headerslist-sortedarray.js new file mode 100644 index 0000000..9541901 --- /dev/null +++ b/test/fetch/headerslist-sortedarray.js @@ -0,0 +1,38 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { HeadersList, compareHeaderName } = require('../../lib/web/fetch/headers') + +const characters = 'abcdefghijklmnopqrstuvwxyz0123456789' +const charactersLength = characters.length + +function generateAsciiString (length) { + let result = '' + for (let i = 0; i < length; ++i) { + result += characters[Math.floor(Math.random() * charactersLength)] + } + return result +} + +const SORT_RUN = 4000 + +test('toSortedArray (fast-path)', () => { + for (let i = 0; i < SORT_RUN; ++i) { + const headersList = new HeadersList() + for (let j = 0; j < 32; ++j) { + headersList.append(generateAsciiString(4), generateAsciiString(4)) + } + assert.deepStrictEqual(headersList.toSortedArray(), [...headersList].sort(compareHeaderName)) + } +}) + +test('toSortedArray (slow-path)', () => { + for (let i = 0; i < SORT_RUN; ++i) { + const headersList = new HeadersList() + for (let j = 0; j < 64; ++j) { + headersList.append(generateAsciiString(4), generateAsciiString(4)) + } + assert.deepStrictEqual(headersList.toSortedArray(), [...headersList].sort(compareHeaderName)) + } +}) diff --git a/test/fetch/http2.js b/test/fetch/http2.js new file mode 100644 index 0000000..f64756d --- /dev/null +++ b/test/fetch/http2.js @@ -0,0 +1,509 @@ +'use strict' + +const { createSecureServer } = require('node:http2') +const { createReadStream, readFileSync } = require('node:fs') +const { once } = require('node:events') +const { Blob } = require('node:buffer') +const { Readable } = require('node:stream') + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const pem = require('https-pem') + +const { Client, fetch, Headers } = require('../..') + +const { closeClientAndServerAsPromise } = require('../utils/node-http') + +test('[Fetch] Issue#2311', async (t) => { + const expectedBody = 'hello from client!' + + const server = createSecureServer(pem, async (req, res) => { + let body = '' + + req.setEncoding('utf8') + + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': req.headers['x-my-header'] + }) + + for await (const chunk of req) { + body += chunk + } + + res.end(body) + }) + + const { strictEqual } = tspl(t, { plan: 2 }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: expectedBody + } + ) + + const responseBody = await response.text() + + t.after(closeClientAndServerAsPromise(client, server)) + + strictEqual(responseBody, expectedBody) + strictEqual(response.headers.get('x-custom-h2'), 'foo') +}) + +test('[Fetch] Simple GET with h2', async (t) => { + const server = createSecureServer(pem) + const expectedRequestBody = 'hello h2!' + + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + 'x-method': headers[':method'], + ':status': 200 + }) + + stream.end(expectedRequestBody) + }) + + const { strictEqual, throws } = tspl(t, { plan: 5 }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.text() + + t.after(closeClientAndServerAsPromise(client, server)) + + strictEqual(responseBody, expectedRequestBody) + strictEqual(response.headers.get('x-method'), 'GET') + strictEqual(response.headers.get('x-custom-h2'), 'foo') + // https://github.com/nodejs/undici/issues/2415 + throws(() => { + response.headers.get(':status') + }, TypeError) + + // See https://fetch.spec.whatwg.org/#concept-response-status-message + strictEqual(response.statusText, '') +}) + +test('[Fetch] Should handle h2 request with body (string or buffer)', async (t) => { + const server = createSecureServer(pem) + const expectedBody = 'hello from client!' + const expectedRequestBody = 'hello h2!' + const requestBody = [] + + server.on('stream', async (stream, headers) => { + stream.on('data', chunk => requestBody.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(expectedRequestBody) + }) + + const { strictEqual } = tspl(t, { plan: 2 }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: expectedBody + } + ) + + const responseBody = await response.text() + + t.after(closeClientAndServerAsPromise(client, server)) + + strictEqual(Buffer.concat(requestBody).toString('utf-8'), expectedBody) + strictEqual(responseBody, expectedRequestBody) +}) + +// Skipping for now, there is something odd in the way the body is handled +test( + '[Fetch] Should handle h2 request with body (stream)', + async (t) => { + const server = createSecureServer(pem) + const expectedBody = readFileSync(__filename, 'utf-8') + const stream = createReadStream(__filename) + const requestChunks = [] + + const { strictEqual } = tspl(t, { plan: 8 }) + + server.on('stream', async (stream, headers) => { + strictEqual(headers[':method'], 'PUT') + strictEqual(headers[':path'], '/') + strictEqual(headers[':scheme'], 'https') + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + for await (const chunk of stream) { + requestChunks.push(chunk) + } + + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.after(closeClientAndServerAsPromise(client, server)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + }, + body: Readable.toWeb(stream), + duplex: 'half' + } + ) + + const responseBody = await response.text() + + strictEqual(response.status, 200) + strictEqual(response.headers.get('content-type'), 'text/plain; charset=utf-8') + strictEqual(response.headers.get('x-custom-h2'), 'foo') + strictEqual(responseBody, 'hello h2!') + strictEqual(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) +test('Should handle h2 request with body (Blob)', { skip: !Blob }, async (t) => { + const server = createSecureServer(pem) + const expectedBody = 'asd' + const requestChunks = [] + const body = new Blob(['asd'], { + type: 'text/plain' + }) + + const { strictEqual } = tspl(t, { plan: 8 }) + + server.on('stream', async (stream, headers) => { + strictEqual(headers[':method'], 'POST') + strictEqual(headers[':path'], '/') + strictEqual(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end('hello h2!') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.after(closeClientAndServerAsPromise(client, server)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + method: 'POST', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.arrayBuffer() + + strictEqual(response.status, 200) + strictEqual(response.headers.get('content-type'), 'text/plain; charset=utf-8') + strictEqual(response.headers.get('x-custom-h2'), 'foo') + strictEqual(new TextDecoder().decode(responseBody).toString(), 'hello h2!') + strictEqual(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) +}) + +test( + 'Should handle h2 request with body (Blob:ArrayBuffer)', + { skip: !Blob }, + async (t) => { + const server = createSecureServer(pem) + const expectedBody = 'hello' + const requestChunks = [] + const expectedResponseBody = { hello: 'h2' } + const buf = Buffer.from(expectedBody) + const body = new ArrayBuffer(buf.byteLength) + + buf.copy(new Uint8Array(body)) + + const { strictEqual, deepStrictEqual } = tspl(t, { plan: 8 }) + + server.on('stream', async (stream, headers) => { + strictEqual(headers[':method'], 'PUT') + strictEqual(headers[':path'], '/') + strictEqual(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'application/json', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(JSON.stringify(expectedResponseBody)) + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.after(closeClientAndServerAsPromise(client, server)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + const responseBody = await response.json() + + strictEqual(response.status, 200) + strictEqual(response.headers.get('content-type'), 'application/json') + strictEqual(response.headers.get('x-custom-h2'), 'foo') + deepStrictEqual(responseBody, expectedResponseBody) + strictEqual(Buffer.concat(requestChunks).toString('utf-8'), expectedBody) + } +) + +test('Issue#2415', async (t) => { + const { doesNotThrow } = tspl(t, { plan: 1 }) + const server = createSecureServer(pem) + + server.on('stream', async (stream, headers) => { + stream.respond({ + ':status': 200 + }) + stream.end('test') + }) + + server.listen() + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client + } + ) + + await response.text() + + t.after(closeClientAndServerAsPromise(client, server)) + + doesNotThrow(() => new Headers(response.headers)) +}) + +test('Issue #2386', async (t) => { + const server = createSecureServer(pem) + const body = Buffer.from('hello') + const requestChunks = [] + const expectedResponseBody = { hello: 'h2' } + const controller = new AbortController() + const signal = controller.signal + + const { strictEqual, ok } = tspl(t, { plan: 4 }) + + server.on('stream', async (stream, headers) => { + strictEqual(headers[':method'], 'PUT') + strictEqual(headers[':path'], '/') + strictEqual(headers[':scheme'], 'https') + + stream.on('data', chunk => requestChunks.push(chunk)) + + stream.respond({ + 'content-type': 'application/json', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }) + + stream.end(JSON.stringify(expectedResponseBody)) + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.after(closeClientAndServerAsPromise(client, server)) + + await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + body, + signal, + method: 'PUT', + dispatcher: client, + headers: { + 'x-my-header': 'foo', + 'content-type': 'text-plain' + } + } + ) + + controller.abort() + ok(true) +}) + +test('Issue #3046', async (t) => { + const server = createSecureServer(pem) + + const { strictEqual, deepStrictEqual } = tspl(t, { plan: 6 }) + + server.on('stream', async (stream, headers) => { + strictEqual(headers[':method'], 'GET') + strictEqual(headers[':path'], '/') + strictEqual(headers[':scheme'], 'https') + + stream.respond({ + 'set-cookie': ['hello=world', 'foo=bar'], + 'content-type': 'text/html; charset=utf-8', + ':status': 200 + }) + + stream.end('

Hello World

') + }) + + server.listen(0) + await once(server, 'listening') + + const client = new Client(`https://localhost:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true + }) + + t.after(closeClientAndServerAsPromise(client, server)) + + const response = await fetch( + `https://localhost:${server.address().port}/`, + // Needs to be passed to disable the reject unauthorized + { + method: 'GET', + dispatcher: client + } + ) + + strictEqual(response.status, 200) + strictEqual(response.headers.get('content-type'), 'text/html; charset=utf-8') + deepStrictEqual(response.headers.getSetCookie(), ['hello=world', 'foo=bar']) +}) diff --git a/test/fetch/integrity.js b/test/fetch/integrity.js new file mode 100644 index 0000000..b887801 --- /dev/null +++ b/test/fetch/integrity.js @@ -0,0 +1,349 @@ +'use strict' + +const { test, after } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { createHash, getHashes } = require('node:crypto') +const { gzipSync } = require('node:zlib') +const { fetch, setGlobalDispatcher, Agent } = require('../..') +const { once } = require('node:events') +const { closeServerAsPromise } = require('../utils/node-http') + +const supportedHashes = getHashes() + +setGlobalDispatcher(new Agent({ + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1 +})) + +test('request with correct integrity checksum', (t, done) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }) + + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + assert.strictEqual(body, await response.text()) + done() + }) +}) + +test('request with wrong integrity checksum', async (t) => { + const body = 'Hello world!' + const hash = 'c0535e4be2b79ffd93291305436bf889314e4a3faec05ecffcbb7df31ad9e51b' + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const expectedError = new TypeError('fetch failed', { + cause: new Error('integrity mismatch') + }) + + await assert.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }), expectedError) +}) + +test('request with integrity checksum on encoded body', (t, done) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.setHeader('content-encoding', 'gzip') + res.end(gzipSync(body)) + }) + + t.after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + assert.strictEqual(body, await response.text()) + done() + }) +}) + +test('request with a totally incorrect integrity', async (t) => { + const server = createServer((req, res) => { + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + await assert.doesNotReject(fetch(`http://localhost:${server.address().port}`, { + integrity: 'what-integrityisthis' + })) +}) + +test('request with mixed in/valid integrities', async (t) => { + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + await assert.doesNotReject(fetch(`http://localhost:${server.address().port}`, { + integrity: `invalid-integrity sha256-${hash}` + })) +}) + +test('request with sha384 hash', { skip: !supportedHashes.includes('sha384') }, async (t) => { + const body = 'Hello world!' + const hash = createHash('sha384').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + // request should succeed + await assert.doesNotReject(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${hash}` + })) + + // request should fail + await assert.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha384-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + })) +}) + +test('request with sha512 hash', { skip: !supportedHashes.includes('sha512') }, async (t) => { + const body = 'Hello world!' + const hash = createHash('sha512').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + // request should succeed + await assert.doesNotReject(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${hash}` + })) + + // request should fail + await assert.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha512-ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs=' + })) +}) + +test('request with correct integrity checksum (base64url)', async (t) => { + t = tspl(t, { plan: 1 }) + const body = 'Hello world!' + const hash = createHash('sha256').update(body).digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + const response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + }) + t.strictEqual(body, await response.text()) + }) + + await t.completed +}) + +test('request with incorrect integrity checksum (base64url)', async (t) => { + t = tspl(t, { plan: 1 }) + + const body = 'Hello world!' + const hash = createHash('sha256').update('invalid').digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${hash}` + })) + }) + + await t.completed +}) + +test('request with incorrect integrity checksum (only dash)', async (t) => { + t = tspl(t, { plan: 1 }) + + const body = 'Hello world!' + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha256--' + })) + }) + + await t.completed +}) + +test('request with incorrect integrity checksum (non-ascii character)', async (t) => { + t = tspl(t, { plan: 1 }) + + const body = 'Hello world!' + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: 'sha256-ä' + })) + }) + + await t.completed +}) + +test('request with incorrect stronger integrity checksum (non-ascii character)', async (t) => { + t = tspl(t, { plan: 2 }) + + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64') + const sha384 = 'ä' + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + })) + await t.rejects(() => fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + })) + }) + + await t.completed +}) + +test('request with correct integrity checksum (base64). mixed', async (t) => { + t = tspl(t, { plan: 6 }) + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64') + const sha384 = createHash('sha384').update(body).digest('base64') + const sha512 = createHash('sha512').update(body).digest('base64') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + let response + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${sha512} sha256-${sha256}` + }) + + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + }) + t.strictEqual(body, await response.text()) + }) + + await t.completed +}) + +test('request with correct integrity checksum (base64url). mixed', async (t) => { + t = tspl(t, { plan: 6 }) + const body = 'Hello world!' + const sha256 = createHash('sha256').update(body).digest('base64url') + const sha384 = createHash('sha384').update(body).digest('base64url') + const sha512 = createHash('sha512').update(body).digest('base64url') + + const server = createServer((req, res) => { + res.end(body) + }) + + after(closeServerAsPromise(server)) + + server.listen(0, async () => { + let response + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha512-${sha512} sha256-${sha256}` + }) + + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha512-${sha512}` + }) + t.strictEqual(body, await response.text()) + + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha256-${sha256} sha384-${sha384}` + }) + t.strictEqual(body, await response.text()) + response = await fetch(`http://localhost:${server.address().port}`, { + integrity: `sha384-${sha384} sha256-${sha256}` + }) + t.strictEqual(body, await response.text()) + }) + + await t.completed +}) diff --git a/test/fetch/issue-1447.js b/test/fetch/issue-1447.js new file mode 100644 index 0000000..dc49113 --- /dev/null +++ b/test/fetch/issue-1447.js @@ -0,0 +1,41 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') + +const undici = require('../..') +const { fetch: theoreticalGlobalFetch } = require('../../undici-fetch') + +test('Mocking works with both fetches', async (t) => { + const { strictEqual } = tspl(t, { plan: 3 }) + + const mockAgent = new undici.MockAgent() + const body = JSON.stringify({ foo: 'bar' }) + + mockAgent.disableNetConnect() + undici.setGlobalDispatcher(mockAgent) + const pool = mockAgent.get('https://example.com') + + pool.intercept({ + path: '/path', + method: 'POST', + body (bodyString) { + strictEqual(bodyString, body) + return true + } + }).reply(200, { ok: 1 }).times(2) + + const url = new URL('https://example.com/path').href + + // undici fetch from node_modules + await undici.fetch(url, { + method: 'POST', + body + }) + + // the global fetch bundled with esbuild + await theoreticalGlobalFetch(url, { + method: 'POST', + body + }) +}) diff --git a/test/fetch/issue-1711.js b/test/fetch/issue-1711.js new file mode 100644 index 0000000..be48d16 --- /dev/null +++ b/test/fetch/issue-1711.js @@ -0,0 +1,60 @@ +'use strict' + +const assert = require('node:assert') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { fetch } = require('../..') + +test('Redirecting a bunch does not cause a MaxListenersExceededWarning', async (t) => { + let redirects = 0 + + const server = createServer((req, res) => { + if (redirects === 15) { + res.end('Okay goodbye') + return + } + + res.writeHead(302, { + Location: `/${redirects++}` + }) + res.end() + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + process.emitWarning = assert.bind(null, false) + + const url = `http://localhost:${server.address().port}` + const response = await fetch(url, { redirect: 'follow' }) + + assert.deepStrictEqual(response.url, `${url}/${redirects - 1}`) +}) + +test( + 'aborting a Stream throws', + () => { + return new Promise((resolve, reject) => { + const httpServer = createServer((request, response) => { + response.end(new Uint8Array(20000)) + }).listen(async () => { + const serverAddress = httpServer.address() + + if (typeof serverAddress === 'object') { + const abortController = new AbortController() + const readStream = (await fetch(`http://localhost:${serverAddress?.port}`, { signal: abortController.signal })).arrayBuffer() + abortController.abort() + setTimeout(reject) + + try { + await readStream + } catch { + httpServer.close() + resolve() + } + } + }) + }) + } +) diff --git a/test/fetch/issue-2009.js b/test/fetch/issue-2009.js new file mode 100644 index 0000000..d77b60b --- /dev/null +++ b/test/fetch/issue-2009.js @@ -0,0 +1,32 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { closeServerAsPromise } = require('../utils/node-http') + +test('issue 2009', async (t) => { + const { doesNotReject } = tspl(t, { plan: 10 }) + + const server = createServer((req, res) => { + res.setHeader('a', 'b') + res.flushHeaders() + + res.socket.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + for (let i = 0; i < 10; i++) { + await doesNotReject( + fetch(`http://localhost:${server.address().port}`).then( + async (resp) => { + await resp.body.cancel('Some message') + } + ) + ) + } +}) diff --git a/test/fetch/issue-2021.js b/test/fetch/issue-2021.js new file mode 100644 index 0000000..5b949e5 --- /dev/null +++ b/test/fetch/issue-2021.js @@ -0,0 +1,34 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { fetch } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') + +// https://github.com/nodejs/undici/issues/2021 +test('content-length header is removed on redirect', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.writeHead(302, { Location: '/redirect2' }) + res.end() + return + } + + res.end() + }).listen(0).unref() + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const body = 'a+b+c' + + await assert.doesNotReject(fetch(`http://localhost:${server.address().port}/redirect`, { + method: 'POST', + body, + headers: { + 'content-length': Buffer.byteLength(body) + } + })) +}) diff --git a/test/fetch/issue-2171.js b/test/fetch/issue-2171.js new file mode 100644 index 0000000..82577f9 --- /dev/null +++ b/test/fetch/issue-2171.js @@ -0,0 +1,26 @@ +'use strict' + +const { fetch } = require('../..') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const assert = require('node:assert') +const { closeServerAsPromise } = require('../utils/node-http') + +test('error reason is forwarded - issue #2171', { skip: !AbortSignal.timeout }, async (t) => { + const server = createServer(() => {}).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const timeout = AbortSignal.timeout(100) + await assert.rejects( + fetch(`http://localhost:${server.address().port}`, { + signal: timeout + }), + { + name: 'TimeoutError', + code: DOMException.TIMEOUT_ERR + } + ) +}) diff --git a/test/fetch/issue-2242.js b/test/fetch/issue-2242.js new file mode 100644 index 0000000..1c0fe1f --- /dev/null +++ b/test/fetch/issue-2242.js @@ -0,0 +1,54 @@ +'use strict' + +const { beforeEach, describe, it } = require('node:test') +const assert = require('node:assert') +const { fetch } = require('../..') +const nodeFetch = require('../../index-fetch') + +describe('Issue #2242', () => { + ['Already aborted', null, false, true, 123, Symbol('Some reason')].forEach( + (reason) => + describe(`when an already-aborted signal's reason is \`${String( + reason + )}\``, () => { + let signal + beforeEach(() => { + signal = AbortSignal.abort(reason) + }) + it('rejects with that reason ', async () => { + await assert.rejects(fetch('http://localhost', { signal }), (err) => { + assert.strictEqual(err, reason) + return true + }) + }) + it('rejects with that reason (from index-fetch)', async () => { + await assert.rejects( + nodeFetch.fetch('http://localhost', { signal }), + (err) => { + assert.strictEqual(err, reason) + return true + } + ) + }) + }) + ) + + describe("when an already-aborted signal's reason is `undefined`", () => { + let signal + beforeEach(() => { + signal = AbortSignal.abort(undefined) + }) + it('rejects with an `AbortError`', async () => { + await assert.rejects( + fetch('http://localhost', { signal }), + new DOMException('This operation was aborted', 'AbortError') + ) + }) + it('rejects with an `AbortError` (from index-fetch)', async () => { + await assert.rejects( + nodeFetch.fetch('http://localhost', { signal }), + new DOMException('This operation was aborted', 'AbortError') + ) + }) + }) +}) diff --git a/test/fetch/issue-2294-patch-method.js b/test/fetch/issue-2294-patch-method.js new file mode 100644 index 0000000..6223989 --- /dev/null +++ b/test/fetch/issue-2294-patch-method.js @@ -0,0 +1,22 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { Request } = require('../..') + +test('Using `patch` method emits a warning.', (t) => { + t = tspl(t, { plan: 1 }) + + const { emitWarning } = process + + after(() => { + process.emitWarning = emitWarning + }) + + process.emitWarning = (warning, options) => { + t.strictEqual(options.code, 'UNDICI-FETCH-patch') + } + + // eslint-disable-next-line no-new + new Request('https://a', { method: 'patch' }) +}) diff --git a/test/fetch/issue-2318.js b/test/fetch/issue-2318.js new file mode 100644 index 0000000..273e2b9 --- /dev/null +++ b/test/fetch/issue-2318.js @@ -0,0 +1,27 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { fetch } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') + +test('Undici overrides user-provided `Host` header', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + strictEqual(req.headers.host, `localhost:${server.address().port}`) + + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + headers: { + host: 'www.idk.org' + } + }) +}) diff --git a/test/fetch/issue-2828.js b/test/fetch/issue-2828.js new file mode 100644 index 0000000..08e8ea7 --- /dev/null +++ b/test/fetch/issue-2828.js @@ -0,0 +1,32 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { Agent, Request, fetch } = require('../..') +const { tspl } = require('@matteo.collina/tspl') + +test('issue #2828, dispatcher is allowed in RequestInit options', async (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + class CustomAgent extends Agent { + dispatch (options, handler) { + options.headers['x-my-header'] = 'hello' + return super.dispatch(...arguments) + } + } + + const server = createServer((req, res) => { + deepStrictEqual(req.headers['x-my-header'], 'hello') + res.end() + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + const request = new Request(`http://localhost:${server.address().port}`, { + dispatcher: new CustomAgent() + }) + + await fetch(request) +}) diff --git a/test/fetch/issue-2898-comment.js b/test/fetch/issue-2898-comment.js new file mode 100644 index 0000000..c46b80b --- /dev/null +++ b/test/fetch/issue-2898-comment.js @@ -0,0 +1,42 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { Agent, Request, fetch } = require('../..') +const { tspl } = require('@matteo.collina/tspl') + +test('issue #2828, RequestInit dispatcher options overrides Request input dispatcher', async (t) => { + const { strictEqual } = tspl(t, { plan: 2 }) + + class CustomAgentA extends Agent { + dispatch (options, handler) { + options.headers['x-my-header-a'] = 'hello' + return super.dispatch(...arguments) + } + } + + class CustomAgentB extends Agent { + dispatch (options, handler) { + options.headers['x-my-header-b'] = 'world' + return super.dispatch(...arguments) + } + } + + const server = createServer((req, res) => { + strictEqual(req.headers['x-my-header-a'], undefined) + strictEqual(req.headers['x-my-header-b'], 'world') + res.end() + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + const request = new Request(`http://localhost:${server.address().port}`, { + dispatcher: new CustomAgentA() + }) + + await fetch(request, { + dispatcher: new CustomAgentB() + }) +}) diff --git a/test/fetch/issue-2898.js b/test/fetch/issue-2898.js new file mode 100644 index 0000000..231b761 --- /dev/null +++ b/test/fetch/issue-2898.js @@ -0,0 +1,33 @@ +'use strict' + +const assert = require('node:assert') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { fetch } = require('../..') + +// https://github.com/nodejs/undici/issues/2898 +test('421 requests with a body work as expected', async (t) => { + const expected = 'This is a 421 Misdirected Request response.' + + const server = createServer((req, res) => { + res.statusCode = 421 + res.end(expected) + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + for (const body of [ + 'hello', + new Uint8Array(Buffer.from('helloworld', 'utf-8')) + ]) { + const response = await fetch(`http://localhost:${server.address().port}`, { + method: 'POST', + body + }) + + assert.deepStrictEqual(response.status, 421) + assert.deepStrictEqual(await response.text(), expected) + } +}) diff --git a/test/fetch/issue-3267.js b/test/fetch/issue-3267.js new file mode 100644 index 0000000..8b7515e --- /dev/null +++ b/test/fetch/issue-3267.js @@ -0,0 +1,18 @@ +'use strict' + +const { Headers } = require('../..') +const { test } = require('node:test') +const assert = require('node:assert') + +test('Spreading a Headers object yields 0 symbols', (t) => { + const baseHeaders = { 'x-foo': 'bar' } + + const requestHeaders = new Headers({ 'Content-Type': 'application/json' }) + const headers = { + ...baseHeaders, + ...requestHeaders + } + + assert.deepStrictEqual(headers, { 'x-foo': 'bar' }) + assert.doesNotThrow(() => new Headers(headers)) +}) diff --git a/test/fetch/issue-3334.js b/test/fetch/issue-3334.js new file mode 100644 index 0000000..968b2a7 --- /dev/null +++ b/test/fetch/issue-3334.js @@ -0,0 +1,27 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { fetch } = require('../..') + +test('a non-empty origin is not appended (issue #3334)', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const origin = 'https://origin.example.com' + + const server = createServer((req, res) => { + strictEqual(req.headers.origin, origin) + res.end() + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + headers: { origin }, + body: '', + method: 'POST', + redirect: 'error' + }) +}) diff --git a/test/fetch/issue-3616.js b/test/fetch/issue-3616.js new file mode 100644 index 0000000..ed9f739 --- /dev/null +++ b/test/fetch/issue-3616.js @@ -0,0 +1,48 @@ +'use strict' + +const { createServer } = require('node:http') +const { tspl } = require('@matteo.collina/tspl') +const { describe, test, after } = require('node:test') +const { fetch } = require('../..') +const { once } = require('node:events') + +describe('https://github.com/nodejs/undici/issues/3616', () => { + const cases = [ + 'x-gzip', + 'gzip', + 'deflate', + 'br' + ] + + for (const encoding of cases) { + test(encoding, async t => { + t = tspl(t, { plan: 2 }) + const server = createServer((req, res) => { + res.writeHead(200, { + 'Content-Length': '0', + Connection: 'close', + 'Content-Encoding': encoding + }) + res.end() + }) + + after(() => { + server.close() + }) + + server.listen(0) + + await once(server, 'listening') + const result = await fetch(`http://localhost:${server.address().port}/`) + + t.ok(result.body.getReader()) + + process.on('uncaughtException', (reason) => { + t.fail('Uncaught Exception:', reason, encoding) + }) + + await new Promise(resolve => setTimeout(resolve, 100)) + t.ok(true) + }) + } +}) diff --git a/test/fetch/issue-3624.js b/test/fetch/issue-3624.js new file mode 100644 index 0000000..37e722d --- /dev/null +++ b/test/fetch/issue-3624.js @@ -0,0 +1,29 @@ +'use strict' + +const assert = require('node:assert') +const { File } = require('node:buffer') +const { test } = require('node:test') +const { once } = require('node:events') +const { createServer } = require('node:http') +const { fetch, FormData } = require('../..') + +// https://github.com/nodejs/undici/issues/3624 +test('crlf is appended to formdata body (issue #3624)', async (t) => { + const server = createServer((req, res) => { + req.pipe(res) + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + const fd = new FormData() + fd.set('a', 'b') + fd.set('c', new File(['d'], 'd.txt.exe'), 'd.txt.exe') + + const response = await fetch(`http://localhost:${server.address().port}`, { + body: fd, + method: 'POST' + }) + + assert((await response.text()).endsWith('\r\n')) +}) diff --git a/test/fetch/issue-3630.js b/test/fetch/issue-3630.js new file mode 100644 index 0000000..d40a5c6 --- /dev/null +++ b/test/fetch/issue-3630.js @@ -0,0 +1,12 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { Request, Agent } = require('../..') +const { getRequestDispatcher } = require('../../lib/web/fetch/request') + +test('Cloned request should inherit its dispatcher', () => { + const agent = new Agent() + const request = new Request('https://a', { dispatcher: agent }) + assert.strictEqual(getRequestDispatcher(request), agent) +}) diff --git a/test/fetch/issue-3767.js b/test/fetch/issue-3767.js new file mode 100644 index 0000000..fec7c71 --- /dev/null +++ b/test/fetch/issue-3767.js @@ -0,0 +1,30 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { fetch } = require('../..') +const { tspl } = require('@matteo.collina/tspl') + +// https://github.com/nodejs/undici/issues/3767 +test('referrerPolicy unsafe-url is respected', async (t) => { + const { completed, deepEqual } = tspl(t, { plan: 1 }) + + const referrer = 'https://google.com/hello/world' + + const server = createServer((req, res) => { + deepEqual(req.headers.referer, referrer) + + res.end() + }).listen(0) + + t.after(server.close.bind(server)) + await once(server, 'listening') + + await fetch(`http://localhost:${server.address().port}`, { + referrer, + referrerPolicy: 'unsafe-url' + }) + + await completed +}) diff --git a/test/fetch/issue-node-46525.js b/test/fetch/issue-node-46525.js new file mode 100644 index 0000000..b35eeb2 --- /dev/null +++ b/test/fetch/issue-node-46525.js @@ -0,0 +1,28 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { fetch } = require('../..') + +// https://github.com/nodejs/node/issues/46525 +test('No warning when reusing AbortController', async (t) => { + function onWarning () { + throw new Error('Got warning') + } + + const server = createServer((req, res) => res.end()).listen(0) + + await once(server, 'listening') + + process.on('warning', onWarning) + t.after(() => { + process.off('warning', onWarning) + return server.close() + }) + + const controller = new AbortController() + for (let i = 0; i < 15; i++) { + await fetch(`http://localhost:${server.address().port}`, { signal: controller.signal }) + } +}) diff --git a/test/fetch/issue-node-56474.js b/test/fetch/issue-node-56474.js new file mode 100644 index 0000000..5c704a1 --- /dev/null +++ b/test/fetch/issue-node-56474.js @@ -0,0 +1,30 @@ +'use strict' + +const { test } = require('node:test') +const { deepStrictEqual } = require('node:assert') +const { Response } = require('../..') + +// https://github.com/nodejs/node/issues/56474 +test('ReadableStream empty enqueue then other enqueued', async () => { + const iterable = { + async * [Symbol.asyncIterator] () { + yield '' + yield '3' + yield '4' + } + } + + const response = new Response(iterable) + deepStrictEqual(await response.text(), '34') +}) + +test('ReadableStream empty enqueue', async () => { + const iterable = { + async * [Symbol.asyncIterator] () { + yield '' + } + } + + const response = new Response(iterable) + deepStrictEqual(await response.text(), '') +}) diff --git a/test/fetch/issue-rsshub-15532.js b/test/fetch/issue-rsshub-15532.js new file mode 100644 index 0000000..1149133 --- /dev/null +++ b/test/fetch/issue-rsshub-15532.js @@ -0,0 +1,25 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { test } = require('node:test') +const { fetch } = require('../..') +const { tspl } = require('@matteo.collina/tspl') + +// https://github.com/DIYgod/RSSHub/issues/15532 +test('An invalid Origin header is not set', async (t) => { + const { deepStrictEqual } = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + deepStrictEqual(req.headers.origin, undefined) + + res.end() + }).listen(0) + + await once(server, 'listening') + t.after(server.close.bind(server)) + + await fetch(`http://localhost:${server.address().port}`, { + method: 'POST' + }) +}) diff --git a/test/fetch/iterators.js b/test/fetch/iterators.js new file mode 100644 index 0000000..dd8c420 --- /dev/null +++ b/test/fetch/iterators.js @@ -0,0 +1,121 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { Headers, FormData } = require('../..') + +test('Implements " Iterator" properly', async (t) => { + await t.test('all Headers iterators implement Headers Iterator', () => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + + for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) { + const gen = headers[iterable]() + // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object + const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + const iteratorProto = Object.getPrototypeOf(gen) + + assert.ok(gen.constructor === IteratorPrototype.constructor) + assert.ok(gen.prototype === undefined) + // eslint-disable-next-line no-proto + assert.strictEqual(gen.__proto__[Symbol.toStringTag], 'Headers Iterator') + // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049 + assert.ok(!(Headers.prototype[iterable] instanceof function * () {}.constructor)) + // eslint-disable-next-line no-proto + assert.ok(gen.__proto__.next.__proto__ === Function.prototype) + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%." + assert.strictEqual(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator]) + assert.strictEqual(Object.getPrototypeOf(iteratorProto), IteratorPrototype) + } + }) + + await t.test('all FormData iterators implement FormData Iterator', () => { + const fd = new FormData() + + for (const iterable of ['keys', 'values', 'entries', Symbol.iterator]) { + const gen = fd[iterable]() + // https://tc39.es/ecma262/#sec-%25iteratorprototype%25-object + const IteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())) + const iteratorProto = Object.getPrototypeOf(gen) + + assert.ok(gen.constructor === IteratorPrototype.constructor) + assert.ok(gen.prototype === undefined) + // eslint-disable-next-line no-proto + assert.strictEqual(gen.__proto__[Symbol.toStringTag], 'FormData Iterator') + // https://github.com/node-fetch/node-fetch/issues/1119#issuecomment-100222049 + assert.ok(!(Headers.prototype[iterable] instanceof function * () {}.constructor)) + // eslint-disable-next-line no-proto + assert.ok(gen.__proto__.next.__proto__ === Function.prototype) + // https://webidl.spec.whatwg.org/#dfn-iterator-prototype-object + // "The [[Prototype]] internal slot of an iterator prototype object must be %IteratorPrototype%." + assert.strictEqual(gen[Symbol.iterator], IteratorPrototype[Symbol.iterator]) + assert.strictEqual(Object.getPrototypeOf(iteratorProto), IteratorPrototype) + } + }) + + await t.test('Iterator symbols are properly set', async (t) => { + await t.test('Headers', () => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + const gen = headers.entries() + + assert.strictEqual(typeof gen[Symbol.toStringTag], 'string') + assert.strictEqual(typeof gen[Symbol.iterator], 'function') + }) + + await t.test('FormData', () => { + const fd = new FormData() + const gen = fd.entries() + + assert.strictEqual(typeof gen[Symbol.toStringTag], 'string') + assert.strictEqual(typeof gen[Symbol.iterator], 'function') + }) + }) + + await t.test('Iterator does not inherit Generator prototype methods', async (t) => { + await t.test('Headers', () => { + const headers = new Headers([['a', 'b'], ['c', 'd']]) + const gen = headers.entries() + + assert.strictEqual(gen.return, undefined) + assert.strictEqual(gen.throw, undefined) + assert.strictEqual(typeof gen.next, 'function') + }) + + await t.test('FormData', () => { + const fd = new FormData() + const gen = fd.entries() + + assert.strictEqual(gen.return, undefined) + assert.strictEqual(gen.throw, undefined) + assert.strictEqual(typeof gen.next, 'function') + }) + }) + + await t.test('Symbol.iterator', () => { + // Headers + const headerValues = new Headers([['a', 'b']]).entries()[Symbol.iterator]() + assert.deepStrictEqual(Array.from(headerValues), [['a', 'b']]) + + // FormData + const formdata = new FormData() + formdata.set('a', 'b') + const formdataValues = formdata.entries()[Symbol.iterator]() + assert.deepStrictEqual(Array.from(formdataValues), [['a', 'b']]) + }) + + await t.test('brand check', () => { + // Headers + assert.throws(() => { + const gen = new Headers().entries() + // eslint-disable-next-line no-proto + gen.__proto__.next() + }, TypeError) + + // FormData + assert.throws(() => { + const gen = new FormData().entries() + // eslint-disable-next-line no-proto + gen.__proto__.next() + }, TypeError) + }) +}) diff --git a/test/fetch/long-lived-abort-controller.js b/test/fetch/long-lived-abort-controller.js new file mode 100644 index 0000000..819f258 --- /dev/null +++ b/test/fetch/long-lived-abort-controller.js @@ -0,0 +1,48 @@ +'use strict' + +const http = require('node:http') +const { fetch } = require('../../') +const { once } = require('events') +const { test } = require('node:test') +const { closeServerAsPromise } = require('../utils/node-http') +const { strictEqual } = require('node:assert') + +// const isNode18 = process.version.startsWith('v18') + +test('long-lived-abort-controller', { skip: true }, async (t) => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.write('Hello World!') + res.end() + }).listen(0) + + await once(server, 'listening') + + t.after(closeServerAsPromise(server)) + + let warningEmitted = false + function onWarning () { + warningEmitted = true + } + process.on('warning', onWarning) + t.after(() => { + process.off('warning', onWarning) + }) + + const controller = new AbortController() + + // The maxListener is set to 1500 in request.js. + // we set it to 2000 to make sure that we are not leaking event listeners. + // Unfortunately we are relying on GC and implementation details here. + for (let i = 0; i < 2000; i++) { + // make request + const res = await fetch(`http://localhost:${server.address().port}`, { + signal: controller.signal + }) + + // drain body + await res.text() + } + + strictEqual(warningEmitted, false) +}) diff --git a/test/fetch/max-listeners.js b/test/fetch/max-listeners.js new file mode 100644 index 0000000..36e4fef --- /dev/null +++ b/test/fetch/max-listeners.js @@ -0,0 +1,16 @@ +'use strict' + +const { setMaxListeners, getMaxListeners, defaultMaxListeners } = require('events') +const { test } = require('node:test') +const assert = require('node:assert') +const { Request } = require('../..') + +test('test max listeners', (t) => { + const controller = new AbortController() + setMaxListeners(Infinity, controller.signal) + for (let i = 0; i <= defaultMaxListeners; i++) { + // eslint-disable-next-line no-new + new Request('http://asd', { signal: controller.signal }) + } + assert.strictEqual(getMaxListeners(controller.signal), Infinity) +}) diff --git a/test/fetch/pull-dont-push.js b/test/fetch/pull-dont-push.js new file mode 100644 index 0000000..27454bd --- /dev/null +++ b/test/fetch/pull-dont-push.js @@ -0,0 +1,54 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { fetch } = require('../..') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { Readable, pipeline } = require('node:stream') +const { setTimeout: sleep } = require('node:timers/promises') + +const { closeServerAsPromise } = require('../utils/node-http') + +test('pull dont\'t push', async (t) => { + let count = 0 + let socket + const max = 1_000_000 + const server = createServer((req, res) => { + res.statusCode = 200 + socket = res.socket + + // infinite stream + const stream = new Readable({ + read () { + this.push('a') + if (count++ > max) { + this.push(null) + } + } + }) + + pipeline(stream, res, () => {}) + }) + + t.after(closeServerAsPromise(server)) + + server.listen(0) + await once(server, 'listening') + + const res = await fetch(`http://localhost:${server.address().port}`) + + // Some time is needed to fill the buffer + await sleep(1000) + + socket.destroy() + assert.strictEqual(count < max, true) // the stream should be closed before the max + + // consume the stream + try { + /* eslint-disable-next-line no-unused-vars */ + for await (const chunk of res.body) { + // process._rawDebug('chunk', chunk) + } + } catch {} +}) diff --git a/test/fetch/redirect-cross-origin-header.js b/test/fetch/redirect-cross-origin-header.js new file mode 100644 index 0000000..3756c22 --- /dev/null +++ b/test/fetch/redirect-cross-origin-header.js @@ -0,0 +1,51 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { fetch } = require('../..') + +test('Cross-origin redirects clear forbidden headers', async (t) => { + const { strictEqual } = tspl(t, { plan: 6 }) + + const server1 = createServer((req, res) => { + strictEqual(req.headers.cookie, undefined) + strictEqual(req.headers.authorization, undefined) + strictEqual(req.headers['proxy-authorization'], undefined) + + res.end('redirected') + }).listen(0) + + const server2 = createServer((req, res) => { + strictEqual(req.headers.authorization, 'test') + strictEqual(req.headers.cookie, 'ddd=dddd') + + res.writeHead(302, { + ...req.headers, + Location: `http://localhost:${server1.address().port}` + }) + res.end() + }).listen(0) + + t.after(() => { + server1.close() + server2.close() + }) + + await Promise.all([ + once(server1, 'listening'), + once(server2, 'listening') + ]) + + const res = await fetch(`http://localhost:${server2.address().port}`, { + headers: { + Authorization: 'test', + Cookie: 'ddd=dddd', + 'Proxy-Authorization': 'test' + } + }) + + const text = await res.text() + strictEqual(text, 'redirected') +}) diff --git a/test/fetch/redirect.js b/test/fetch/redirect.js new file mode 100644 index 0000000..a9533bc --- /dev/null +++ b/test/fetch/redirect.js @@ -0,0 +1,78 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { fetch } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') + +// https://github.com/nodejs/undici/issues/1776 +test('Redirecting with a body does not cancel the current request - #1776', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 301 + res.setHeader('location', '/redirect/') + res.write('Moved Permanently') + setTimeout(() => res.end(), 500) + return + } + + res.write(req.url) + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`) + assert.strictEqual(await resp.text(), '/redirect/') + assert.ok(resp.redirected) +}) + +test('Redirecting with an empty body does not throw an error - #2027', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 307 + res.setHeader('location', '/redirect/') + res.write('Moved Permanently') + res.end() + return + } + res.write(req.url) + res.end() + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`, { method: 'PUT', body: '' }) + assert.strictEqual(await resp.text(), '/redirect/') + assert.ok(resp.redirected) +}) + +test('Redirecting with a body does not fail to write body - #2543', async (t) => { + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.writeHead(307, { location: '/target' }) + res.write('Moved Permanently') + setTimeout(() => res.end(), 500) + } else { + let body = '' + req.on('data', (chunk) => { body += chunk }) + req.on('end', () => assert.strictEqual(body, 'body')) + res.write('ok') + res.end() + } + }).listen(0) + + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const resp = await fetch(`http://localhost:${server.address().port}/redirect`, { + method: 'POST', + body: 'body' + }) + assert.strictEqual(await resp.text(), 'ok') + assert.ok(resp.redirected) +}) diff --git a/test/fetch/referrrer-policy.js b/test/fetch/referrrer-policy.js new file mode 100644 index 0000000..e2e8ee9 --- /dev/null +++ b/test/fetch/referrrer-policy.js @@ -0,0 +1,122 @@ +'use strict' + +const { once } = require('node:events') +const { createServer } = require('node:http') +const { describe, test } = require('node:test') +const { fetch } = require('../..') +const tspl = require('@matteo.collina/tspl') + +describe('referrer-policy', () => { + ;[ + [ + 'should ignore empty string as policy', + 'origin, asdas, asdaw34, no-referrer,,', + 'no-referrer' + ], + [ + 'should set referrer policy from response headers on redirect', + 'origin', + 'origin' + ], + [ + 'should select the first valid police', + 'asdas, origin', + 'origin' + ], + [ + 'should select the first valid policy #2', + 'no-referrer, asdas, origin, 0943sd', + 'origin' + ], + [ + 'should pick the last fallback over invalid policy tokens', + 'origin, asdas, asdaw34', + 'origin' + ], + [ + 'should set not change request referrer policy if no Referrer-Policy from initial redirect response', + null, + 'strict-origin-when-cross-origin' + ], + [ + 'should set not change request referrer policy if the policy is a non-valid Referrer Policy', + 'asdasd', + 'strict-origin-when-cross-origin' + ], + [ + 'should set not change request referrer policy if the policy is a non-valid Referrer Policy #2', + 'asdasd, asdasa, 12daw,', + 'strict-origin-when-cross-origin' + ], + + [ + 'referrer policy is origin', + 'origin', + 'origin' + ], + [ + 'referrer policy is no-referrer', + 'no-referrer', + 'no-referrer' + ], + [ + 'referrer policy is strict-origin-when-cross-origin', + 'strict-origin-when-cross-origin', + 'strict-origin-when-cross-origin' + ], + [ + 'referrer policy is unsafe-url', + 'unsafe-url', + 'unsafe-url' + ] + ].forEach(([title, responseReferrerPolicy, expectedReferrerPolicy, referrer]) => { + test(title, async (t) => { + t = tspl(t, { plan: 1 }) + + const server = createServer((req, res) => { + switch (res.req.url) { + case '/redirect': + res.writeHead(302, undefined, { + Location: '/target', + 'referrer-policy': responseReferrerPolicy + }) + res.end() + break + case '/target': + switch (expectedReferrerPolicy) { + case 'no-referrer': + t.strictEqual(req.headers['referer'], undefined) + break + case 'origin': + t.strictEqual(req.headers['referer'], `http://127.0.0.1:${port}/`) + break + case 'strict-origin-when-cross-origin': + t.strictEqual(req.headers['referer'], `http://127.0.0.1:${port}/index.html?test=1`) + break + case 'unsafe-url': + t.strictEqual(req.headers['referer'], `http://127.0.0.1:${port}/index.html?test=1`) + break + } + res.writeHead(200, 'dummy', { 'Content-Type': 'text/plain' }) + res.end() + break + } + }) + + server.listen(0) + await once(server, 'listening') + + const { port } = server.address() + await fetch(`http://127.0.0.1:${port}/redirect`, { + referrer: referrer || `http://127.0.0.1:${port}/index.html?test=1` + }) + + await t.completed + + server.closeAllConnections() + server.closeIdleConnections() + server.close() + await once(server, 'close') + }) + }) +}) diff --git a/test/fetch/relative-url.js b/test/fetch/relative-url.js new file mode 100644 index 0000000..2a57e15 --- /dev/null +++ b/test/fetch/relative-url.js @@ -0,0 +1,104 @@ +'use strict' + +const { test, afterEach } = require('node:test') +const assert = require('node:assert') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { + getGlobalOrigin, + setGlobalOrigin, + Response, + Request, + fetch +} = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') + +afterEach(() => setGlobalOrigin(undefined)) + +test('setGlobalOrigin & getGlobalOrigin', () => { + assert.strictEqual(getGlobalOrigin(), undefined) + + setGlobalOrigin('http://localhost:3000') + assert.deepStrictEqual(getGlobalOrigin(), new URL('http://localhost:3000')) + + setGlobalOrigin(undefined) + assert.strictEqual(getGlobalOrigin(), undefined) + + setGlobalOrigin(new URL('http://localhost:3000')) + assert.deepStrictEqual(getGlobalOrigin(), new URL('http://localhost:3000')) + + assert.throws(() => { + setGlobalOrigin('invalid.url') + }, TypeError) + + assert.throws(() => { + setGlobalOrigin('wss://invalid.protocol') + }, TypeError) + + assert.throws(() => setGlobalOrigin(true)) +}) + +test('Response.redirect', () => { + assert.throws(() => { + Response.redirect('/relative/path', 302) + }, TypeError('Failed to parse URL from /relative/path')) + + assert.doesNotThrow(() => { + setGlobalOrigin('http://localhost:3000') + Response.redirect('/relative/path', 302) + }) + + setGlobalOrigin('http://localhost:3000') + const response = Response.redirect('/relative/path', 302) + // See step #7 of https://fetch.spec.whatwg.org/#dom-response-redirect + assert.strictEqual(response.headers.get('location'), 'http://localhost:3000/relative/path') +}) + +test('new Request', (t) => { + assert.throws( + () => new Request('/relative/path'), + TypeError('Failed to parse URL from /relative/path') + ) + + assert.doesNotThrow(() => { + setGlobalOrigin('http://localhost:3000') + // eslint-disable-next-line no-new + new Request('/relative/path') + }) + + setGlobalOrigin('http://localhost:3000') + const request = new Request('/relative/path') + assert.strictEqual(request.url, 'http://localhost:3000/relative/path') +}) + +test('fetch', async (t) => { + await assert.rejects(fetch('/relative/path'), TypeError('Failed to parse URL from /relative/path')) + + await t.test('Basic fetch', async (t) => { + const server = createServer((req, res) => { + assert.strictEqual(req.url, '/relative/path') + res.end() + }).listen(0) + + setGlobalOrigin(`http://localhost:${server.address().port}`) + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + await assert.doesNotReject(fetch('/relative/path')) + }) + + await t.test('fetch return', async (t) => { + const server = createServer((req, res) => { + assert.strictEqual(req.url, '/relative/path') + res.end() + }).listen(0) + + setGlobalOrigin(`http://localhost:${server.address().port}`) + t.after(closeServerAsPromise(server)) + await once(server, 'listening') + + const response = await fetch('/relative/path') + + assert.strictEqual(response.url, `http://localhost:${server.address().port}/relative/path`) + }) +}) diff --git a/test/fetch/request-inspect-custom.js b/test/fetch/request-inspect-custom.js new file mode 100644 index 0000000..e2e60bd --- /dev/null +++ b/test/fetch/request-inspect-custom.js @@ -0,0 +1,22 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const util = require('node:util') +const { Request } = require('../../') + +describe('Request custom inspection', () => { + it('should return a custom inspect output', () => { + const request = new Request('https://example.com/api', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + + const inspectedOutput = util.inspect(request) + + const expectedOutput = "Request {\n method: 'POST',\n url: 'https://example.com/api',\n headers: Headers { 'Content-Type': 'application/json' },\n destination: '',\n referrer: 'about:client',\n referrerPolicy: '',\n mode: 'cors',\n credentials: 'same-origin',\n cache: 'default',\n redirect: 'follow',\n integrity: '',\n keepalive: false,\n isReloadNavigation: false,\n isHistoryNavigation: false,\n signal: AbortSignal { aborted: false }\n}" + assert.strictEqual(inspectedOutput, expectedOutput) + }) +}) diff --git a/test/fetch/request.js b/test/fetch/request.js new file mode 100644 index 0000000..8ee4c8e --- /dev/null +++ b/test/fetch/request.js @@ -0,0 +1,461 @@ +/* globals AbortController */ + +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const { + Request, + Headers, + fetch +} = require('../../') + +const hasSignalReason = 'reason' in AbortSignal.prototype + +test('arg validation', async (t) => { + // constructor + assert.throws(() => { + // eslint-disable-next-line + new Request() + }, TypeError) + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', 0) + }, TypeError) + assert.throws(() => { + const url = new URL('http://asd') + url.password = 'asd' + // eslint-disable-next-line + new Request(url) + }, TypeError) + assert.throws(() => { + const url = new URL('http://asd') + url.username = 'asd' + // eslint-disable-next-line + new Request(url) + }, TypeError) + assert.doesNotThrow(() => { + // eslint-disable-next-line + new Request('http://asd', undefined) + }, TypeError) + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + window: {} + }) + }, TypeError) + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + window: 1 + }) + }, TypeError) + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + mode: 'navigate' + }) + }) + + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + referrerPolicy: 'agjhagna' + }) + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + mode: 'agjhagna' + }) + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + credentials: 'agjhagna' + }) + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + cache: 'agjhagna' + }) + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + method: 'agjhagnaöööö' + }) + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line + new Request('http://asd', { + method: 'TRACE' + }) + }, TypeError) + + assert.throws(() => { + Request.prototype.destination.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.referrer.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.referrerPolicy.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.mode.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.credentials.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.cache.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.redirect.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.integrity.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.keepalive.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.isReloadNavigation.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.isHistoryNavigation.toString() + }, TypeError) + + assert.throws(() => { + Request.prototype.signal.toString() + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line no-unused-expressions + Request.prototype.body + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line no-unused-expressions + Request.prototype.bodyUsed + }, TypeError) + + assert.throws(() => { + Request.prototype.clone.call(null) + }, TypeError) + + assert.doesNotThrow(() => { + Request.prototype[Symbol.toStringTag].charAt(0) + }) + + for (const method of [ + 'text', + 'json', + 'arrayBuffer', + 'blob', + 'formData' + ]) { + await assert.rejects(async () => { + await new Request('http://localhost')[method].call({ + blob () { + return { + text () { + return Promise.resolve('emulating this') + } + } + } + }) + }, TypeError) + } +}) + +test('undefined window', () => { + assert.doesNotThrow(() => new Request('http://asd', { window: undefined })) +}) + +test('undefined body', () => { + const req = new Request('http://asd', { body: undefined }) + assert.strictEqual(req.body, null) +}) + +test('undefined method', () => { + const req = new Request('http://asd', { method: undefined }) + assert.strictEqual(req.method, 'GET') +}) + +test('undefined headers', () => { + const req = new Request('http://asd', { headers: undefined }) + assert.strictEqual([...req.headers.entries()].length, 0) +}) + +test('undefined referrer', () => { + const req = new Request('http://asd', { referrer: undefined }) + assert.strictEqual(req.referrer, 'about:client') +}) + +test('undefined referrerPolicy', () => { + const req = new Request('http://asd', { referrerPolicy: undefined }) + assert.strictEqual(req.referrerPolicy, '') +}) + +test('undefined mode', () => { + const req = new Request('http://asd', { mode: undefined }) + assert.strictEqual(req.mode, 'cors') +}) + +test('undefined credentials', () => { + const req = new Request('http://asd', { credentials: undefined }) + assert.strictEqual(req.credentials, 'same-origin') +}) + +test('undefined cache', () => { + const req = new Request('http://asd', { cache: undefined }) + assert.strictEqual(req.cache, 'default') +}) + +test('undefined redirect', () => { + const req = new Request('http://asd', { redirect: undefined }) + assert.strictEqual(req.redirect, 'follow') +}) + +test('undefined keepalive', () => { + const req = new Request('http://asd', { keepalive: undefined }) + assert.strictEqual(req.keepalive, false) +}) + +test('undefined integrity', () => { + const req = new Request('http://asd', { integrity: undefined }) + assert.strictEqual(req.integrity, '') +}) + +test('null integrity', () => { + const req = new Request('http://asd', { integrity: null }) + assert.strictEqual(req.integrity, 'null') +}) + +test('undefined signal', () => { + const req = new Request('http://asd', { signal: undefined }) + assert.strictEqual(req.signal.aborted, false) +}) + +test('pre aborted signal', () => { + const ac = new AbortController() + ac.abort('gwak') + const req = new Request('http://asd', { signal: ac.signal }) + assert.strictEqual(req.signal.aborted, true) + if (hasSignalReason) { + assert.strictEqual(req.signal.reason, 'gwak') + } +}) + +test('post aborted signal', (t) => { + const { strictEqual, ok } = tspl(t, { plan: 2 }) + + const ac = new AbortController() + const req = new Request('http://asd', { signal: ac.signal }) + strictEqual(req.signal.aborted, false) + ac.signal.addEventListener('abort', () => { + if (hasSignalReason) { + strictEqual(req.signal.reason, 'gwak') + } else { + ok(true) + } + }, { once: true }) + ac.abort('gwak') +}) + +test('pre aborted signal cloned', () => { + const ac = new AbortController() + ac.abort('gwak') + const req = new Request('http://asd', { signal: ac.signal }).clone() + assert.strictEqual(req.signal.aborted, true) + if (hasSignalReason) { + assert.strictEqual(req.signal.reason, 'gwak') + } +}) + +test('URLSearchParams body with Headers object - issue #1407', async () => { + const body = new URLSearchParams({ + abc: 123 + }) + + const request = new Request( + 'http://localhost', + { + method: 'POST', + body, + headers: { + Authorization: 'test' + } + } + ) + + assert.strictEqual(request.headers.get('content-type'), 'application/x-www-form-urlencoded;charset=UTF-8') + assert.strictEqual(request.headers.get('authorization'), 'test') + assert.strictEqual(await request.text(), 'abc=123') +}) + +test('post aborted signal cloned', (t) => { + const { strictEqual, ok } = tspl(t, { plan: 2 }) + + const ac = new AbortController() + const req = new Request('http://asd', { signal: ac.signal }).clone() + strictEqual(req.signal.aborted, false) + ac.signal.addEventListener('abort', () => { + if (hasSignalReason) { + strictEqual(req.signal.reason, 'gwak') + } else { + ok(true) + } + }, { once: true }) + ac.abort('gwak') +}) + +test('Passing headers in init', async (t) => { + // https://github.com/nodejs/undici/issues/1400 + await t.test('Headers instance', () => { + const req = new Request('http://localhost', { + headers: new Headers({ key: 'value' }) + }) + + assert.strictEqual(req.headers.get('key'), 'value') + }) + + await t.test('key:value object', () => { + const req = new Request('http://localhost', { + headers: { key: 'value' } + }) + + assert.strictEqual(req.headers.get('key'), 'value') + }) + + await t.test('[key, value][]', () => { + const req = new Request('http://localhost', { + headers: [['key', 'value']] + }) + + assert.strictEqual(req.headers.get('key'), 'value') + }) +}) + +test('Symbol.toStringTag', () => { + const req = new Request('http://localhost') + + assert.strictEqual(req[Symbol.toStringTag], 'Request') + assert.strictEqual(Request.prototype[Symbol.toStringTag], 'Request') +}) + +test('invalid RequestInit values', () => { + /* eslint-disable no-new */ + assert.throws(() => { + new Request('http://l', { mode: 'CoRs' }) + }, TypeError, 'not exact case = error') + + assert.throws(() => { + new Request('http://l', { mode: 'random' }) + }, TypeError) + + assert.throws(() => { + new Request('http://l', { credentials: 'OMIt' }) + }, TypeError, 'not exact case = error') + + assert.throws(() => { + new Request('http://l', { credentials: 'random' }) + }, TypeError) + + assert.throws(() => { + new Request('http://l', { cache: 'DeFaULt' }) + }, TypeError, 'not exact case = error') + + assert.throws(() => { + new Request('http://l', { cache: 'random' }) + }, TypeError) + + assert.throws(() => { + new Request('http://l', { redirect: 'FOllOW' }) + }, TypeError, 'not exact case = error') + + assert.throws(() => { + new Request('http://l', { redirect: 'random' }) + }, TypeError) + /* eslint-enable no-new */ +}) + +test('RequestInit.signal option', async () => { + assert.throws(() => { + // eslint-disable-next-line no-new + new Request('http://asd', { + signal: true + }) + }, TypeError) + + await assert.rejects(fetch('http://asd', { + signal: false + }), TypeError) +}) + +// https://github.com/nodejs/undici/issues/2050 +test('set-cookie headers get cleared when passing a Request as first param', () => { + const req1 = new Request('http://localhost', { + headers: { + 'set-cookie': 'a=1' + } + }) + + assert.deepStrictEqual([...req1.headers], [['set-cookie', 'a=1']]) + const req2 = new Request(req1, { headers: {} }) + assert.deepStrictEqual([...req1.headers], [['set-cookie', 'a=1']]) + assert.deepStrictEqual([...req2.headers], []) + assert.deepStrictEqual(req2.headers.getSetCookie(), []) +}) + +// https://github.com/nodejs/undici/issues/2124 +test('request.referrer', () => { + for (const referrer of ['about://client', 'about://client:1234']) { + const request = new Request('http://a', { referrer }) + + assert.strictEqual(request.referrer, 'about:client') + } +}) + +// https://github.com/nodejs/undici/issues/2445 +test('Clone the set-cookie header when Request is passed as the first parameter and no header is passed.', (t) => { + const request = new Request('http://localhost', { headers: { 'set-cookie': 'A' } }) + const request2 = new Request(request) + assert.deepStrictEqual([...request.headers], [['set-cookie', 'A']]) + request2.headers.append('set-cookie', 'B') + assert.deepStrictEqual([...request.headers], [['set-cookie', 'A']]) + assert.strictEqual(request.headers.getSetCookie().join(', '), request.headers.get('set-cookie')) + assert.strictEqual(request2.headers.getSetCookie().join(', '), request2.headers.get('set-cookie')) +}) + +// Tests for optimization introduced in https://github.com/nodejs/undici/pull/2456 +test('keys to object prototypes method', (t) => { + const request = new Request('http://localhost', { method: 'hasOwnProperty' }) + assert(typeof request.method === 'string') +}) + +// https://github.com/nodejs/undici/issues/2465 +test('Issue#2465', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const request = new Request('http://localhost', { body: new SharedArrayBuffer(0), method: 'POST' }) + strictEqual(await request.text(), '[object SharedArrayBuffer]') +}) diff --git a/test/fetch/resource-timing.js b/test/fetch/resource-timing.js new file mode 100644 index 0000000..b32fd08 --- /dev/null +++ b/test/fetch/resource-timing.js @@ -0,0 +1,141 @@ +'use strict' + +const { test } = require('node:test') +const { tspl } = require('@matteo.collina/tspl') +const { createServer } = require('node:http') +const { fetch } = require('../..') +const { closeServerAsPromise } = require('../utils/node-http') + +const { + PerformanceObserver, + performance +} = require('node:perf_hooks') + +test('should create a PerformanceResourceTiming after each fetch request', (t, done) => { + const { strictEqual, ok, deepStrictEqual } = tspl(t, { plan: 8 }) + + const obs = new PerformanceObserver(list => { + const expectedResourceEntryName = `http://localhost:${server.address().port}/` + + const entries = list.getEntries() + strictEqual(entries.length, 1) + const [entry] = entries + strictEqual(entry.name, expectedResourceEntryName) + strictEqual(entry.entryType, 'resource') + + ok(entry.duration >= 0) + ok(entry.startTime >= 0) + + const entriesByName = list.getEntriesByName(expectedResourceEntryName) + strictEqual(entriesByName.length, 1) + deepStrictEqual(entriesByName[0], entry) + + obs.disconnect() + performance.clearResourceTimings() + done() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + strictEqual('ok', await body.text()) + }) + + t.after(closeServerAsPromise(server)) +}) + +test('should include encodedBodySize in performance entry', (t, done) => { + const { strictEqual } = tspl(t, { plan: 4 }) + const obs = new PerformanceObserver(list => { + const [entry] = list.getEntries() + strictEqual(entry.encodedBodySize, 2) + strictEqual(entry.decodedBodySize, 2) + strictEqual(entry.transferSize, 2 + 300) + + obs.disconnect() + performance.clearResourceTimings() + done() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}`) + strictEqual('ok', await body.text()) + }) + + t.after(closeServerAsPromise(server)) +}) + +test('timing entries should be in order', (t, done) => { + const { ok, strictEqual } = tspl(t, { plan: 13 }) + const obs = new PerformanceObserver(list => { + const [entry] = list.getEntries() + + ok(entry.startTime > 0) + ok(entry.fetchStart >= entry.startTime) + ok(entry.domainLookupStart >= entry.fetchStart) + ok(entry.domainLookupEnd >= entry.domainLookupStart) + ok(entry.connectStart >= entry.domainLookupEnd) + ok(entry.connectEnd >= entry.connectStart) + ok(entry.requestStart >= entry.connectEnd) + ok(entry.responseStart >= entry.requestStart) + ok(entry.responseEnd >= entry.responseStart) + ok(entry.duration > 0) + + ok(entry.redirectStart === 0) + ok(entry.redirectEnd === 0) + + obs.disconnect() + performance.clearResourceTimings() + done() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/redirect`) + strictEqual('ok', await body.text()) + }) + + t.after(closeServerAsPromise(server)) +}) + +test('redirect timing entries should be included when redirecting', (t, done) => { + const { ok, strictEqual } = tspl(t, { plan: 4 }) + const obs = new PerformanceObserver(list => { + const [entry] = list.getEntries() + + ok(entry.redirectStart >= entry.startTime) + ok(entry.redirectEnd >= entry.redirectStart) + ok(entry.connectStart >= entry.redirectEnd) + + obs.disconnect() + performance.clearResourceTimings() + done() + }) + + obs.observe({ entryTypes: ['resource'] }) + + const server = createServer((req, res) => { + if (req.url === '/redirect') { + res.statusCode = 307 + res.setHeader('location', '/redirect/') + res.end() + return + } + res.end('ok') + }).listen(0, async () => { + const body = await fetch(`http://localhost:${server.address().port}/redirect`) + strictEqual('ok', await body.text()) + }) + + t.after(closeServerAsPromise(server)) +}) diff --git a/test/fetch/response-inspect-custom.js b/test/fetch/response-inspect-custom.js new file mode 100644 index 0000000..ca8a5a0 --- /dev/null +++ b/test/fetch/response-inspect-custom.js @@ -0,0 +1,30 @@ +'use strict' + +const { describe, it } = require('node:test') +const assert = require('node:assert') +const util = require('node:util') +const { Response } = require('../../') + +describe('Response custom inspection', () => { + it('should return a custom inspect output', () => { + const response = new Response(null) + const inspectedOutput = util.inspect(response, { + depth: null, + getters: true + }) + + const expectedOutput = `Response { + status: 200, + statusText: '', + headers: Headers {}, + body: null, + bodyUsed: false, + ok: true, + redirected: false, + type: 'default', + url: '' +}` + + assert.strictEqual(inspectedOutput, expectedOutput) + }) +}) diff --git a/test/fetch/response-json.js b/test/fetch/response-json.js new file mode 100644 index 0000000..7dc6ad2 --- /dev/null +++ b/test/fetch/response-json.js @@ -0,0 +1,98 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { Response } = require('../../') + +// https://github.com/web-platform-tests/wpt/pull/32825/ + +const APPLICATION_JSON = 'application/json' +const FOO_BAR = 'foo/bar' + +const INIT_TESTS = [ + [undefined, 200, '', APPLICATION_JSON, {}], + [{ status: 400 }, 400, '', APPLICATION_JSON, {}], + [{ statusText: 'foo' }, 200, 'foo', APPLICATION_JSON, {}], + [{ headers: {} }, 200, '', APPLICATION_JSON, {}], + [{ headers: { 'content-type': FOO_BAR } }, 200, '', FOO_BAR, {}], + [{ headers: { 'x-foo': 'bar' } }, 200, '', APPLICATION_JSON, { 'x-foo': 'bar' }] +] + +test('Check response returned by static json() with init', async () => { + for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) { + const response = Response.json('hello world', init) + assert.strictEqual(response.type, 'default', "Response's type is default") + assert.strictEqual(response.status, expectedStatus, "Response's status is " + expectedStatus) + assert.strictEqual(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText)) + assert.strictEqual(response.headers.get('content-type'), expectedContentType, "Response's content-type is " + expectedContentType) + for (const key in expectedHeaders) { + assert.strictEqual(response.headers.get(key), expectedHeaders[key], "Response's header " + key + ' is ' + JSON.stringify(expectedHeaders[key])) + } + + const data = await response.json() + assert.strictEqual(data, 'hello world', "Response's body is 'hello world'") + } +}) + +test('Throws TypeError when calling static json() with an invalid status', () => { + const nullBodyStatus = [204, 205, 304] + + for (const status of nullBodyStatus) { + assert.throws(() => { + Response.json('hello world', { status }) + }, TypeError, `Throws TypeError when calling static json() with a status of ${status}`) + } +}) + +test('Check static json() encodes JSON objects correctly', async () => { + const response = Response.json({ foo: 'bar' }) + const data = await response.json() + assert.strictEqual(typeof data, 'object', "Response's json body is an object") + assert.strictEqual(data.foo, 'bar', "Response's json body is { foo: 'bar' }") +}) + +test('Check static json() throws when data is not encodable', () => { + assert.throws(() => { + Response.json(Symbol('foo')) + }, TypeError) +}) + +test('Check static json() throws when data is circular', () => { + const a = { b: 1 } + a.a = a + + assert.throws(() => { + Response.json(a) + }, TypeError) +}) + +test('Check static json() propagates JSON serializer errors', () => { + class CustomError extends Error { + name = 'CustomError' + } + + assert.throws(() => { + Response.json({ get foo () { throw new CustomError('bar') } }) + }, CustomError) +}) + +// note: these tests are not part of any WPTs +test('unserializable values', () => { + assert.throws(() => { + Response.json(Symbol('symbol')) + }, TypeError) + + assert.throws(() => { + Response.json(undefined) + }, TypeError) + + assert.throws(() => { + Response.json() + }, TypeError) +}) + +test('invalid init', () => { + assert.throws(() => { + Response.json(null, 3) + }, TypeError) +}) diff --git a/test/fetch/response.js b/test/fetch/response.js new file mode 100644 index 0000000..230e700 --- /dev/null +++ b/test/fetch/response.js @@ -0,0 +1,299 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const { setImmediate } = require('node:timers/promises') +const { AsyncLocalStorage } = require('node:async_hooks') +const { tspl } = require('@matteo.collina/tspl') +const { + Response, + FormData +} = require('../../') + +test('arg validation', async () => { + // constructor + assert.throws(() => { + // eslint-disable-next-line + new Response(null, 0) + }, TypeError) + assert.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: 99 + }) + }, RangeError) + assert.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: 600 + }) + }, RangeError) + assert.throws(() => { + // eslint-disable-next-line + new Response(null, { + status: '600' + }) + }, RangeError) + assert.throws(() => { + // eslint-disable-next-line + new Response(null, { + statusText: '\u0000' + }) + }, TypeError) + + for (const nullStatus of [204, 205, 304]) { + assert.throws(() => { + // eslint-disable-next-line + new Response(new ArrayBuffer(16), { + status: nullStatus + }) + }, TypeError) + } + + assert.doesNotThrow(() => { + Response.prototype[Symbol.toStringTag].charAt(0) + }, TypeError) + + assert.throws(() => { + Response.prototype.type.toString() + }, TypeError) + + assert.throws(() => { + Response.prototype.url.toString() + }, TypeError) + + assert.throws(() => { + Response.prototype.redirected.toString() + }, TypeError) + + assert.throws(() => { + Response.prototype.status.toString() + }, TypeError) + + assert.throws(() => { + Response.prototype.ok.toString() + }, TypeError) + + assert.throws(() => { + Response.prototype.statusText.toString() + }, TypeError) + + assert.throws(() => { + Response.prototype.headers.toString() + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line no-unused-expressions + Response.prototype.body + }, TypeError) + + assert.throws(() => { + // eslint-disable-next-line no-unused-expressions + Response.prototype.bodyUsed + }, TypeError) + + assert.throws(() => { + Response.prototype.clone.call(null) + }, TypeError) + + await assert.rejects( + new Response('http://localhost').text.call({ + blob () { + return { + text () { + return Promise.resolve('emulating response.blob()') + } + } + } + }), TypeError) +}) + +test('response clone', () => { + // https://github.com/nodejs/undici/issues/1122 + const response1 = new Response(null, { status: 201 }) + const response2 = new Response(undefined, { status: 201 }) + + assert.deepStrictEqual(response1.body, response1.clone().body) + assert.deepStrictEqual(response2.body, response2.clone().body) + assert.strictEqual(response2.body, null) +}) + +test('Symbol.toStringTag', () => { + const resp = new Response() + + assert.strictEqual(resp[Symbol.toStringTag], 'Response') + assert.strictEqual(Response.prototype[Symbol.toStringTag], 'Response') +}) + +test('async iterable body', async () => { + const asyncIterable = { + async * [Symbol.asyncIterator] () { + yield 'a' + yield 'b' + yield 'c' + } + } + + const response = new Response(asyncIterable) + assert.strictEqual(await response.text(), 'abc') +}) + +// https://github.com/nodejs/node/pull/43752#issuecomment-1179678544 +test('Modifying headers using Headers.prototype.set', () => { + const response = new Response('body', { + headers: { + 'content-type': 'test/test', + 'Content-Encoding': 'hello/world' + } + }) + + const response2 = response.clone() + + response.headers.set('content-type', 'application/wasm') + response.headers.set('Content-Encoding', 'world/hello') + + assert.strictEqual(response.headers.get('content-type'), 'application/wasm') + assert.strictEqual(response.headers.get('Content-Encoding'), 'world/hello') + + response2.headers.delete('content-type') + response2.headers.delete('Content-Encoding') + + assert.strictEqual(response2.headers.get('content-type'), null) + assert.strictEqual(response2.headers.get('Content-Encoding'), null) +}) + +// https://github.com/nodejs/node/issues/43838 +test('constructing a Response with a ReadableStream body', async (t) => { + const text = '{"foo":"bar"}' + const uint8 = new TextEncoder().encode(text) + + await t.test('Readable stream with Uint8Array chunks', async () => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(uint8) + controller.close() + } + }) + + const response1 = new Response(readable) + const response2 = response1.clone() + const response3 = response1.clone() + + assert.strictEqual(await response1.text(), text) + assert.deepStrictEqual(await response2.arrayBuffer(), uint8.buffer) + assert.deepStrictEqual(await response3.json(), JSON.parse(text)) + }) + + await t.test('.arrayBuffer() correctly clones multiple buffers', async () => { + const buffer = Buffer.allocUnsafeSlow(2 * 1024 - 2) + const readable = new ReadableStream({ + start (controller) { + for (let i = 0; i < buffer.length; i += 128) { + controller.enqueue(buffer.slice(i, i + 128)) + } + controller.close() + } + }) + + const response = new Response(readable) + assert.deepStrictEqual(await response.arrayBuffer(), buffer.buffer) + }) + + await t.test('Readable stream with non-Uint8Array chunks', async () => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(text) // string + controller.close() + } + }) + + const response = new Response(readable) + + await assert.rejects(response.text(), TypeError) + }) + + await t.test('Readable with ArrayBuffer chunk still throws', async () => { + const readable = new ReadableStream({ + start (controller) { + controller.enqueue(uint8.buffer) + controller.close() + } + }) + + const response1 = new Response(readable) + const response2 = response1.clone() + const response3 = response1.clone() + const response4 = response1.clone() + + await assert.rejects(response1.arrayBuffer(), TypeError) + await assert.rejects(response2.text(), TypeError) + await assert.rejects(response3.json(), TypeError) + await assert.rejects(response4.blob(), TypeError) + }) +}) + +// https://github.com/nodejs/undici/issues/2465 +test('Issue#2465', async (t) => { + const { strictEqual } = tspl(t, { plan: 1 }) + const response = new Response(new SharedArrayBuffer(0)) + strictEqual(await response.text(), '[object SharedArrayBuffer]') +}) + +test('Check the Content-Type of invalid formData', async (t) => { + await t.test('_application/x-www-form-urlencoded', async (t) => { + const { rejects } = tspl(t, { plan: 1 }) + const response = new Response('x=y', { headers: { 'content-type': '_application/x-www-form-urlencoded' } }) + await rejects(response.formData(), TypeError) + }) + + await t.test('_multipart/form-data', async (t) => { + const { rejects } = tspl(t, { plan: 1 }) + const formData = new FormData() + formData.append('x', 'y') + const response = new Response(formData, { headers: { 'content-type': '_multipart/form-data' } }) + await rejects(response.formData(), TypeError) + }) + + await t.test('application/x-www-form-urlencoded_', async (t) => { + const { rejects } = tspl(t, { plan: 1 }) + const response = new Response('x=y', { headers: { 'content-type': 'application/x-www-form-urlencoded_' } }) + await rejects(response.formData(), TypeError) + }) + + await t.test('multipart/form-data_', async (t) => { + const { rejects } = tspl(t, { plan: 1 }) + const formData = new FormData() + formData.append('x', 'y') + const response = new Response(formData, { headers: { 'content-type': 'multipart/form-data_' } }) + await rejects(response.formData(), TypeError) + }) +}) + +test('clone body garbage collection', async () => { + if (typeof global.gc === 'undefined') { + throw new Error('gc is not available. Run with \'--expose-gc\'.') + } + const asyncLocalStorage = new AsyncLocalStorage() + let ref + + await new Promise(resolve => { + asyncLocalStorage.run(new Map(), async () => { + const res = new Response('hello world') + const clone = res.clone() + + asyncLocalStorage.getStore().set('key', clone) + ref = new WeakRef(clone.body) + + await res.text() + await clone.text() // consume body + + resolve() + }) + }) + + await setImmediate() + global.gc() + + const cloneBody = ref.deref() + assert.equal(cloneBody, undefined, 'clone body was not garbage collected') +}) diff --git a/test/fetch/spread.js b/test/fetch/spread.js new file mode 100644 index 0000000..ca05bea --- /dev/null +++ b/test/fetch/spread.js @@ -0,0 +1,41 @@ +'use strict' + +const undici = require('../..') +const { test } = require('node:test') +const assert = require('node:assert') +const { inspect } = require('node:util') + +test('spreading web classes yields empty objects', (t) => { + for (const object of [ + new undici.FormData(), + new undici.Response(null), + new undici.Request('http://a') + ]) { + assert.deepStrictEqual({ ...object }, {}) + } +}) + +test('Objects only have an expected set of symbols on their prototypes', (t) => { + const allowedSymbols = [ + Symbol.iterator, + Symbol.toStringTag, + inspect.custom + ] + + for (const object of [ + undici.FormData, + undici.Response, + undici.Request, + undici.Headers, + undici.WebSocket, + undici.MessageEvent, + undici.CloseEvent, + undici.ErrorEvent, + undici.EventSource + ]) { + const symbols = Object.keys(Object.getOwnPropertyDescriptors(object.prototype)) + .filter(v => typeof v === 'symbol') + + assert(symbols.every(symbol => allowedSymbols.includes(symbol))) + } +}) diff --git a/test/fetch/user-agent.js b/test/fetch/user-agent.js new file mode 100644 index 0000000..da8936c --- /dev/null +++ b/test/fetch/user-agent.js @@ -0,0 +1,28 @@ +'use strict' + +const { test } = require('node:test') +const assert = require('node:assert') +const events = require('node:events') +const http = require('node:http') +const undici = require('../../') +const { closeServerAsPromise } = require('../utils/node-http') + +const nodeBuild = require('../../undici-fetch.js') + +test('user-agent defaults correctly', async (t) => { + const server = http.createServer((req, res) => { + res.end(JSON.stringify({ userAgentHeader: req.headers['user-agent'] })) + }) + t.after(closeServerAsPromise(server)) + + server.listen(0) + await events.once(server, 'listening') + const url = `http://localhost:${server.address().port}` + const [nodeBuildJSON, undiciJSON] = await Promise.all([ + nodeBuild.fetch(url).then((body) => body.json()), + undici.fetch(url).then((body) => body.json()) + ]) + + assert.strictEqual(nodeBuildJSON.userAgentHeader, 'node') + assert.strictEqual(undiciJSON.userAgentHeader, 'undici') +}) diff --git a/test/fetch/util.js b/test/fetch/util.js new file mode 100644 index 0000000..0000d6a --- /dev/null +++ b/test/fetch/util.js @@ -0,0 +1,377 @@ +'use strict' + +const { describe, test } = require('node:test') +const assert = require('node:assert') +const { tspl } = require('@matteo.collina/tspl') +const util = require('../../lib/web/fetch/util') +const { HeadersList } = require('../../lib/web/fetch/headers') +const { createHash } = require('node:crypto') + +test('responseURL', (t) => { + const { ok } = tspl(t, { plan: 2 }) + + ok(util.responseURL({ + urlList: [ + new URL('http://asd'), + new URL('http://fgh') + ] + })) + ok(!util.responseURL({ + urlList: [] + })) +}) + +test('responseLocationURL', (t) => { + const { ok } = tspl(t, { plan: 3 }) + + const acceptHeaderList = new HeadersList() + acceptHeaderList.append('Accept', '*/*') + + const locationHeaderList = new HeadersList() + locationHeaderList.append('Location', 'http://asd') + + ok(!util.responseLocationURL({ + status: 200 + })) + ok(!util.responseLocationURL({ + status: 301, + headersList: acceptHeaderList + })) + ok(util.responseLocationURL({ + status: 301, + headersList: locationHeaderList, + urlList: [ + new URL('http://asd'), + new URL('http://fgh') + ] + })) +}) + +test('requestBadPort', (t) => { + const { strictEqual } = tspl(t, { plan: 3 }) + + strictEqual('allowed', util.requestBadPort({ + urlList: [new URL('https://asd')] + })) + strictEqual('blocked', util.requestBadPort({ + urlList: [new URL('http://asd:7')] + })) + strictEqual('blocked', util.requestBadPort({ + urlList: [new URL('https://asd:7')] + })) +}) + +// https://html.spec.whatwg.org/multipage/origin.html#same-origin +// look at examples +test('sameOrigin', async (t) => { + await t.test('first test', () => { + const A = { + protocol: 'https:', + hostname: 'example.org', + port: '' + } + + const B = { + protocol: 'https:', + hostname: 'example.org', + port: '' + } + + assert.ok(util.sameOrigin(A, B)) + }) + + await t.test('second test', () => { + const A = { + protocol: 'https:', + hostname: 'example.org', + port: '314' + } + + const B = { + protocol: 'https:', + hostname: 'example.org', + port: '420' + } + + assert.ok(!util.sameOrigin(A, B)) + }) + + await t.test('obviously shouldn\'t be equal', () => { + assert.ok(!util.sameOrigin( + { protocol: 'http:', hostname: 'example.org' }, + { protocol: 'https:', hostname: 'example.org' } + )) + + assert.ok(!util.sameOrigin( + { protocol: 'https:', hostname: 'example.org' }, + { protocol: 'https:', hostname: 'example.com' } + )) + }) + + await t.test('file:// urls', () => { + // urls with opaque origins should return true + + const a = new URL('file:///C:/undici') + const b = new URL('file:///var/undici') + + assert.ok(util.sameOrigin(a, b)) + }) +}) + +test('isURLPotentiallyTrustworthy', (t) => { + // https://datatracker.ietf.org/doc/html/draft-ietf-dnsop-let-localhost-be-localhost#section-5.2 + const valid = [ + 'http://localhost', + 'http://localhost.', + 'http://127.0.0.1', + 'http://[::1]', + 'https://something.com', + 'wss://hello.com', + 'data:text/plain;base64,randomstring', + 'about:blank', + 'about:srcdoc', + 'http://subdomain.localhost', + 'http://subdomain.localhost.', + 'http://adb.localhost', + 'http://localhost.localhost', + 'blob:http://example.com/550e8400-e29b-41d4-a716-446655440000' + ] + const invalid = [ + 'http://localhost.example.com', + 'http://subdomain.localhost.example.com', + 'file:///link/to/file.txt', + 'http://121.3.4.5:55', + 'null:8080', + 'something:8080' + ] + + // t.plan(valid.length + invalid.length + 1) + const { ok } = tspl(t, { plan: valid.length + invalid.length + 1 }) + ok(!util.isURLPotentiallyTrustworthy('string')) + + for (const url of valid) { + const instance = new URL(url) + ok(util.isURLPotentiallyTrustworthy(instance), instance) + } + + for (const url of invalid) { + const instance = new URL(url) + ok(!util.isURLPotentiallyTrustworthy(instance)) + } +}) + +describe('setRequestReferrerPolicyOnRedirect', () => { + [ + [ + 'should ignore empty string as policy', + 'origin, asdas, asdaw34, no-referrer,,', + 'no-referrer' + ], + [ + 'should set referrer policy from response headers on redirect', + 'origin', + 'origin' + ], + [ + 'should select the first valid policy from a response', + 'asdas, origin', + 'origin' + ], + [ + 'should select the first valid policy from a response#2', + 'no-referrer, asdas, origin, 0943sd', + 'origin' + ], + [ + 'should pick the last fallback over invalid policy tokens', + 'origin, asdas, asdaw34', + 'origin' + ], + [ + 'should set not change request referrer policy if no Referrer-Policy from initial redirect response', + null, + 'no-referrer, strict-origin-when-cross-origin' + ], + [ + 'should set not change request referrer policy if the policy is a non-valid Referrer Policy', + 'asdasd', + 'no-referrer, strict-origin-when-cross-origin' + ], + [ + 'should set not change request referrer policy if the policy is a non-valid Referrer Policy #2', + 'asdasd, asdasa, 12daw,', + 'no-referrer, strict-origin-when-cross-origin' + ] + ].forEach(([title, responseReferrerPolicy, expected]) => { + test(title, (t) => { + const request = { + referrerPolicy: 'no-referrer, strict-origin-when-cross-origin' + } + + const actualResponse = { + headersList: new HeadersList() + } + + const { strictEqual } = tspl(t, { plan: 1 }) + + actualResponse.headersList.append('Connection', 'close') + actualResponse.headersList.append('Location', 'https://some-location.com/redirect') + if (responseReferrerPolicy) { + actualResponse.headersList.append('Referrer-Policy', responseReferrerPolicy) + } + util.setRequestReferrerPolicyOnRedirect(request, actualResponse) + + strictEqual(request.referrerPolicy, expected) + }) + }) +}) + +test('parseMetadata', async (t) => { + await t.test('should parse valid metadata with option', () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} !@ sha384-${hash384} !@ sha512-${hash512} !@` + const result = util.parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + }) + + await t.test('should parse valid metadata with non ASCII chars option', () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} !© sha384-${hash384} !€ sha512-${hash512} !µ` + const result = util.parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + }) + + await t.test('should parse valid metadata without option', () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const hash384 = createHash('sha384').update(body).digest('base64') + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} sha384-${hash384} sha512-${hash512}` + const result = util.parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: hash384.replace(/=/g, '') }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + }) + + await t.test('should set hash as undefined when invalid base64 chars are provided', () => { + const body = 'Hello world!' + const hash256 = createHash('sha256').update(body).digest('base64') + const invalidHash384 = 'zifp5hE1Xl5LQQqQz[]Bq/iaq9Wb6jVb//T7EfTmbXD2aEP5c2ZdJr9YTDfcTE1ZH+' + const hash512 = createHash('sha512').update(body).digest('base64') + + const validMetadata = `sha256-${hash256} sha384-${invalidHash384} sha512-${hash512}` + const result = util.parseMetadata(validMetadata) + + assert.deepEqual(result, [ + { algo: 'sha256', hash: hash256.replace(/=/g, '') }, + { algo: 'sha384', hash: undefined }, + { algo: 'sha512', hash: hash512.replace(/=/g, '') } + ]) + }) +}) + +describe('urlHasHttpsScheme', () => { + const { urlHasHttpsScheme } = util + + test('should return false for http url', () => { + assert.strictEqual(urlHasHttpsScheme('http://example.com'), false) + }) + test('should return true for https url', () => { + assert.strictEqual(urlHasHttpsScheme('https://example.com'), true) + }) + test('should return false for http object', () => { + assert.strictEqual(urlHasHttpsScheme({ protocol: 'http:' }), false) + }) + test('should return true for https object', () => { + assert.strictEqual(urlHasHttpsScheme({ protocol: 'https:' }), true) + }) +}) + +describe('isValidHeaderValue', () => { + const { isValidHeaderValue } = util + + test('should return true for valid string', () => { + assert.strictEqual(isValidHeaderValue('valid123'), true) + assert.strictEqual(isValidHeaderValue('va lid123'), true) + assert.strictEqual(isValidHeaderValue('va\tlid123'), true) + }) + test('should return false for string containing NUL', () => { + assert.strictEqual(isValidHeaderValue('invalid\0'), false) + assert.strictEqual(isValidHeaderValue('in\0valid'), false) + assert.strictEqual(isValidHeaderValue('\0invalid'), false) + }) + test('should return false for string containing CR', () => { + assert.strictEqual(isValidHeaderValue('invalid\r'), false) + assert.strictEqual(isValidHeaderValue('in\rvalid'), false) + assert.strictEqual(isValidHeaderValue('\rinvalid'), false) + }) + test('should return false for string containing LF', () => { + assert.strictEqual(isValidHeaderValue('invalid\n'), false) + assert.strictEqual(isValidHeaderValue('in\nvalid'), false) + assert.strictEqual(isValidHeaderValue('\ninvalid'), false) + }) + + test('should return false for string with leading TAB', () => { + assert.strictEqual(isValidHeaderValue('\tinvalid'), false) + }) + test('should return false for string with trailing TAB', () => { + assert.strictEqual(isValidHeaderValue('invalid\t'), false) + }) + test('should return false for string with leading SPACE', () => { + assert.strictEqual(isValidHeaderValue(' invalid'), false) + }) + test('should return false for string with trailing SPACE', () => { + assert.strictEqual(isValidHeaderValue('invalid '), false) + }) +}) + +describe('isOriginIPPotentiallyTrustworthy()', () => { + [ + ['0000:0000:0000:0000:0000:0000:0000:0001', true], + ['0001:0000:0000:0000:0000:0000:0000:0001', false], + ['0000:0000:0000:0000:0000:0000::0001', true], + ['0001:0000:0000:0000:0000:0000::0001', false], + ['0000:0000:0001:0000:0000:0000::0001', false], + ['0000:0000:0000:0000:0000::0001', true], + ['0000:0000:0000:0000::0001', true], + ['0000:0000:0000::0001', true], + ['0000:0000::0001', true], + ['0000::0001', true], + ['::0001', true], + ['::1', true], + ['[::1]', true], + ['::2', false], + ['::', false], + ['127.0.0.1', true], + ['127.255.255.255', true], + ['128.255.255.255', false], + ['127.0.0.1', true], + ['127.0.0.0', false] + ].forEach(([ip, expected]) => { + test(`${ip} is ${expected ? '' : 'not '}potentially trustworthy`, () => { + assert.strictEqual(util.isOriginIPPotentiallyTrustworthy(ip), expected) + }) + }) +}) diff --git a/test/fixed-queue.js b/test/fixed-queue.js new file mode 100644 index 0000000..3cb1b39 --- /dev/null +++ b/test/fixed-queue.js @@ -0,0 +1,39 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test } = require('node:test') + +const FixedQueue = require('../lib/dispatcher/fixed-queue') + +test('fixed queue 1', (t) => { + t = tspl(t, { plan: 5 }) + + const queue = new FixedQueue() + t.strictEqual(queue.head, queue.tail) + t.ok(queue.isEmpty()) + queue.push('a') + t.ok(!queue.isEmpty()) + t.strictEqual(queue.shift(), 'a') + t.strictEqual(queue.shift(), null) +}) + +test('fixed queue 2', (t) => { + t = tspl(t, { plan: 7 + 2047 }) + + const queue = new FixedQueue() + for (let i = 0; i < 2047; i++) { + queue.push('a') + } + t.ok(queue.head.isFull()) + queue.push('a') + t.ok(!queue.head.isFull()) + + t.notEqual(queue.head, queue.tail) + for (let i = 0; i < 2047; i++) { + t.strictEqual(queue.shift(), 'a') + } + t.strictEqual(queue.head, queue.tail) + t.ok(!queue.isEmpty()) + t.strictEqual(queue.shift(), 'a') + t.ok(queue.isEmpty()) +}) diff --git a/test/fixtures/ca.pem b/test/fixtures/ca.pem new file mode 100644 index 0000000..7ceba67 --- /dev/null +++ b/test/fixtures/ca.pem @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +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 +1UV2bT78VPpeGn8= +-----END CERTIFICATE----- diff --git a/test/fixtures/cache-tests/LICENSE b/test/fixtures/cache-tests/LICENSE new file mode 100644 index 0000000..f377bbc --- /dev/null +++ b/test/fixtures/cache-tests/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, Mark Nottingham +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/test/fixtures/cache-tests/results/apache.json b/test/fixtures/cache-tests/results/apache.json new file mode 100644 index 0000000..bba8e09 --- /dev/null +++ b/test/fixtures/cache-tests/results/apache.json @@ -0,0 +1,675 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": true, + "304-etag-update-response-Content-Encoding": [ + "Setup", + "retry" + ], + "304-etag-update-response-Content-Foo": true, + "304-etag-update-response-Content-Length": [ + "Setup", + "retry" + ], + "304-etag-update-response-Content-Location": [ + "Setup", + "retry" + ], + "304-etag-update-response-Content-MD5": [ + "Setup", + "retry" + ], + "304-etag-update-response-Content-Range": [ + "Setup", + "retry" + ], + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": [ + "Setup", + "retry" + ], + "304-etag-update-response-ETag": [ + "Setup", + "retry" + ], + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": true, + "304-etag-update-response-Set-Cookie2": true, + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": true, + "304-etag-update-response-X-Frame-Options": true, + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": true, + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": true, + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-suffix-twoline": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": true, + "cc-resp-no-cache-revalidate-fresh": true, + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": true, + "ccreq-ma1": true, + "ccreq-magreaterage": true, + "ccreq-max-stale": true, + "ccreq-max-stale-age": true, + "ccreq-min-fresh": true, + "ccreq-min-fresh-age": true, + "ccreq-no-cache": true, + "ccreq-no-cache-etag": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "ccreq-no-cache-lm": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "ccreq-no-store": true, + "ccreq-oic": true, + "cdn-cc-invalid-sh-type-unknown": true, + "cdn-cc-invalid-sh-type-wrong": true, + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 01:05:30 GMT\"" + ], + "cdn-fresh-cc-nostore": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-0": true, + "cdn-max-age-0-expires": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-age": true, + "cdn-max-age-case-insensitive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-cc-max-age-invalid-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-extension": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-long-cc-max-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-private": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-remove-age-exceed": [ + "Assertion", + "Response 2 Age header not present." + ], + "cdn-remove-header": true, + "conditional-304-etag": true, + "conditional-etag-forward": true, + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": true, + "conditional-etag-quoted-respond-unquoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": true, + "conditional-etag-strong-respond-multiple-last": true, + "conditional-etag-strong-respond-multiple-second": true, + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-unquoted-respond-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-unquoted-respond-unquoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": true, + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-weak-respond-lowercase": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-weak-respond-omit-slash": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-lm-fresh": true, + "conditional-lm-fresh-earlier": true, + "conditional-lm-fresh-no-lm": [ + "Setup", + "Response 2 does not come from cache" + ], + "conditional-lm-fresh-rfc850": true, + "conditional-lm-stale": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": true, + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": true, + "freshness-expires-invalid-2-digit-year": true, + "freshness-expires-invalid-aest": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": true, + "freshness-expires-invalid-multiple-lines": true, + "freshness-expires-invalid-multiple-spaces": true, + "freshness-expires-invalid-no-comma": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-tz": true, + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-decimal-zero": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": true, + "freshness-max-age-space-before-equals": true, + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-fresh-stale-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sameline": true, + "freshness-max-age-two-stale-fresh-sepline": true, + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "Assertion", + "Response 3 does not come from cache" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-410-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-writethrough": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": true, + "headers-omit-headers-listed-in-Connection": true, + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": true, + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": true, + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": true, + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": true, + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Setup", + "Response 2 status is 500, not 599" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": true, + "heuristic-delta-1800": true, + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": true, + "heuristic-delta-3600": true, + "heuristic-delta-43200": true, + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": true, + "heuristic-delta-600": true, + "heuristic-delta-86400": true, + "invalidate-DELETE": true, + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": true, + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": true, + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": true, + "other-age-update-expires": true, + "other-age-update-max-age": true, + "other-authorization": true, + "other-authorization-must-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-authorization-public": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-authorization-smaxage": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-cookie": true, + "other-date-update": [ + "Assertion", + "Response 2 header Date is \"Tue, 09 Jul 2024 01:05:24 GMT\", not \"Tue, 09 Jul 2024 01:05:21 GMT\"" + ], + "other-date-update-expires": [ + "Assertion", + "Response 2 header Date is \"Tue, 09 Jul 2024 01:05:24 GMT\", not \"Tue, 09 Jul 2024 01:05:21 GMT\"" + ], + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": true, + "other-set-cookie": true, + "partial-store-complete-reuse-partial": true, + "partial-store-complete-reuse-partial-no-last": true, + "partial-store-complete-reuse-partial-suffix": true, + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": true, + "partial-use-stored-headers": true, + "pragma-request-extension": true, + "pragma-request-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-extension": true, + "pragma-response-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": true, + "stale-close": true, + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": true, + "stale-sie-close": true, + "stale-warning-become": true, + "stale-warning-stored": true, + "stale-while-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-while-revalidate-window": [ + "Setup", + "Response 2 does not come from cache" + ], + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-204-stale": true, + "status-299-fresh": [ + "Setup", + "Response 2 status is 500, not 299" + ], + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": true, + "status-302-stale": true, + "status-303-fresh": true, + "status-303-stale": true, + "status-307-fresh": true, + "status-307-stale": true, + "status-308-fresh": true, + "status-308-stale": true, + "status-400-fresh": true, + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Setup", + "Response 2 status is 500, not 499" + ], + "status-499-stale": true, + "status-500-fresh": true, + "status-500-stale": true, + "status-502-fresh": true, + "status-502-stale": true, + "status-503-fresh": true, + "status-503-stale": true, + "status-504-fresh": true, + "status-504-stale": true, + "status-599-fresh": [ + "Setup", + "Response 2 status is 500, not 599" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": true, + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} diff --git a/test/fixtures/cache-tests/results/caddy.json b/test/fixtures/cache-tests/results/caddy.json new file mode 100644 index 0000000..e29f0dc --- /dev/null +++ b/test/fixtures/cache-tests/results/caddy.json @@ -0,0 +1,843 @@ +{ + "304-etag-update-response-Cache-Control": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Clear-Site-Data": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Encoding": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Foo": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Length": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Location": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-MD5": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Range": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Security-Policy": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Content-Type": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-ETag": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Expires": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Public-Key-Pins": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Set-Cookie": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Set-Cookie2": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Test-Header": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-X-Content-Foo": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-X-Frame-Options": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-X-Test-Header": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-X-XSS-Protection": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-lm-use-stored-Test-Header": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": true, + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-suffix-twoline": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": [ + "Assertion", + "Request 3 should have been conditional, but it was not." + ], + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "cc-resp-no-cache-revalidate-fresh": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-max-stale-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-min-fresh": true, + "ccreq-min-fresh-age": true, + "ccreq-no-cache": true, + "ccreq-no-cache-etag": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "ccreq-no-cache-lm": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": [ + "Assertion", + "Response 1 status is 200, not 504" + ], + "cdn-cc-invalid-sh-type-unknown": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-cc-invalid-sh-type-wrong": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 01:03:11 GMT\"" + ], + "cdn-fresh-cc-nostore": true, + "cdn-max-age": true, + "cdn-max-age-0": true, + "cdn-max-age-0-expires": true, + "cdn-max-age-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-case-insensitive": true, + "cdn-max-age-cc-max-age-invalid-expires": true, + "cdn-max-age-expires": true, + "cdn-max-age-extension": true, + "cdn-max-age-long-cc-max-age": true, + "cdn-max-age-max": true, + "cdn-max-age-max-plus": true, + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": true, + "cdn-private": true, + "cdn-remove-age-exceed": [ + "Assertion", + "Response 2 Age header not present." + ], + "cdn-remove-header": true, + "conditional-304-etag": true, + "conditional-etag-forward": true, + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": true, + "conditional-etag-quoted-respond-unquoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-strong-generate": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-strong-respond-multiple-last": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-strong-respond-multiple-second": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-unquoted-respond-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-etag-unquoted-respond-unquoted": true, + "conditional-etag-vary-headers": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": true, + "conditional-etag-weak-respond-lowercase": true, + "conditional-etag-weak-respond-omit-slash": true, + "conditional-lm-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-lm-fresh-earlier": [ + "Assertion", + "Response 2 does not come from cache" + ], + "conditional-lm-fresh-no-lm": [ + "Setup", + "Response 2 does not come from cache" + ], + "conditional-lm-fresh-rfc850": [ + "Setup", + "Response 2 does not come from cache" + ], + "conditional-lm-stale": true, + "freshness-expires-32bit": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-age-fast-date": true, + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-far-future": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-future": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": true, + "freshness-expires-invalid-2-digit-year": true, + "freshness-expires-invalid-aest": true, + "freshness-expires-invalid-date": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-invalid-date-dashes": true, + "freshness-expires-invalid-multiple-lines": true, + "freshness-expires-invalid-multiple-spaces": true, + "freshness-expires-invalid-no-comma": true, + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": true, + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-month": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-tz": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-weekday": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-decimal-zero": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": true, + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": true, + "freshness-max-age-space-before-equals": true, + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-fresh-stale-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sameline": true, + "freshness-max-age-two-stale-fresh-sepline": true, + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "FetchError", + "request to http://localhost:8006/test/3a29d44d-d103-492b-9046-be67546d71d7 failed, reason: Parse Error: Empty Content-Length" + ], + "head-200-retain": [ + "FetchError", + "request to http://localhost:8006/test/d07b2651-4270-4ea6-ae9f-041b1b82aae8 failed, reason: Parse Error: Empty Content-Length" + ], + "head-200-update": [ + "FetchError", + "request to http://localhost:8006/test/eb317c8b-cea4-4b4c-b72b-71df1aa6e863 failed, reason: Parse Error: Empty Content-Length" + ], + "head-410-update": [ + "FetchError", + "request to http://localhost:8006/test/7f947275-bffe-4c72-a493-c64352423d8e failed, reason: Parse Error: Empty Content-Length" + ], + "head-writethrough": [ + "FetchError", + "request to http://localhost:8006/test/f9b80f6e-7980-4a44-ae58-d58ac539223c failed, reason: Parse Error: Empty Content-Length" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Connection": true, + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": true, + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": [ + "Setup", + "Response 1 status is 502, not 200" + ], + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1800": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-3600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-43200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-86400": [ + "Assertion", + "Response 2 does not come from cache" + ], + "invalidate-DELETE": true, + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": true, + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": true, + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": true, + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": true, + "other-age-update-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-update-max-age": true, + "other-authorization": true, + "other-authorization-must-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-authorization-public": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-authorization-smaxage": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-cookie": true, + "other-date-update": true, + "other-date-update-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-date-update-expires-update": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-set-cookie": true, + "partial-store-complete-reuse-partial": [ + "Assertion", + "Response 2 status is 200, not 206" + ], + "partial-store-complete-reuse-partial-no-last": [ + "Assertion", + "Response 2 status is 200, not 206" + ], + "partial-store-complete-reuse-partial-suffix": [ + "Assertion", + "Response 2 status is 200, not 206" + ], + "partial-store-partial-complete": [ + "Setup", + "Response 2 status is 206, not 200" + ], + "partial-store-partial-reuse-partial": true, + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response body is \"01234\", not \"234\"" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response body is \"01234\", not \"234\"" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response body is \"01234\", not \"4\"" + ], + "partial-use-headers": [ + "Setup", + "Response 2 status is 200, not 206" + ], + "partial-use-stored-headers": [ + "Setup", + "Response 2 status is 200, not 206" + ], + "pragma-request-extension": true, + "pragma-request-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-extension": true, + "pragma-response-no-cache": true, + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-sie-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-warning-become": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-warning-stored": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-while-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-while-revalidate-window": [ + "Setup", + "Response 2 does not come from cache" + ], + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-302-stale": true, + "status-303-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-303-stale": true, + "status-307-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-307-stale": true, + "status-308-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-308-stale": true, + "status-400-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-499-stale": true, + "status-500-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-500-stale": true, + "status-502-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-502-stale": true, + "status-503-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-503-stale": true, + "status-504-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-504-stale": true, + "status-599-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": true, + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} diff --git a/test/fixtures/cache-tests/results/chrome.json b/test/fixtures/cache-tests/results/chrome.json new file mode 100644 index 0000000..f243e29 --- /dev/null +++ b/test/fixtures/cache-tests/results/chrome.json @@ -0,0 +1,599 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": [ + "Assertion", + "Response 2 header Clear-Site-Data is \"null\", not \"cookies\"" + ], + "304-etag-update-response-Content-Encoding": [ + "Assertion", + "Response 2 header Content-Encoding is \"arizqhypgxofwne\", not \"askcumewogyqias\"" + ], + "304-etag-update-response-Content-Foo": true, + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": [ + "Assertion", + "Response 2 header Content-Location is \"/foo\", not \"/bar\"" + ], + "304-etag-update-response-Content-MD5": [ + "Assertion", + "Response 2 header Content-MD5 is \"rL0Y20zC+Fzt72VPzMSk2A==\", not \"N7UdGUp1E+RbVvZSTy1R8g==\"" + ], + "304-etag-update-response-Content-Range": [ + "Assertion", + "Response 2 header Content-Range is \"apetixmbqfujync\", not \"aqgwmcsiyoeukaq\"" + ], + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": [ + "Assertion", + "Response 2 header Content-Type is \"text/plain\", not \"text/plain;charset=utf-8\"" + ], + "304-etag-update-response-ETag": [ + "Assertion", + "Response 2 header ETag is \"\"abcdef\"\", not \"\"ghijkl\"\"" + ], + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": [ + "Assertion", + "Response 2 header X-Content-Foo is \"azyxwvutsrqponm\", not \"aaaaaaaaaaaaaaa\"" + ], + "304-etag-update-response-X-Frame-Options": [ + "Assertion", + "Response 2 header X-Frame-Options is \"deny\", not \"sameorigin\"" + ], + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": [ + "Assertion", + "Response 2 header X-XSS-Protection is \"1\", not \"1; mode=block\"" + ], + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": true, + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": true, + "age-parse-suffix-twoline": true, + "cc-resp-immutable-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cc-resp-immutable-stale": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": true, + "cc-resp-no-cache-revalidate-fresh": true, + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-private": true, + "ccreq-ma0": true, + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-max-stale-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-min-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-min-fresh-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache": true, + "ccreq-no-cache-etag": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "ccreq-no-cache-lm": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": [ + "Assertion", + "Response 1 status is 200, not 504" + ], + "conditional-etag-forward": true, + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": [ + "Assertion", + "Request 2 header If-None-Match is \"\"abcdef\"\"" + ], + "conditional-etag-weak-generate-weak": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": true, + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-2-digit-year": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-aest": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-spaces": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-no-comma": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-time-periods": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-utc": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": true, + "freshness-expires-wrong-case-tz": true, + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-decimal-zero": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-private": true, + "freshness-max-age-s-maxage-private-multiple": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": true, + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": true, + "freshness-max-age-two-fresh-stale-sepline": true, + "freshness-max-age-two-stale-fresh-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-none": true, + "head-200-freshness-update": [ + "Assertion", + "Response 3 does not come from cache" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-410-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-writethrough": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": true, + "headers-omit-headers-listed-in-Connection": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": [ + "Assertion", + "Response 2 header Clear-Site-Data is \"null\", not \"cookies\"" + ], + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "headers-store-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"null\", not \"a=c\"" + ], + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": true, + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": true, + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": true, + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": true, + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": true, + "heuristic-delta-1800": true, + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": true, + "heuristic-delta-3600": true, + "heuristic-delta-43200": true, + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": true, + "heuristic-delta-600": true, + "heuristic-delta-86400": true, + "invalidate-DELETE": true, + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": true, + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": true, + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": [ + "Assertion", + "Response 2 Age header not present." + ], + "other-age-update-expires": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-age-update-max-age": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-cookie": true, + "other-date-update": true, + "other-date-update-expires": true, + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": true, + "other-set-cookie": true, + "partial-store-complete-reuse-partial": true, + "partial-store-complete-reuse-partial-no-last": true, + "partial-store-complete-reuse-partial-suffix": true, + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": true, + "partial-use-stored-headers": true, + "pragma-request-extension": true, + "pragma-request-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-extension": true, + "pragma-response-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close": [ + "TypeError", + "Failed to fetch" + ], + "stale-close-must-revalidate": [ + "TypeError", + "Failed to fetch" + ], + "stale-close-no-cache": [ + "TypeError", + "Failed to fetch" + ], + "stale-sie-503": [ + "TypeError", + "Failed to fetch" + ], + "stale-sie-close": [ + "TypeError", + "Failed to fetch" + ], + "stale-warning-become": [ + "TypeError", + "Failed to fetch" + ], + "stale-warning-stored": [ + "TypeError", + "Failed to fetch" + ], + "stale-while-revalidate": true, + "stale-while-revalidate-window": true, + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": true, + "status-299-stale": true, + "status-400-fresh": true, + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": true, + "status-499-stale": true, + "status-500-fresh": true, + "status-500-stale": true, + "status-502-fresh": true, + "status-502-stale": true, + "status-503-fresh": true, + "status-503-stale": true, + "status-504-fresh": true, + "status-504-stale": true, + "status-599-fresh": true, + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": [ + "Assertion", + "Response 3 does not come from cache" + ], + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": true, + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": true, + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} \ No newline at end of file diff --git a/test/fixtures/cache-tests/results/fastly.json b/test/fixtures/cache-tests/results/fastly.json new file mode 100644 index 0000000..c380900 --- /dev/null +++ b/test/fixtures/cache-tests/results/fastly.json @@ -0,0 +1,882 @@ +{ + "304-etag-update-response-Cache-Control": [ + "Assertion", + "Response 2 header Cache-Control is \"max-age=1\", not \"max-age=3600\"" + ], + "304-etag-update-response-Clear-Site-Data": [ + "Assertion", + "Response 2 header Clear-Site-Data is \"cache\", not \"cookies\"" + ], + "304-etag-update-response-Content-Encoding": [ + "Assertion", + "Response 2 header Content-Encoding is \"arizqhypgxofwne\", not \"askcumewogyqias\"" + ], + "304-etag-update-response-Content-Foo": [ + "Assertion", + "Response 2 header Content-Foo is \"awsokgcyuqmieaw\", not \"axurolifczwtqnk\"" + ], + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": [ + "Assertion", + "Response 2 header Content-Location is \"/foo\", not \"/bar\"" + ], + "304-etag-update-response-Content-MD5": [ + "Assertion", + "Response 2 header Content-MD5 is \"rL0Y20zC+Fzt72VPzMSk2A==\", not \"N7UdGUp1E+RbVvZSTy1R8g==\"" + ], + "304-etag-update-response-Content-Range": [ + "Assertion", + "Response 2 header Content-Range is \"null\", not \"aqgwmcsiyoeukaq\"" + ], + "304-etag-update-response-Content-Security-Policy": [ + "Assertion", + "Response 2 header Content-Security-Policy is \"default-src 'self'\", not \"default-src 'self' cdn.example.com\"" + ], + "304-etag-update-response-Content-Type": [ + "Assertion", + "Response 2 header Content-Type is \"text/plain\", not \"text/plain;charset=utf-8\"" + ], + "304-etag-update-response-ETag": [ + "Assertion", + "Response 2 header ETag is \"\"abcdef\"\", not \"\"ghijkl\"\"" + ], + "304-etag-update-response-Expires": [ + "Assertion", + "Response 2 header Expires is \"Fri, 01 Jan 2038 01:01:01 GMT\", not \"Mon, 11 Jan 2038 11:11:11 GMT\"" + ], + "304-etag-update-response-Public-Key-Pins": [ + "Assertion", + "Response 2 header Public-Key-Pins is \"auoicwqkeysmgau\", not \"avqlgbwrmhcxsni\"" + ], + "304-etag-update-response-Set-Cookie": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"a=b\", not \"a=c\"" + ], + "304-etag-update-response-Test-Header": [ + "Assertion", + "Response 2 header Test-Header is \"aaaaaaaaaaaaaaa\", not \"abcdefghijklmno\"" + ], + "304-etag-update-response-X-Content-Foo": [ + "Assertion", + "Response 2 header X-Content-Foo is \"azyxwvutsrqponm\", not \"aaaaaaaaaaaaaaa\"" + ], + "304-etag-update-response-X-Frame-Options": [ + "Assertion", + "Response 2 header X-Frame-Options is \"deny\", not \"sameorigin\"" + ], + "304-etag-update-response-X-Test-Header": [ + "Assertion", + "Response 2 header X-Test-Header is \"adgjmpsvybehknq\", not \"aeimquycgkoswae\"" + ], + "304-etag-update-response-X-XSS-Protection": [ + "Assertion", + "Response 2 header X-XSS-Protection is \"1\", not \"1; mode=block\"" + ], + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": true, + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": true, + "age-parse-parameter": true, + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": true, + "age-parse-suffix-twoline": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cc-resp-no-cache-case-insensitive": [ + "Assertion", + "Response 2 comes from cache" + ], + "cc-resp-no-cache-revalidate": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "cc-resp-no-cache-revalidate-fresh": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-max-stale-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-min-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-min-fresh-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache-etag": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-cache-lm": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": [ + "Assertion", + "Response 1 status is 200, not 504" + ], + "cdn-cc-invalid-sh-type-unknown": true, + "cdn-cc-invalid-sh-type-wrong": true, + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 01:17:30 GMT\"" + ], + "cdn-fresh-cc-nostore": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-0": true, + "cdn-max-age-0-expires": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-age": true, + "cdn-max-age-case-insensitive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-cc-max-age-invalid-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-extension": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-long-cc-max-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-private": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-remove-age-exceed": [ + "Assertion", + "Response 2 Age header not present." + ], + "cdn-remove-header": true, + "conditional-304-etag": true, + "conditional-etag-forward": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": true, + "conditional-etag-quoted-respond-unquoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": true, + "conditional-etag-strong-respond-multiple-last": true, + "conditional-etag-strong-respond-multiple-second": true, + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-quoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-unquoted": true, + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": true, + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": true, + "conditional-etag-weak-respond-lowercase": true, + "conditional-etag-weak-respond-omit-slash": true, + "conditional-lm-fresh": true, + "conditional-lm-fresh-earlier": true, + "conditional-lm-fresh-no-lm": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh-rfc850": true, + "conditional-lm-stale": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-age-slow-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-2-digit-year": true, + "freshness-expires-invalid-aest": true, + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": true, + "freshness-expires-invalid-multiple-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-spaces": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-no-comma": true, + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": true, + "freshness-expires-old-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": true, + "freshness-expires-wrong-case-tz": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": true, + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-decimal-five": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-decimal-zero": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": true, + "freshness-max-age-two-fresh-stale-sepline": true, + "freshness-max-age-two-stale-fresh-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "head-410-update": [ + "Setup", + "Response 3 status is 410, not 200" + ], + "head-writethrough": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-omit-headers-listed-in-Connection": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": [ + "Assertion", + "Response 2 header Content-Range is \"null\", not \"ananananananana\"" + ], + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": [ + "Setup", + "Response 1 status is 503, not 200" + ], + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": [ + "Setup", + "retry" + ], + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1800": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-3600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-43200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-86400": [ + "Assertion", + "Response 2 does not come from cache" + ], + "invalidate-DELETE": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 header age is 0, should be bigger than 0" + ], + "other-age-gen": true, + "other-age-update-expires": [ + "Assertion", + "Response 2 header Age is 3, should be bigger than 32" + ], + "other-age-update-max-age": true, + "other-authorization": [ + "Assertion", + "Response 2 comes from cache" + ], + "other-authorization-must-revalidate": true, + "other-authorization-public": true, + "other-authorization-smaxage": true, + "other-cookie": true, + "other-date-update": [ + "Assertion", + "Response 2 header Date is \"Tue, 09 Jul 2024 01:17:25 GMT\", not \"Tue, 09 Jul 2024 01:17:22 GMT\"" + ], + "other-date-update-expires": [ + "Assertion", + "Response 2 header Date is \"Tue, 09 Jul 2024 01:17:25 GMT\", not \"Tue, 09 Jul 2024 01:17:22 GMT\"" + ], + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-set-cookie": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial": true, + "partial-store-complete-reuse-partial-no-last": true, + "partial-store-complete-reuse-partial-suffix": true, + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": true, + "partial-use-stored-headers": true, + "pragma-request-extension": true, + "pragma-request-no-cache": true, + "pragma-response-extension": true, + "pragma-response-no-cache": true, + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Setup", + "retry" + ], + "stale-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-sie-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-warning-become": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-warning-stored": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-while-revalidate": true, + "stale-while-revalidate-window": true, + "status-200-fresh": true, + "status-200-must-understand": true, + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-204-stale": true, + "status-299-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": true, + "status-302-stale": true, + "status-303-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-303-stale": true, + "status-307-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-307-stale": true, + "status-308-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-308-stale": true, + "status-400-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-499-stale": true, + "status-500-fresh": [ + "Setup", + "retry" + ], + "status-500-stale": [ + "Setup", + "retry" + ], + "status-502-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-502-stale": true, + "status-503-fresh": [ + "Setup", + "retry" + ], + "status-503-stale": [ + "Setup", + "retry" + ], + "status-504-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-504-stale": true, + "status-599-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-empty-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-empty-star-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-foo-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-foo": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-star-lines": [ + "Assertion", + "Response 2 comes from cache" + ] +} diff --git a/test/fixtures/cache-tests/results/firefox.json b/test/fixtures/cache-tests/results/firefox.json new file mode 100644 index 0000000..6757050 --- /dev/null +++ b/test/fixtures/cache-tests/results/firefox.json @@ -0,0 +1,587 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": true, + "304-etag-update-response-Content-Encoding": [ + "Assertion", + "Response 2 header Content-Encoding is \"arizqhypgxofwne\", not \"askcumewogyqias\"" + ], + "304-etag-update-response-Content-Foo": true, + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": [ + "Assertion", + "Response 2 header Content-Location is \"/foo\", not \"/bar\"" + ], + "304-etag-update-response-Content-MD5": [ + "Assertion", + "Response 2 header Content-MD5 is \"rL0Y20zC+Fzt72VPzMSk2A==\", not \"N7UdGUp1E+RbVvZSTy1R8g==\"" + ], + "304-etag-update-response-Content-Range": [ + "Assertion", + "Response 2 header Content-Range is \"apetixmbqfujync\", not \"aqgwmcsiyoeukaq\"" + ], + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": [ + "Assertion", + "Response 2 header Content-Type is \"text/plain\", not \"text/plain;charset=utf-8\"" + ], + "304-etag-update-response-ETag": [ + "Assertion", + "Response 2 header ETag is \"\"abcdef\"\", not \"\"ghijkl\"\"" + ], + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": true, + "304-etag-update-response-X-Frame-Options": true, + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": true, + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": [ + "Assertion", + "Response 2 does not come from cache" + ], + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": [ + "Assertion", + "Response 2 does not come from cache" + ], + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": true, + "age-parse-parameter": true, + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": true, + "age-parse-suffix-twoline": true, + "cc-resp-immutable-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cc-resp-immutable-stale": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": [ + "Assertion", + "Response 2 comes from cache" + ], + "cc-resp-no-cache-revalidate": true, + "cc-resp-no-cache-revalidate-fresh": true, + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-private": true, + "ccreq-ma0": true, + "ccreq-ma1": true, + "ccreq-magreaterage": true, + "ccreq-max-stale": true, + "ccreq-max-stale-age": true, + "ccreq-min-fresh": true, + "ccreq-min-fresh-age": true, + "ccreq-no-cache": true, + "ccreq-no-cache-etag": true, + "ccreq-no-cache-lm": true, + "ccreq-no-store": true, + "ccreq-oic": [ + "Assertion", + "Response 1 status is 200, not 504" + ], + "conditional-etag-forward": true, + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": [ + "Assertion", + "Request 2 header If-None-Match is \"\"abcdef\"\"" + ], + "conditional-etag-weak-generate-weak": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": true, + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-2-digit-year": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-aest": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-spaces": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-no-comma": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-time-periods": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-utc": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": true, + "freshness-expires-wrong-case-tz": true, + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": true, + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": true, + "freshness-max-age-decimal-zero": true, + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-private": true, + "freshness-max-age-s-maxage-private-multiple": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-fresh-stale-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sameline": true, + "freshness-max-age-two-stale-fresh-sepline": true, + "freshness-none": true, + "head-200-freshness-update": [ + "Assertion", + "Response 3 does not come from cache" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-410-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-writethrough": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Connection": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "headers-store-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"null\", not \"a=c\"" + ], + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": true, + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": true, + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": true, + "heuristic-204-cached": true, + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": true, + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": true, + "heuristic-delta-1800": true, + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": true, + "heuristic-delta-3600": true, + "heuristic-delta-43200": true, + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": true, + "heuristic-delta-600": true, + "heuristic-delta-86400": true, + "invalidate-DELETE": true, + "invalidate-DELETE-cl": true, + "invalidate-DELETE-failed": [ + "Assertion", + "Response 3 does not come from cache" + ], + "invalidate-DELETE-location": true, + "invalidate-M-SEARCH": true, + "invalidate-M-SEARCH-cl": true, + "invalidate-M-SEARCH-failed": [ + "Assertion", + "Response 3 does not come from cache" + ], + "invalidate-M-SEARCH-location": true, + "invalidate-POST": true, + "invalidate-POST-cl": true, + "invalidate-POST-failed": [ + "Assertion", + "Response 3 does not come from cache" + ], + "invalidate-POST-location": true, + "invalidate-PUT": true, + "invalidate-PUT-cl": true, + "invalidate-PUT-failed": [ + "Assertion", + "Response 3 does not come from cache" + ], + "invalidate-PUT-location": true, + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": [ + "Assertion", + "Response 2 Age header not present." + ], + "other-age-update-expires": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-age-update-max-age": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-cookie": true, + "other-date-update": true, + "other-date-update-expires": true, + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": true, + "other-set-cookie": true, + "partial-store-complete-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial-no-last": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": [ + "Setup", + "Response 2 does not come from cache" + ], + "partial-use-stored-headers": [ + "Setup", + "Response 2 does not come from cache" + ], + "pragma-request-extension": true, + "pragma-request-no-cache": true, + "pragma-response-extension": true, + "pragma-response-no-cache": true, + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-close-must-revalidate": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-close-no-cache": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-sie-503": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-sie-close": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-warning-become": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-warning-stored": [ + "TypeError", + "NetworkError when attempting to fetch resource." + ], + "stale-while-revalidate": true, + "stale-while-revalidate-window": true, + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-299-stale": true, + "status-400-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-400-stale": true, + "status-404-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-499-stale": true, + "status-500-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-500-stale": true, + "status-502-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-502-stale": true, + "status-503-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-503-stale": true, + "status-504-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-504-stale": true, + "status-599-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": [ + "Assertion", + "Response 3 does not come from cache" + ], + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": true, + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": true, + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} \ No newline at end of file diff --git a/test/fixtures/cache-tests/results/index.mjs b/test/fixtures/cache-tests/results/index.mjs new file mode 100644 index 0000000..e820403 --- /dev/null +++ b/test/fixtures/cache-tests/results/index.mjs @@ -0,0 +1,71 @@ + +export default [ + { + file: 'chrome.json', + name: 'Chrome', + type: 'browser', + version: '126.0.6478.127' + }, + { + file: 'firefox.json', + name: 'Firefox', + type: 'browser', + version: '127.0.2', + link: 'https://github.com/http-tests/cache-tests/wiki/Firefox' + }, + { + file: 'safari.json', + name: 'Safari', + type: 'browser', + version: 'Version 17.5 (19618.2.12.11.6)' + }, + { + file: 'nginx.json', + name: 'nginx', + type: 'rev-proxy', + version: '1.26.0-1ubuntu2', + link: 'https://github.com/http-tests/cache-tests/wiki/nginx' + }, + { + file: 'squid.json', + name: 'Squid', + type: 'rev-proxy', + version: '6.9-1ubuntu1', + link: 'https://github.com/http-tests/cache-tests/wiki/Squid' + }, + { + file: 'trafficserver.json', + name: 'ATS', + type: 'rev-proxy', + version: '9.2.4+ds-2', + link: 'https://github.com/http-tests/cache-tests/wiki/Traffic-Server' + }, + { + file: 'apache.json', + name: 'httpd', + type: 'rev-proxy', + version: '2.4.59-2ubuntu2', + link: 'https://github.com/http-tests/cache-tests/wiki/Apache-httpd' + }, + { + file: 'varnish.json', + name: 'Varnish', + type: 'rev-proxy', + version: '7.1.1-1.1ubuntu1', + link: 'https://github.com/http-tests/cache-tests/wiki/Varnish' + }, + { + file: 'caddy.json', + name: 'caddy', + type: 'rev-proxy', + version: '0.7.0', + link: 'https://github.com/http-tests/cache-tests/wiki/Caddy' + }, + { + file: 'fastly.json', + name: 'Fastly', + type: 'cdn', + version: '2024-07-09', + link: 'https://github.com/http-tests/cache-tests/wiki/Fastly' + } +] diff --git a/test/fixtures/cache-tests/results/nginx.json b/test/fixtures/cache-tests/results/nginx.json new file mode 100644 index 0000000..a180768 --- /dev/null +++ b/test/fixtures/cache-tests/results/nginx.json @@ -0,0 +1,849 @@ +{ + "304-etag-update-response-Cache-Control": [ + "Assertion", + "Response 2 header Cache-Control is \"max-age=1\", not \"max-age=3600\"" + ], + "304-etag-update-response-Clear-Site-Data": [ + "Assertion", + "Response 2 header Clear-Site-Data is \"cache\", not \"cookies\"" + ], + "304-etag-update-response-Content-Encoding": [ + "Assertion", + "Response 2 header Content-Encoding is \"arizqhypgxofwne\", not \"askcumewogyqias\"" + ], + "304-etag-update-response-Content-Foo": [ + "AbortError", + "The user aborted a request." + ], + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": [ + "Assertion", + "Response 2 header Content-Location is \"/foo\", not \"/bar\"" + ], + "304-etag-update-response-Content-MD5": [ + "Assertion", + "Response 2 header Content-MD5 is \"rL0Y20zC+Fzt72VPzMSk2A==\", not \"N7UdGUp1E+RbVvZSTy1R8g==\"" + ], + "304-etag-update-response-Content-Range": [ + "Assertion", + "Response 2 header Content-Range is \"apetixmbqfujync\", not \"aqgwmcsiyoeukaq\"" + ], + "304-etag-update-response-Content-Security-Policy": [ + "Assertion", + "Response 2 header Content-Security-Policy is \"default-src 'self'\", not \"default-src 'self' cdn.example.com\"" + ], + "304-etag-update-response-Content-Type": [ + "AbortError", + "The user aborted a request." + ], + "304-etag-update-response-ETag": [ + "AbortError", + "The user aborted a request." + ], + "304-etag-update-response-Expires": [ + "Assertion", + "Response 2 header Expires is \"Fri, 01 Jan 2038 01:01:01 GMT\", not \"Mon, 11 Jan 2038 11:11:11 GMT\"" + ], + "304-etag-update-response-Public-Key-Pins": [ + "Assertion", + "Response 2 header Public-Key-Pins is \"auoicwqkeysmgau\", not \"avqlgbwrmhcxsni\"" + ], + "304-etag-update-response-Set-Cookie": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"a=b\", not \"a=c\"" + ], + "304-etag-update-response-Test-Header": [ + "Assertion", + "Response 2 header Test-Header is \"aaaaaaaaaaaaaaa\", not \"abcdefghijklmno\"" + ], + "304-etag-update-response-X-Content-Foo": [ + "Assertion", + "Response 2 header X-Content-Foo is \"azyxwvutsrqponm\", not \"aaaaaaaaaaaaaaa\"" + ], + "304-etag-update-response-X-Frame-Options": [ + "Assertion", + "Response 2 header X-Frame-Options is \"deny\", not \"sameorigin\"" + ], + "304-etag-update-response-X-Test-Header": [ + "Assertion", + "Response 2 header X-Test-Header is \"adgjmpsvybehknq\", not \"aeimquycgkoswae\"" + ], + "304-etag-update-response-X-XSS-Protection": [ + "Assertion", + "Response 2 header X-XSS-Protection is \"1\", not \"1; mode=block\"" + ], + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": true, + "age-parse-large": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-large-minus-one": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-larger": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-suffix-twoline": [ + "Assertion", + "Response 2 comes from cache" + ], + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "cc-resp-no-cache-revalidate-fresh": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-max-stale-age": true, + "ccreq-min-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-min-fresh-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache-etag": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-cache-lm": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": [ + "Assertion", + "Response 1 status is 200, not 504" + ], + "cdn-cc-invalid-sh-type-unknown": true, + "cdn-cc-invalid-sh-type-wrong": true, + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 01:06:50 GMT\"" + ], + "cdn-fresh-cc-nostore": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-0": true, + "cdn-max-age-0-expires": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-age": true, + "cdn-max-age-case-insensitive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-cc-max-age-invalid-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-extension": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-long-cc-max-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-private": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-remove-age-exceed": [ + "Assertion", + "Response 2 Age header not present." + ], + "cdn-remove-header": true, + "conditional-304-etag": true, + "conditional-etag-forward": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-quoted-respond-unquoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": true, + "conditional-etag-strong-respond-multiple-last": true, + "conditional-etag-strong-respond-multiple-second": true, + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-quoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-unquoted": true, + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": true, + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": true, + "conditional-etag-weak-respond-lowercase": true, + "conditional-etag-weak-respond-omit-slash": true, + "conditional-lm-fresh": true, + "conditional-lm-fresh-earlier": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh-no-lm": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh-rfc850": true, + "conditional-lm-stale": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-age-slow-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": true, + "freshness-expires-invalid-2-digit-year": true, + "freshness-expires-invalid-aest": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": true, + "freshness-expires-invalid-multiple-lines": true, + "freshness-expires-invalid-multiple-spaces": true, + "freshness-expires-invalid-no-comma": true, + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-old-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-past": true, + "freshness-expires-present": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": true, + "freshness-expires-wrong-case-tz": true, + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-decimal-five": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-decimal-zero": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": true, + "freshness-max-age-space-before-equals": true, + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": true, + "freshness-max-age-two-fresh-stale-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sepline": true, + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "head-410-update": [ + "Setup", + "Response 3 status is 410, not 200" + ], + "head-writethrough": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Connection": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": [ + "Setup", + "Response 1 status is 502, not 200" + ], + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1800": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-3600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-43200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-86400": [ + "Assertion", + "Response 2 does not come from cache" + ], + "invalidate-DELETE": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": [ + "AbortError", + "The user aborted a request." + ], + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": [ + "Assertion", + "Response 2 Age header not present." + ], + "other-age-update-expires": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-age-update-max-age": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-authorization": [ + "Assertion", + "Response 2 comes from cache" + ], + "other-authorization-must-revalidate": true, + "other-authorization-public": true, + "other-authorization-smaxage": true, + "other-cookie": true, + "other-date-update": [ + "Assertion", + "Response 2 header Date is \"Tue, 09 Jul 2024 01:06:44 GMT\", not \"Tue, 09 Jul 2024 01:06:41 GMT\"" + ], + "other-date-update-expires": [ + "Assertion", + "Response 2 header Date is \"Tue, 09 Jul 2024 01:06:44 GMT\", not \"Tue, 09 Jul 2024 01:06:41 GMT\"" + ], + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-set-cookie": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial": [ + "Assertion", + "Response 2 status is 200, not 206" + ], + "partial-store-complete-reuse-partial-no-last": [ + "Assertion", + "Response 2 status is 200, not 206" + ], + "partial-store-complete-reuse-partial-suffix": [ + "Assertion", + "Response 2 status is 200, not 206" + ], + "partial-store-partial-complete": [ + "Setup", + "Response 2 status is 206, not 200" + ], + "partial-store-partial-reuse-partial": [ + "Setup", + "Request 1 header Range is \"undefined\", not \"bytes=-5\"" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response body is \"01234\", not \"234\"" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response body is \"01234\", not \"234\"" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response body is \"01234\", not \"4\"" + ], + "partial-use-headers": [ + "Setup", + "Response 2 status is 200, not 206" + ], + "partial-use-stored-headers": [ + "Setup", + "Response 2 status is 200, not 206" + ], + "pragma-request-extension": true, + "pragma-request-no-cache": true, + "pragma-response-extension": true, + "pragma-response-no-cache": true, + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": true, + "stale-sie-close": true, + "stale-warning-become": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-warning-stored": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-while-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-while-revalidate-window": [ + "Setup", + "Response 2 does not come from cache" + ], + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": true, + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": true, + "status-302-stale": true, + "status-303-fresh": true, + "status-303-stale": true, + "status-307-fresh": true, + "status-307-stale": true, + "status-308-fresh": true, + "status-308-stale": true, + "status-400-fresh": true, + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": true, + "status-499-stale": true, + "status-500-fresh": true, + "status-500-stale": true, + "status-502-fresh": true, + "status-502-stale": true, + "status-503-fresh": true, + "status-503-stale": true, + "status-504-fresh": true, + "status-504-stale": true, + "status-599-fresh": true, + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": true, + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-empty-star-lines": true, + "vary-syntax-foo-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star": true, + "vary-syntax-star-foo": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-star-lines": true +} diff --git a/test/fixtures/cache-tests/results/safari.json b/test/fixtures/cache-tests/results/safari.json new file mode 100644 index 0000000..9a0e5ed --- /dev/null +++ b/test/fixtures/cache-tests/results/safari.json @@ -0,0 +1,611 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": true, + "304-etag-update-response-Content-Encoding": [ + "Assertion", + "Response 2 header Content-Encoding is \"arizqhypgxofwne\", not \"askcumewogyqias\"" + ], + "304-etag-update-response-Content-Foo": [ + "Assertion", + "Response 2 header Content-Foo is \"awsokgcyuqmieaw\", not \"axurolifczwtqnk\"" + ], + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": [ + "Assertion", + "Response 2 header Content-Location is \"/foo\", not \"/bar\"" + ], + "304-etag-update-response-Content-MD5": [ + "Assertion", + "Response 2 header Content-MD5 is \"rL0Y20zC+Fzt72VPzMSk2A==\", not \"N7UdGUp1E+RbVvZSTy1R8g==\"" + ], + "304-etag-update-response-Content-Range": [ + "Assertion", + "Response 2 header Content-Range is \"apetixmbqfujync\", not \"aqgwmcsiyoeukaq\"" + ], + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": [ + "Assertion", + "Response 2 header Content-Type is \"text/plain\", not \"text/plain;charset=utf-8\"" + ], + "304-etag-update-response-ETag": [ + "Assertion", + "Response 2 header ETag is \"\"abcdef\"\", not \"\"ghijkl\"\"" + ], + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": [ + "Assertion", + "Response 2 header X-Content-Foo is \"azyxwvutsrqponm\", not \"aaaaaaaaaaaaaaa\"" + ], + "304-etag-update-response-X-Frame-Options": [ + "Assertion", + "Response 2 header X-Frame-Options is \"deny\", not \"sameorigin\"" + ], + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": [ + "Assertion", + "Response 2 header X-XSS-Protection is \"1\", not \"1; mode=block\"" + ], + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": [ + "Assertion", + "Response 2 does not come from cache" + ], + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-suffix-twoline": [ + "Assertion", + "Response 2 comes from cache" + ], + "cc-resp-immutable-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cc-resp-immutable-stale": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": true, + "cc-resp-no-cache-revalidate-fresh": true, + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-private": true, + "ccreq-ma0": true, + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": true, + "ccreq-max-stale-age": true, + "ccreq-min-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-min-fresh-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache": true, + "ccreq-no-cache-etag": true, + "ccreq-no-cache-lm": true, + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": [ + "TypeError", + "Load failed" + ], + "conditional-etag-forward": true, + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": true, + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-2-digit-year": true, + "freshness-expires-invalid-aest": true, + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-lines": true, + "freshness-expires-invalid-multiple-spaces": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-no-comma": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-month": true, + "freshness-expires-wrong-case-tz": true, + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": true, + "freshness-max-age-decimal-zero": true, + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": true, + "freshness-max-age-s-maxage-private": true, + "freshness-max-age-s-maxage-private-multiple": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": true, + "freshness-max-age-two-fresh-stale-sepline": true, + "freshness-max-age-two-stale-fresh-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-none": true, + "head-200-freshness-update": [ + "Assertion", + "Response 3 does not come from cache" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-410-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-writethrough": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-omit-headers-listed-in-Connection": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "headers-store-Set-Cookie2": [ + "Assertion", + "Response 2 header Set-Cookie2 is \"null\", not \"a=c\"" + ], + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": true, + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": true, + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": true, + "heuristic-204-cached": true, + "heuristic-403-not_cached": true, + "heuristic-404-cached": true, + "heuristic-405-cached": true, + "heuristic-410-cached": true, + "heuristic-414-cached": true, + "heuristic-501-cached": true, + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": true, + "heuristic-delta-1800": true, + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": true, + "heuristic-delta-3600": true, + "heuristic-delta-43200": true, + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": true, + "heuristic-delta-600": true, + "heuristic-delta-86400": true, + "invalidate-DELETE": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": [ + "Assertion", + "Response 2 Age header not present." + ], + "other-age-update-expires": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-age-update-max-age": [ + "Assertion", + "Response 2 header Age is 30, should be bigger than 32" + ], + "other-cookie": true, + "other-date-update": true, + "other-date-update-expires": true, + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": true, + "other-set-cookie": true, + "partial-store-complete-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial-no-last": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": true, + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": [ + "Setup", + "Response 2 does not come from cache" + ], + "partial-use-stored-headers": [ + "Setup", + "Response 2 does not come from cache" + ], + "pragma-request-extension": true, + "pragma-request-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-extension": true, + "pragma-response-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close": [ + "TypeError", + "Load failed" + ], + "stale-close-must-revalidate": [ + "TypeError", + "Load failed" + ], + "stale-close-no-cache": [ + "TypeError", + "Load failed" + ], + "stale-sie-503": [ + "TypeError", + "Load failed" + ], + "stale-sie-close": [ + "TypeError", + "Load failed" + ], + "stale-warning-become": [ + "TypeError", + "Load failed" + ], + "stale-warning-stored": [ + "TypeError", + "Load failed" + ], + "stale-while-revalidate": true, + "stale-while-revalidate-window": true, + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-299-stale": true, + "status-400-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-499-stale": true, + "status-500-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-500-stale": true, + "status-502-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-502-stale": true, + "status-503-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-503-stale": true, + "status-504-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-504-stale": true, + "status-599-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": [ + "Assertion", + "Response 3 does not come from cache" + ], + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": true, + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": true, + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} \ No newline at end of file diff --git a/test/fixtures/cache-tests/results/squid.json b/test/fixtures/cache-tests/results/squid.json new file mode 100644 index 0000000..56f65a1 --- /dev/null +++ b/test/fixtures/cache-tests/results/squid.json @@ -0,0 +1,681 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": true, + "304-etag-update-response-Content-Encoding": true, + "304-etag-update-response-Content-Foo": true, + "304-etag-update-response-Content-Length": [ + "Assertion", + "Response 2 header Content-Length is \"10\", not \"36\"" + ], + "304-etag-update-response-Content-Location": true, + "304-etag-update-response-Content-MD5": true, + "304-etag-update-response-Content-Range": true, + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": true, + "304-etag-update-response-ETag": true, + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Set-Cookie2": true, + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": true, + "304-etag-update-response-X-Frame-Options": true, + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": true, + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": [ + "Assertion", + "Response 2 does not come from cache" + ], + "age-parse-large": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-large-minus-one": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-larger": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": true, + "age-parse-parameter": true, + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": true, + "age-parse-suffix-twoline": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": true, + "cc-resp-no-cache-revalidate-fresh": true, + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": true, + "ccreq-ma1": true, + "ccreq-magreaterage": true, + "ccreq-max-stale": true, + "ccreq-max-stale-age": true, + "ccreq-min-fresh": true, + "ccreq-min-fresh-age": true, + "ccreq-no-cache": true, + "ccreq-no-cache-etag": [ + "Setup", + "Response 2 status is 502, not 200" + ], + "ccreq-no-cache-lm": [ + "Setup", + "Response 2 status is 502, not 200" + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": true, + "cdn-cc-invalid-sh-type-unknown": true, + "cdn-cc-invalid-sh-type-wrong": true, + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 00:51:04 GMT\"" + ], + "cdn-fresh-cc-nostore": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-0": true, + "cdn-max-age-0-expires": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-age": true, + "cdn-max-age-case-insensitive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-cc-max-age-invalid-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-extension": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-long-cc-max-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-private": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-remove-age-exceed": [ + "Assertion", + "Response 2 Age header not present." + ], + "cdn-remove-header": true, + "conditional-304-etag": [ + "Assertion", + "Response 2 header ETag is \"null\", not \"\"abcdef\"\"" + ], + "conditional-etag-forward": true, + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": true, + "conditional-etag-quoted-respond-unquoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "request 2 doesn't have if-none-match header" + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": true, + "conditional-etag-strong-respond-multiple-last": true, + "conditional-etag-strong-respond-multiple-second": true, + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-quoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-unquoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": [ + "Assertion", + "request 2 doesn't have if-none-match header" + ], + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-weak-respond-lowercase": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-weak-respond-omit-slash": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh": true, + "conditional-lm-fresh-earlier": true, + "conditional-lm-fresh-no-lm": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh-rfc850": true, + "conditional-lm-stale": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": true, + "freshness-expires-invalid-2-digit-year": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-aest": true, + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-lines": true, + "freshness-expires-invalid-multiple-spaces": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-no-comma": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": true, + "freshness-expires-old-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": true, + "freshness-expires-wrong-case-tz": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": true, + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": true, + "freshness-max-age-decimal-zero": true, + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": true, + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": true, + "freshness-max-age-two-fresh-stale-sepline": true, + "freshness-max-age-two-stale-fresh-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "Assertion", + "Response 3 does not come from cache" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-410-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-writethrough": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Connection": true, + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": [ + "Setup", + "Response 1 status is 502, not 200" + ], + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": true, + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": true, + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": true, + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": true, + "heuristic-delta-1800": true, + "heuristic-delta-30": true, + "heuristic-delta-300": true, + "heuristic-delta-3600": true, + "heuristic-delta-43200": true, + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": true, + "heuristic-delta-600": true, + "heuristic-delta-86400": true, + "invalidate-DELETE": true, + "invalidate-DELETE-cl": true, + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": true, + "invalidate-M-SEARCH": true, + "invalidate-M-SEARCH-cl": true, + "invalidate-M-SEARCH-failed": [ + "Assertion", + "Response 3 does not come from cache" + ], + "invalidate-M-SEARCH-location": true, + "invalidate-POST": true, + "invalidate-POST-cl": true, + "invalidate-POST-failed": true, + "invalidate-POST-location": true, + "invalidate-PUT": true, + "invalidate-PUT-cl": true, + "invalidate-PUT-failed": true, + "invalidate-PUT-location": true, + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 age header not present." + ], + "other-age-gen": true, + "other-age-update-expires": true, + "other-age-update-max-age": true, + "other-authorization": [ + "Setup", + "Request 1 header Authorization is \"undefined\", not \"FOO\"" + ], + "other-authorization-must-revalidate": [ + "Setup", + "Request 1 header Authorization is \"undefined\", not \"FOO\"" + ], + "other-authorization-public": [ + "Setup", + "Request 1 header Authorization is \"undefined\", not \"FOO\"" + ], + "other-authorization-smaxage": [ + "Setup", + "Request 1 header Authorization is \"undefined\", not \"FOO\"" + ], + "other-cookie": true, + "other-date-update": true, + "other-date-update-expires": true, + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": true, + "other-set-cookie": true, + "partial-store-complete-reuse-partial": true, + "partial-store-complete-reuse-partial-no-last": true, + "partial-store-complete-reuse-partial-suffix": true, + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": true, + "partial-use-stored-headers": true, + "pragma-request-extension": true, + "pragma-request-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-extension": true, + "pragma-response-no-cache": true, + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": true, + "stale-close": true, + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": true, + "stale-sie-close": true, + "stale-warning-become": [ + "Assertion", + "Response 2 warning header not present." + ], + "stale-warning-stored": [ + "Assertion", + "Response 2 warning header not present." + ], + "stale-while-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-while-revalidate-window": [ + "Setup", + "Response 2 does not come from cache" + ], + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-204-stale": true, + "status-299-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": true, + "status-302-stale": true, + "status-303-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-303-stale": true, + "status-307-fresh": true, + "status-307-stale": true, + "status-308-fresh": true, + "status-308-stale": true, + "status-400-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-400-stale": true, + "status-404-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-499-stale": true, + "status-500-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-500-stale": true, + "status-502-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-502-stale": true, + "status-503-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-503-stale": true, + "status-504-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-504-stale": true, + "status-599-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": true, + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": true, + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} diff --git a/test/fixtures/cache-tests/results/trafficserver.json b/test/fixtures/cache-tests/results/trafficserver.json new file mode 100644 index 0000000..01a0da3 --- /dev/null +++ b/test/fixtures/cache-tests/results/trafficserver.json @@ -0,0 +1,678 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": true, + "304-etag-update-response-Content-Encoding": true, + "304-etag-update-response-Content-Foo": true, + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": true, + "304-etag-update-response-Content-MD5": true, + "304-etag-update-response-Content-Range": true, + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": [ + "Assertion", + "Response 2 header Content-Type is \"text/plain\", not \"text/plain;charset=utf-8\"" + ], + "304-etag-update-response-ETag": true, + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "304-etag-update-response-Set-Cookie2": true, + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": true, + "304-etag-update-response-X-Frame-Options": true, + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": true, + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": [ + "Assertion", + "Response 2 does not come from cache" + ], + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": true, + "age-parse-parameter": true, + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": true, + "age-parse-suffix-twoline": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "cc-resp-no-cache-revalidate-fresh": true, + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": true, + "ccreq-max-stale-age": true, + "ccreq-min-fresh": true, + "ccreq-min-fresh-age": true, + "ccreq-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache-etag": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-cache-lm": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": true, + "cdn-cc-invalid-sh-type-unknown": true, + "cdn-cc-invalid-sh-type-wrong": true, + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 01:02:07 GMT\"" + ], + "cdn-fresh-cc-nostore": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-0": true, + "cdn-max-age-0-expires": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-age": true, + "cdn-max-age-case-insensitive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-cc-max-age-invalid-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-extension": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-long-cc-max-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-private": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-remove-age-exceed": true, + "cdn-remove-header": true, + "conditional-304-etag": true, + "conditional-etag-forward": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": true, + "conditional-etag-quoted-respond-unquoted": true, + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": true, + "conditional-etag-strong-respond-multiple-last": true, + "conditional-etag-strong-respond-multiple-second": true, + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-quoted": true, + "conditional-etag-unquoted-respond-unquoted": true, + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-weak-respond-lowercase": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-weak-respond-omit-slash": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh": true, + "conditional-lm-fresh-earlier": true, + "conditional-lm-fresh-no-lm": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh-rfc850": true, + "conditional-lm-stale": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": true, + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": true, + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-2-digit-year": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-aest": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-multiple-spaces": true, + "freshness-expires-invalid-no-comma": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-tz": true, + "freshness-expires-wrong-case-weekday": true, + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": true, + "freshness-max-age-a100": true, + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": true, + "freshness-max-age-decimal-five": true, + "freshness-max-age-decimal-zero": true, + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": true, + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-fresh-stale-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sameline": true, + "freshness-max-age-two-stale-fresh-sepline": true, + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "Assertion", + "Response 3 does not come from cache" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-410-update": [ + "Setup", + "Response 3 does not come from cache" + ], + "head-writethrough": true, + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Connection": [ + "Assertion", + "Response 2 includes unexpected header a: \"1\"" + ], + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": true, + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Assertion", + "Response 2 header Set-Cookie is \"null\", not \"a=c\"" + ], + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": [ + "Setup", + "Response 1 status is 400, not 200" + ], + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": true, + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": true, + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": true, + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1800": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-3600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-43200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-86400": [ + "Assertion", + "Response 2 does not come from cache" + ], + "invalidate-DELETE": [ + "Setup", + "Response 2 status is 403, not 200" + ], + "invalidate-DELETE-cl": [ + "Setup", + "Response 2 status is 403, not 200" + ], + "invalidate-DELETE-failed": [ + "Setup", + "Response 2 status is 403, not 500" + ], + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": true, + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": true, + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": true, + "other-age-gen": true, + "other-age-update-expires": true, + "other-age-update-max-age": true, + "other-authorization": [ + "Assertion", + "Response 2 comes from cache" + ], + "other-authorization-must-revalidate": true, + "other-authorization-public": true, + "other-authorization-smaxage": true, + "other-cookie": true, + "other-date-update": true, + "other-date-update-expires": true, + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-set-cookie": true, + "partial-store-complete-reuse-partial": true, + "partial-store-complete-reuse-partial-no-last": true, + "partial-store-complete-reuse-partial-suffix": true, + "partial-store-partial-complete": [ + "Assertion", + "Request 2 header range is \"undefined\", not \"bytes=5-\"" + ], + "partial-store-partial-reuse-partial": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-absent": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-use-headers": true, + "partial-use-stored-headers": true, + "pragma-request-extension": true, + "pragma-request-no-cache": true, + "pragma-response-extension": true, + "pragma-response-no-cache": [ + "Assertion", + "Response 2 does not come from cache" + ], + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": true, + "stale-close": true, + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": true, + "stale-sie-close": true, + "stale-warning-become": true, + "stale-warning-stored": true, + "stale-while-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-while-revalidate-window": [ + "Setup", + "Response 2 does not come from cache" + ], + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": true, + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": true, + "status-302-stale": true, + "status-303-fresh": true, + "status-303-stale": true, + "status-307-fresh": true, + "status-307-stale": true, + "status-308-fresh": true, + "status-308-stale": true, + "status-400-fresh": true, + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": true, + "status-499-stale": true, + "status-500-fresh": true, + "status-500-stale": true, + "status-502-fresh": true, + "status-502-stale": true, + "status-503-fresh": true, + "status-503-stale": true, + "status-504-fresh": true, + "status-504-stale": true, + "status-599-fresh": true, + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": true, + "vary-normalise-lang-case": true, + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": true, + "vary-normalise-space": true, + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": true, + "vary-syntax-empty-star-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-foo-star": true, + "vary-syntax-star": true, + "vary-syntax-star-foo": true, + "vary-syntax-star-star": true, + "vary-syntax-star-star-lines": true +} diff --git a/test/fixtures/cache-tests/results/varnish.json b/test/fixtures/cache-tests/results/varnish.json new file mode 100644 index 0000000..1b10d18 --- /dev/null +++ b/test/fixtures/cache-tests/results/varnish.json @@ -0,0 +1,804 @@ +{ + "304-etag-update-response-Cache-Control": true, + "304-etag-update-response-Clear-Site-Data": true, + "304-etag-update-response-Content-Encoding": [ + "Assertion", + "Response 2 header Content-Encoding is \"arizqhypgxofwne\", not \"askcumewogyqias\"" + ], + "304-etag-update-response-Content-Foo": true, + "304-etag-update-response-Content-Length": true, + "304-etag-update-response-Content-Location": true, + "304-etag-update-response-Content-MD5": true, + "304-etag-update-response-Content-Range": [ + "Setup", + "Response 1 status is 503, not 200" + ], + "304-etag-update-response-Content-Security-Policy": true, + "304-etag-update-response-Content-Type": true, + "304-etag-update-response-ETag": true, + "304-etag-update-response-Expires": true, + "304-etag-update-response-Public-Key-Pins": true, + "304-etag-update-response-Set-Cookie": [ + "Setup", + "Request 2 should have been conditional, but it was not." + ], + "304-etag-update-response-Set-Cookie2": true, + "304-etag-update-response-Test-Header": true, + "304-etag-update-response-X-Content-Foo": true, + "304-etag-update-response-X-Frame-Options": true, + "304-etag-update-response-X-Test-Header": true, + "304-etag-update-response-X-XSS-Protection": true, + "304-lm-use-stored-Test-Header": true, + "age-parse-dup-0": true, + "age-parse-dup-0-twoline": true, + "age-parse-dup-old": true, + "age-parse-float": [ + "Assertion", + "Response 2 does not come from cache" + ], + "age-parse-large": true, + "age-parse-large-minus-one": true, + "age-parse-larger": true, + "age-parse-negative": true, + "age-parse-nonnumeric": true, + "age-parse-numeric-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-parameter": [ + "Assertion", + "Response 2 comes from cache" + ], + "age-parse-prefix": true, + "age-parse-prefix-twoline": true, + "age-parse-suffix": true, + "age-parse-suffix-twoline": true, + "cc-resp-must-revalidate-fresh": true, + "cc-resp-must-revalidate-stale": true, + "cc-resp-no-cache": true, + "cc-resp-no-cache-case-insensitive": true, + "cc-resp-no-cache-revalidate": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "cc-resp-no-cache-revalidate-fresh": [ + "Assertion", + "Request 2 should have been conditional, but it was not." + ], + "cc-resp-no-store": true, + "cc-resp-no-store-case-insensitive": true, + "cc-resp-no-store-fresh": true, + "cc-resp-no-store-old-max-age": true, + "cc-resp-no-store-old-new": true, + "cc-resp-private-shared": true, + "ccreq-ma0": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-ma1": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-magreaterage": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-max-stale": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-max-stale-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "ccreq-min-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-min-fresh-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-no-cache-etag": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-cache-lm": [ + "Assertion", + "request 2 wasn't sent to server" + ], + "ccreq-no-store": [ + "Assertion", + "Response 2 comes from cache" + ], + "ccreq-oic": [ + "Assertion", + "Response 1 status is 200, not 504" + ], + "cdn-cc-invalid-sh-type-unknown": true, + "cdn-cc-invalid-sh-type-wrong": true, + "cdn-date-update-exceed": true, + "cdn-expires-update-exceed": [ + "Assertion", + "Response 2 header Expires is \"null\", not \"Tue, 09 Jul 2024 01:02:40 GMT\"" + ], + "cdn-fresh-cc-nostore": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-0": true, + "cdn-max-age-0-expires": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-age": true, + "cdn-max-age-case-insensitive": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-cc-max-age-invalid-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-expires": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-extension": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-long-cc-max-age": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-max-age-max": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-max-plus": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-short-cc-max-age": [ + "Assertion", + "Response 2 does not come from cache" + ], + "cdn-max-age-space-after-equals": true, + "cdn-max-age-space-before-equals": true, + "cdn-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-no-store-cc-fresh": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-private": [ + "Assertion", + "Response 2 comes from cache" + ], + "cdn-remove-age-exceed": true, + "cdn-remove-header": true, + "conditional-304-etag": true, + "conditional-etag-forward": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-forward-unquoted": [ + "Assertion", + "Request 1 header If-None-Match is \"undefined\", not \"\"abcdef\"\"" + ], + "conditional-etag-precedence": true, + "conditional-etag-quoted-respond-unquoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-generate": true, + "conditional-etag-strong-generate-unquoted": [ + "Assertion", + "Request 2 header If-None-Match is \"abcdef\", not \"\"abcdef\"\"" + ], + "conditional-etag-strong-respond": true, + "conditional-etag-strong-respond-multiple-first": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-respond-multiple-last": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-respond-multiple-second": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-strong-respond-obs-text": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-quoted": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-etag-unquoted-respond-unquoted": true, + "conditional-etag-vary-headers": true, + "conditional-etag-vary-headers-mismatch": true, + "conditional-etag-weak-generate-weak": true, + "conditional-etag-weak-respond": true, + "conditional-etag-weak-respond-backslash": true, + "conditional-etag-weak-respond-lowercase": true, + "conditional-etag-weak-respond-omit-slash": true, + "conditional-lm-fresh": true, + "conditional-lm-fresh-earlier": true, + "conditional-lm-fresh-no-lm": [ + "Assertion", + "Response 2 status is 200, not 304" + ], + "conditional-lm-fresh-rfc850": true, + "conditional-lm-stale": true, + "freshness-expires-32bit": true, + "freshness-expires-age-fast-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-expires-age-slow-date": true, + "freshness-expires-ansi-c": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-far-future": true, + "freshness-expires-future": true, + "freshness-expires-invalid": true, + "freshness-expires-invalid-1-digit-hour": true, + "freshness-expires-invalid-2-digit-year": true, + "freshness-expires-invalid-aest": true, + "freshness-expires-invalid-date": true, + "freshness-expires-invalid-date-dashes": true, + "freshness-expires-invalid-multiple-lines": true, + "freshness-expires-invalid-multiple-spaces": true, + "freshness-expires-invalid-no-comma": true, + "freshness-expires-invalid-time-periods": true, + "freshness-expires-invalid-utc": true, + "freshness-expires-old-date": true, + "freshness-expires-past": true, + "freshness-expires-present": true, + "freshness-expires-rfc850": true, + "freshness-expires-wrong-case-month": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-tz": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-expires-wrong-case-weekday": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age": true, + "freshness-max-age-0": true, + "freshness-max-age-0-expires": true, + "freshness-max-age-100a": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-a100": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-age": true, + "freshness-max-age-case-insenstive": true, + "freshness-max-age-date": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-decimal-five": true, + "freshness-max-age-decimal-zero": true, + "freshness-max-age-expires": true, + "freshness-max-age-expires-invalid": true, + "freshness-max-age-extension": true, + "freshness-max-age-ignore-quoted": true, + "freshness-max-age-ignore-quoted-rev": true, + "freshness-max-age-leading-zero": true, + "freshness-max-age-max": true, + "freshness-max-age-max-minus-1": true, + "freshness-max-age-max-plus": true, + "freshness-max-age-max-plus-1": true, + "freshness-max-age-negative": true, + "freshness-max-age-quoted": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-s-maxage-shared-longer": true, + "freshness-max-age-s-maxage-shared-longer-multiple": true, + "freshness-max-age-s-maxage-shared-longer-reversed": true, + "freshness-max-age-s-maxage-shared-shorter": true, + "freshness-max-age-s-maxage-shared-shorter-expires": true, + "freshness-max-age-single-quoted": true, + "freshness-max-age-space-after-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-space-before-equals": [ + "Assertion", + "Response 2 comes from cache" + ], + "freshness-max-age-stale": true, + "freshness-max-age-two-fresh-stale-sameline": true, + "freshness-max-age-two-fresh-stale-sepline": true, + "freshness-max-age-two-stale-fresh-sameline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-max-age-two-stale-fresh-sepline": [ + "Assertion", + "Response 2 does not come from cache" + ], + "freshness-none": true, + "freshness-s-maxage-shared": true, + "head-200-freshness-update": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "head-200-retain": [ + "Assertion", + "Response 2 header Template-A is \"null\", not \"1\"" + ], + "head-200-update": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "head-410-update": [ + "Setup", + "Response 3 status is 410, not 200" + ], + "head-writethrough": [ + "Assertion", + "Request 2 had method GET, not HEAD" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Cache-Control-no-cache-single": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-omit-headers-listed-in-Connection": true, + "headers-store-Cache-Control": true, + "headers-store-Clear-Site-Data": true, + "headers-store-Connection": true, + "headers-store-Content-Encoding": true, + "headers-store-Content-Foo": true, + "headers-store-Content-Length": true, + "headers-store-Content-Location": true, + "headers-store-Content-MD5": true, + "headers-store-Content-Range": [ + "Setup", + "Response 1 status is 503, not 200" + ], + "headers-store-Content-Security-Policy": true, + "headers-store-Content-Type": true, + "headers-store-ETag": true, + "headers-store-Expires": true, + "headers-store-Keep-Alive": true, + "headers-store-Proxy-Authenticate": true, + "headers-store-Proxy-Authentication-Info": true, + "headers-store-Proxy-Authorization": true, + "headers-store-Proxy-Connection": true, + "headers-store-Public-Key-Pins": true, + "headers-store-Set-Cookie": [ + "Setup", + "Response 2 does not come from cache" + ], + "headers-store-Set-Cookie2": true, + "headers-store-TE": true, + "headers-store-Test-Header": true, + "headers-store-Transfer-Encoding": [ + "Setup", + "Response 1 status is 503, not 200" + ], + "headers-store-Upgrade": true, + "headers-store-X-Content-Foo": true, + "headers-store-X-Frame-Options": true, + "headers-store-X-Test-Header": true, + "headers-store-X-XSS-Protection": true, + "heuristic-200-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-201-not_cached": true, + "heuristic-202-not_cached": true, + "heuristic-203-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-204-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-403-not_cached": true, + "heuristic-404-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-405-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-410-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-414-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-501-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-502-not_cached": true, + "heuristic-503-not_cached": true, + "heuristic-504-not_cached": true, + "heuristic-599-cached": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-599-not_cached": true, + "heuristic-delta-10": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-1800": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-30": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-300": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-3600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-43200": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-5": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-60": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-600": [ + "Assertion", + "Response 2 does not come from cache" + ], + "heuristic-delta-86400": [ + "Assertion", + "Response 2 does not come from cache" + ], + "invalidate-DELETE": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-DELETE-failed": true, + "invalidate-DELETE-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-M-SEARCH-failed": true, + "invalidate-M-SEARCH-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-POST-failed": true, + "invalidate-POST-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-cl": [ + "Assertion", + "Response 3 comes from cache" + ], + "invalidate-PUT-failed": true, + "invalidate-PUT-location": [ + "Assertion", + "Response 3 comes from cache" + ], + "method-POST": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-age-delay": [ + "Assertion", + "Response 1 header age is 0, should be bigger than 0" + ], + "other-age-gen": true, + "other-age-update-expires": true, + "other-age-update-max-age": true, + "other-authorization": true, + "other-authorization-must-revalidate": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-authorization-public": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-authorization-smaxage": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-cookie": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-date-update": true, + "other-date-update-expires": true, + "other-date-update-expires-update": true, + "other-fresh-content-disposition-attachment": true, + "other-heuristic-content-disposition-attachment": [ + "Assertion", + "Response 2 does not come from cache" + ], + "other-set-cookie": [ + "Assertion", + "Response 2 does not come from cache" + ], + "partial-store-complete-reuse-partial": true, + "partial-store-complete-reuse-partial-no-last": true, + "partial-store-complete-reuse-partial-suffix": true, + "partial-store-partial-complete": [ + "Setup", + "Response 1 status is 503, not 206" + ], + "partial-store-partial-reuse-partial": [ + "Setup", + "Response 1 status is 503, not 206" + ], + "partial-store-partial-reuse-partial-absent": [ + "Setup", + "Response 1 status is 503, not 206" + ], + "partial-store-partial-reuse-partial-byterange": [ + "Setup", + "Response 1 status is 503, not 206" + ], + "partial-store-partial-reuse-partial-suffix": [ + "Setup", + "Response 1 status is 503, not 206" + ], + "partial-use-headers": true, + "partial-use-stored-headers": true, + "pragma-request-extension": true, + "pragma-request-no-cache": true, + "pragma-response-extension": true, + "pragma-response-no-cache": true, + "pragma-response-no-cache-heuristic": [ + "Assertion", + "Response 2 does not come from cache" + ], + "query-args-different": true, + "query-args-same": true, + "stale-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-close-must-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-no-cache": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-proxy-revalidate": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-close-s-maxage=2": [ + "Assertion", + "Response 2 comes from cache" + ], + "stale-sie-503": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-sie-close": [ + "Assertion", + "Response 2 does not come from cache" + ], + "stale-warning-become": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-warning-stored": [ + "Setup", + "Response 2 does not come from cache" + ], + "stale-while-revalidate": true, + "stale-while-revalidate-window": true, + "status-200-fresh": true, + "status-200-must-understand": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-200-stale": true, + "status-203-fresh": true, + "status-203-stale": true, + "status-204-fresh": true, + "status-204-stale": true, + "status-299-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-299-stale": true, + "status-301-fresh": true, + "status-301-stale": true, + "status-302-fresh": true, + "status-302-stale": true, + "status-303-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-303-stale": true, + "status-307-fresh": true, + "status-307-stale": true, + "status-308-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-308-stale": true, + "status-400-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-400-stale": true, + "status-404-fresh": true, + "status-404-stale": true, + "status-410-fresh": true, + "status-410-stale": true, + "status-499-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-499-stale": true, + "status-500-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-500-stale": true, + "status-502-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-502-stale": true, + "status-503-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-503-stale": true, + "status-504-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-504-stale": true, + "status-599-fresh": [ + "Assertion", + "Response 2 does not come from cache" + ], + "status-599-must-understand": true, + "status-599-stale": true, + "vary-2-match": true, + "vary-2-match-omit": true, + "vary-2-no-match": true, + "vary-3-match": true, + "vary-3-no-match": true, + "vary-3-omit": true, + "vary-3-order": true, + "vary-cache-key": true, + "vary-invalidate": true, + "vary-match": true, + "vary-no-match": true, + "vary-normalise-combine": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-case": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-order": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-select": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-lang-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-normalise-space": [ + "Assertion", + "Response 2 does not come from cache" + ], + "vary-omit": true, + "vary-omit-stored": true, + "vary-star": true, + "vary-syntax-empty-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-empty-star-lines": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-foo-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star": true, + "vary-syntax-star-foo": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-star": [ + "Assertion", + "Response 2 comes from cache" + ], + "vary-syntax-star-star-lines": [ + "Assertion", + "Response 2 comes from cache" + ] +} diff --git a/test/fixtures/cache-tests/test-engine/cli.mjs b/test/fixtures/cache-tests/test-engine/cli.mjs new file mode 100644 index 0000000..241ea37 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/cli.mjs @@ -0,0 +1,44 @@ +import { runTests, getResults } from './client/runner.mjs' +import { determineTestResult } from './lib/results.mjs' +import { GREEN, NC } from './lib/defines.mjs' +import fetch from 'node-fetch-with-proxy' +import tests from '../tests/index.mjs' + +const baseUrl = process.env.npm_config_base || process.env.npm_package_config_base +const testId = process.env.npm_config_id || process.env.npm_package_config_id + +let testsToRun +if (testId !== '') { + console.log(`Running ${testId}`) + tests.forEach(suite => { + suite.tests.forEach(test => { + if (test.id === testId) { + test.dump = true + testsToRun = [{ + name: suite.name, + id: suite.id, + description: suite.description, + tests: [test] + }] + } + }) + }) +} else { + testsToRun = tests +} + +await runTests(testsToRun, fetch, false, baseUrl).catch(err => { + console.error(err) + process.exit(1) +}) + +const results = getResults() + +if (testId !== '') { + console.log(`${GREEN}==== Results${NC}`) + const resultSymbol = determineTestResult(tests, testId, results, false) + const resultDetails = results[testId][1] || '' + console.log(`${resultSymbol[2]} - ${resultDetails}`) +} else { + console.log(JSON.stringify(results, null, 2)) +} diff --git a/test/fixtures/cache-tests/test-engine/client/config.mjs b/test/fixtures/cache-tests/test-engine/client/config.mjs new file mode 100644 index 0000000..147bdce --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/client/config.mjs @@ -0,0 +1,22 @@ +export let fetch = null +export let useBrowserCache = false +export let baseUrl = '' +export const requestTimeout = 10 // seconds + +export function setFetch (call) { + if (call !== undefined) { + if ('bind' in call) { + fetch = call.bind(fetch) + } else { + fetch = call + } + } +} + +export function setUseBrowserCache (bool) { + if (bool !== undefined) useBrowserCache = bool +} + +export function setBaseUrl (url) { + if (url !== undefined) baseUrl = url +} diff --git a/test/fixtures/cache-tests/test-engine/client/fetching.mjs b/test/fixtures/cache-tests/test-engine/client/fetching.mjs new file mode 100644 index 0000000..f27a763 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/client/fetching.mjs @@ -0,0 +1,45 @@ +import * as config from './config.mjs' +import { fixupHeader } from '../lib/header-fixup.mjs' + +export function init (idx, reqConfig, prevResp) { + const init = { + headers: [] + } + if (!config.useBrowserCache) { + init.cache = 'no-store' + init.headers.push(['Pragma', 'foo']) // dirty hack for Fetch + init.headers.push(['Cache-Control', 'nothing-to-see-here']) // ditto + } + if ('request_method' in reqConfig) init.method = reqConfig.request_method + if ('request_headers' in reqConfig) init.headers = reqConfig.request_headers + if ('magic_ims' in reqConfig && reqConfig.magic_ims === true) { + for (let i = 0; i < init.headers.length; i++) { + const header = init.headers[i] + if (header[0].toLowerCase() === 'if-modified-since') { + init.headers[i] = fixupHeader(header, prevResp, reqConfig) + } + } + } + if ('name' in reqConfig) init.headers.push(['Test-Name', reqConfig.name]) + if ('request_body' in reqConfig) init.body = reqConfig.request_body + if ('mode' in reqConfig) init.mode = reqConfig.mode + if ('credentials' in reqConfig) init.mode = reqConfig.credentials + if ('cache' in reqConfig) init.cache = reqConfig.cache + if ('redirect' in reqConfig) init.redirect = reqConfig.redirect + init.headers.push(['Test-ID', reqConfig.id]) + init.headers.push(['Req-Num', (idx + 1).toString()]) + return init +} + +export function inflateRequests (test) { + const rawRequests = test.requests + const requests = [] + for (let i = 0; i < rawRequests.length; i++) { + const reqConfig = rawRequests[i] + reqConfig.name = test.name + reqConfig.id = test.id + reqConfig.dump = test.dump + requests.push(reqConfig) + } + return requests +} diff --git a/test/fixtures/cache-tests/test-engine/client/runner.mjs b/test/fixtures/cache-tests/test-engine/client/runner.mjs new file mode 100644 index 0000000..dd3bb6d --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/client/runner.mjs @@ -0,0 +1,41 @@ +import * as config from './config.mjs' +import { makeTest, testResults } from './test.mjs' + +export async function runTests (tests, myFetch, browserCache, base, chunkSize = 50) { + config.setFetch(myFetch) + config.setBaseUrl(base) + config.setUseBrowserCache(browserCache) + + const testArray = [] + tests.forEach(testSet => { + testSet.tests.forEach(test => { + if (test.id === undefined) throw new Error('Missing test id') + if (test.browser_only === true && !config.useBrowserCache === true) return + if (test.cdn_only === true && config.useBrowserCache === true) return + // note: still runs cdn tests on rev-proxy + if (test.browser_skip === true && config.useBrowserCache === true) return + testArray.push(test) + }) + }) + return runSome(testArray, chunkSize) +} + +export function getResults () { + const ordered = {} + Object.keys(testResults).sort().forEach(key => { + ordered[key] = testResults[key] + }) + return ordered +} + +async function runSome (tests, chunkSize) { + let index = 0 + function next () { + if (index < tests.length) { + const these = tests.slice(index, index + chunkSize).map(makeTest) + index += chunkSize + return Promise.all(these).then(next) + } + } + return next() +} diff --git a/test/fixtures/cache-tests/test-engine/client/test.mjs b/test/fixtures/cache-tests/test-engine/client/test.mjs new file mode 100644 index 0000000..078fdd4 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/client/test.mjs @@ -0,0 +1,299 @@ +import * as defines from '../lib/defines.mjs' +import { fixupHeader } from '../lib/header-fixup.mjs' +import * as utils from '../lib/utils.mjs' +import * as config from './config.mjs' +import * as clientUtils from './utils.mjs' +import * as fetching from './fetching.mjs' +const assert = utils.assert +const setupCheck = clientUtils.setupCheck + +export const testUUIDs = {} +export const testResults = {} + +export async function makeTest (test) { + const uuid = utils.token() + testUUIDs[test.id] = uuid + const requests = fetching.inflateRequests(test) + const responses = [] + const fetchFunctions = [] + for (let i = 0; i < requests.length; ++i) { + fetchFunctions.push({ + code: idx => { + const reqConfig = requests[idx] + const reqNum = idx + 1 + const url = clientUtils.makeTestUrl(uuid, reqConfig) + let prevRes + if (i > 0) { + prevRes = Object.fromEntries(responses[i - 1].headers) + } + const init = fetching.init(idx, reqConfig, prevRes) + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, config.requestTimeout * 1000) + init.signal = controller.signal + if (test.dump === true) clientUtils.logRequest(url, init, reqNum) + return config.fetch(url, init) + .then(response => { + responses.push(response) + return checkResponse(test, requests, idx, response) + }) + .finally(() => { + clearTimeout(timeout) + }) + }, + pauseAfter: 'pause_after' in requests[i] + }) + } + + let idx = 0 + function runNextStep () { + if (fetchFunctions.length) { + const nextFetchFunction = fetchFunctions.shift() + if (nextFetchFunction.pauseAfter === true) { + return nextFetchFunction.code(idx++) + .then(clientUtils.pause) + .then(runNextStep) + } else { + return nextFetchFunction.code(idx++) + .then(runNextStep) + } + } + } + + return clientUtils.putTestConfig(uuid, requests) + .catch(handleError) + .then(runNextStep) + .then(() => { + return clientUtils.getServerState(uuid) + }) + .then(serverState => { + checkServerRequests(requests, responses, serverState) + }) + .then(() => { // pass + if (test.id in testResults) throw new Error(`Duplicate test ${test.id}`) + testResults[test.id] = true + }) + .catch(err => { // fail + if (test.id in testResults) throw new Error(`Duplicate test ${test.id}`) + testResults[test.id] = [(err.name || 'unknown'), err.message] + }) +} + +function checkResponse (test, requests, idx, response) { + const reqNum = idx + 1 + const reqConfig = requests[idx] + const resNum = parseInt(response.headers.get('Server-Request-Count')) + if (test.dump === true) clientUtils.logResponse(response, reqNum) + + // catch retries + if (response.headers.has('Request-Numbers')) { + const serverRequests = response.headers.get('Request-Numbers').split(' ').map(item => parseInt(item)) + if (serverRequests.length !== new Set(serverRequests).size) { + assert(true, false, 'retry') + } + } + + // check response type + if ('expected_type' in reqConfig) { + const typeSetup = setupCheck(reqConfig, 'expected_type') + if (reqConfig.expected_type === 'cached') { + if (response.status === 304 && isNaN(resNum)) { // some caches will not include the hdr + // pass + } else { + assert(typeSetup, resNum < reqNum, `Response ${reqNum} does not come from cache`) + } + } + if (reqConfig.expected_type === 'not_cached') { + assert(typeSetup, resNum === reqNum, `Response ${reqNum} comes from cache`) + } + } + + // check response status + if ('expected_status' in reqConfig) { + assert(setupCheck(reqConfig, 'expected_status'), + response.status === reqConfig.expected_status, + `Response ${reqNum} status is ${response.status}, not ${reqConfig.expected_status}`) + } else if ('response_status' in reqConfig) { + assert(true, // response status is always setup + response.status === reqConfig.response_status[0], + `Response ${reqNum} status is ${response.status}, not ${reqConfig.response_status[0]}`) + } else if (response.status === 999) { + // special condition; the server thought it should have received a conditional request. + assert(setupCheck(reqConfig, 'expected_type'), false, + `Request ${reqNum} should have been conditional, but it was not.`) + } else { + assert(true, // default status is always setup + response.status === 200, + `Response ${reqNum} status is ${response.status}, not 200`) + } + + // check response headers + if ('expected_response_headers' in reqConfig) { + const respPresentSetup = setupCheck(reqConfig, 'expected_response_headers') + reqConfig.expected_response_headers.forEach(header => { + if (typeof header === 'string') { + assert(respPresentSetup, response.headers.has(header), + `Response ${reqNum} ${header} header not present.`) + } else if (header.length > 2) { + assert(respPresentSetup, response.headers.has(header[0]), + `Response ${reqNum} ${header[0]} header not present.`) + + const value = response.headers.get(header[0]) + let msg, condition + if (header[1] === '=') { + const expected = response.headers.get(header[2]) + condition = value === expected + msg = `match ${header[2]} (${expected})` + } else if (header[1] === '>') { + const expected = header[2] + condition = parseInt(value) > expected + msg = `be bigger than ${expected}` + } else { + throw new Error(`Unknown expected-header operator '${header[1]}'`) + } + + assert(respPresentSetup, condition, + `Response ${reqNum} header ${header[0]} is ${value}, should ${msg}`) + } else { + const expectedValue = fixupHeader( + header, Object.fromEntries(response.headers), reqConfig)[1] + assert(respPresentSetup, response.headers.get(header[0]) === expectedValue, + `Response ${reqNum} header ${header[0]} is "${response.headers.get(header[0])}", not "${expectedValue}"`) + } + }) + } + if ('expected_response_headers_missing' in reqConfig) { + const respMissingSetup = setupCheck(reqConfig, 'expected_response_headers_missing') + reqConfig.expected_response_headers_missing.forEach(header => { + if (typeof header === 'string') { + assert(respMissingSetup, !response.headers.has(header), + `Response ${reqNum} includes unexpected header ${header}: "${response.headers.get(header)}"`) + } else if (header.length === 2) { + if (response.headers.has(header[0]) && response.headers[header[0]]) { + const hdrValue = response.headers[header[0]] + assert(respMissingSetup, hdrValue.indexOf(header[1]) === -1, `Response ${reqNum} header ${header[0]} still has value "${hdrValue}"`) + } + } else { + throw new Error(`Unknown unexpected-header form '${header}'`) + } + }) + } + return response.text().then(makeCheckResponseBody(test, reqConfig, response.status)) +} + +function makeCheckResponseBody (test, reqConfig, statusCode) { + return function checkResponseBody (resBody) { + if ('check_body' in reqConfig && reqConfig.check_body === false) { + return true + } else if ('expected_response_text' in reqConfig) { + if (reqConfig.expected_response_text !== null) { + assert(setupCheck(reqConfig, 'expected_response_text'), + resBody === reqConfig.expected_response_text, + `Response body is "${resBody}", not "${reqConfig.expected_response_text}"`) + } + } else if ('response_body' in reqConfig && reqConfig.response_body !== null) { + assert(true, // response_body is always setup + resBody === reqConfig.response_body, + `Response body is "${resBody}", not "${reqConfig.response_body}"`) + } else if (!defines.noBodyStatus.has(statusCode) && reqConfig.request_method !== 'HEAD') { + const uuid = testUUIDs[test.id] + assert(true, // no_body is always setup + resBody === uuid, + `Response body is "${resBody}", not "${uuid}"`) + } + } +} + +function checkServerRequests (requests, responses, serverState) { + // compare a test's requests array against the server-side serverState + let testIdx = 0 + for (let i = 0; i < requests.length; ++i) { + const expectedValidatingHeaders = [] + const reqConfig = requests[i] + const response = responses[i] + const serverRequest = serverState[testIdx] + const reqNum = i + 1 + const typeSetup = setupCheck(reqConfig, 'expected_type') + if ('expected_type' in reqConfig) { + if (reqConfig.expected_type === 'cached') continue // the server will not see the request + if (reqConfig.expected_type === 'not_cached') { + assert(typeSetup, serverRequest.request_num === reqNum, `Response ${reqNum} comes from cache (${serverRequest.request_num} on server)`) + } + if (reqConfig.expected_type === 'etag_validated') { + expectedValidatingHeaders.push('if-none-match') + } + if (reqConfig.expected_type === 'lm_validated') { + expectedValidatingHeaders.push('if-modified-since') + } + } + testIdx++ // only increment for requests the server sees + expectedValidatingHeaders.forEach(vhdr => { + assert(typeSetup, typeof (serverRequest) !== 'undefined', `request ${reqNum} wasn't sent to server`) + assert(typeSetup, Object.prototype.hasOwnProperty.call(serverRequest.request_headers, vhdr), + `request ${reqNum} doesn't have ${vhdr} header`) + }) + if ('expected_request_headers' in reqConfig) { + const reqPresentSetup = setupCheck(reqConfig, 'expected_request_headers') + reqConfig.expected_request_headers.forEach(header => { + if (typeof header === 'string') { + const headerName = header.toLowerCase() + assert(reqPresentSetup, Object.prototype.hasOwnProperty.call(serverRequest.request_headers, headerName), + `Request ${reqNum} ${header} header not present.`) + } else { + const reqValue = serverRequest.request_headers[header[0].toLowerCase()] + assert(reqPresentSetup, reqValue === header[1], + `Request ${reqNum} header ${header[0]} is "${reqValue}", not "${header[1]}"`) + } + }) + } + if ('expected_request_headers_missing' in reqConfig) { + const reqmPresentSetup = setupCheck(reqConfig, 'expected_request_headers_missing') + reqConfig.expected_request_headers_missing.forEach(header => { + if (typeof header === 'string') { + const headerName = header.toLowerCase() + assert(reqmPresentSetup, !Object.prototype.hasOwnProperty.call(serverRequest.request_headers, headerName), + `Request ${reqNum} ${header} header present.`) + } else { + const reqValue = serverRequest.request_headers[header[0].toLowerCase()] + assert(reqmPresentSetup, reqValue !== header[1], + `Request ${reqNum} header ${header[0]} is "${reqValue}"`) + } + }) + } + if (typeof serverRequest !== 'undefined' && 'response_headers' in serverRequest) { + serverRequest.response_headers.forEach(header => { + if (config.useBrowserCache && defines.forbiddenResponseHeaders.has(header[0].toLowerCase())) { + // browsers prevent reading these headers through the Fetch API so we can't verify them + return + } + if (defines.skipResponseHeaders.has(header[0].toLowerCase())) { + // these just cause spurious failures + return + } + let received = response.headers.get(header[0]) + // XXX: assumes that if a proxy joins headers, it'll separate them with a comma and exactly one space + if (Array.isArray(received)) { + received = received.join(', ') + } + if (Array.isArray(header[1])) { + header[1] = header[1].join(', ') + } + assert(true, // default headers is always setup + received === header[1], + `Response ${reqNum} header ${header[0]} is "${received}", not "${header[1]}"`) + }) + } + if ('expected_method' in reqConfig) { + assert( + setupCheck(reqConfig, 'expected_method'), + serverRequest.request_method === reqConfig.expected_method, + `Request ${reqNum} had method ${serverRequest.request_method}, not ${reqConfig.expected_method}` + ) + } + } +} + +function handleError (err) { + console.error(`ERROR: ${err}`) +} diff --git a/test/fixtures/cache-tests/test-engine/client/utils.mjs b/test/fixtures/cache-tests/test-engine/client/utils.mjs new file mode 100644 index 0000000..b1ac45c --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/client/utils.mjs @@ -0,0 +1,82 @@ +import * as config from './config.mjs' +import * as utils from '../lib/utils.mjs' +import * as defines from '../lib/defines.mjs' + +export function pause () { + return new Promise(function (resolve, reject) { + setTimeout(() => { + return resolve() + }, 3000) + }) +} + +export function makeTestUrl (uuid, reqConfig) { + let extra = '' + if ('filename' in reqConfig) { + extra += `/${reqConfig.filename}` + } + if ('query_arg' in reqConfig) { + extra += `?${reqConfig.query_arg}` + } + return `${config.baseUrl}/test/${uuid}${extra}` +} + +const uninterestingHeaders = new Set(['date', 'expires', 'last-modified', 'content-length', 'content-type', 'connection', 'content-language', 'vary', 'mime-version']) + +export async function putTestConfig (uuid, requests) { + const init = { + method: 'PUT', + headers: [['content-type', 'application/json']], + body: JSON.stringify(requests) + } + return config.fetch(`${config.baseUrl}/config/${uuid}`, init) + .then(response => { + if (response.status !== 201) { + let headers = '' + response.headers.forEach((hvalue, hname) => { // for some reason, node-fetch reverses these + if (!uninterestingHeaders.has(hname.toLowerCase())) { + headers += `${hname}: ${hvalue} ` + } + }) + throw new utils.SetupError({ message: `PUT config resulted in ${response.status} ${response.statusText} - ${headers}` }) + } + }) +} + +export async function getServerState (uuid) { + return config.fetch(`${config.baseUrl}/state/${uuid}`) + .then(response => { + if (response.status === 200) { + return response.text() + } + }).then(text => { + if (text === undefined) return [] + return JSON.parse(text) + }) +} + +export function setupCheck (reqConfig, memberName) { + return reqConfig.setup === true || ('setup_tests' in reqConfig && reqConfig.setup_tests.indexOf(memberName) > -1) +} + +export function logRequest (url, init, reqNum) { + console.log(`${defines.GREEN}=== Client request ${reqNum}${defines.NC}`) + if ('method' in init) { + console.log(` ${init.method} ${url}`) + } else { + console.log(` GET ${url}`) + } + init.headers.forEach(header => { + console.log(` ${header[0]}: ${header[1]}`) + }) + console.log('') +} + +export function logResponse (response, reqNum) { + console.log(`${defines.GREEN}=== Client response ${reqNum}${defines.NC}`) + console.log(` HTTP ${response.status} ${response.statusText}`) + response.headers.forEach((hvalue, hname) => { // for some reason, node-fetch reverses these + console.log(` ${hname}: ${hvalue}`) + }) + console.log('') +} diff --git a/test/fixtures/cache-tests/test-engine/export.mjs b/test/fixtures/cache-tests/test-engine/export.mjs new file mode 100644 index 0000000..27eec3a --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/export.mjs @@ -0,0 +1,18 @@ +import fs from 'fs' + +import Ajv from 'ajv' + +import tests from '../tests/index.mjs' + +if (process.argv[2] === 'validate') { + const ajv = new Ajv() + const schema = JSON.parse(fs.readFileSync('test-engine/lib/testsuite-schema.json', 'utf8')) + const validate = ajv.compile(schema) + const valid = validate(tests) + if (!valid) { + console.log(validate.errors) + process.exit(1) + } +} else { + console.log(JSON.stringify(tests, null, 2)) +} diff --git a/test/fixtures/cache-tests/test-engine/lib/defines.mjs b/test/fixtures/cache-tests/test-engine/lib/defines.mjs new file mode 100644 index 0000000..fae246b --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/defines.mjs @@ -0,0 +1,28 @@ +export const noBodyStatus = new Set([204, 304]) + +export const dateHeaders = new Set(['date', 'expires', 'last-modified', 'if-modified-since', 'if-unmodified-since']) + +export const locationHeaders = new Set(['location', 'content-location']) + +// https://fetch.spec.whatwg.org/#forbidden-response-header-name +export const forbiddenResponseHeaders = new Set(['set-cookie', 'set-cookie2']) + +// headers to skip when checking response_headers (not expected) +export const skipResponseHeaders = new Set(['date']) + +// colours for console +export const RED = '\x1b[31m' +export const GREEN = '\x1b[32m' +export const BLUE = '\x1b[34m' +export const NC = '\x1b[0m' + +// mime types for server +export const mimeTypes = { + html: 'text/html', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + js: 'application/javascript', + mjs: 'application/javascript', + css: 'text/css' +} diff --git a/test/fixtures/cache-tests/test-engine/lib/display.mjs b/test/fixtures/cache-tests/test-engine/lib/display.mjs new file mode 100644 index 0000000..75f14dd --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/display.mjs @@ -0,0 +1,153 @@ +/* global Blob marked */ + +import '../../asset/marked.min.js' +import { Liquid } from '../../asset/liquid.browser.esm.mjs' +import { modalOpen } from './modal.mjs' + +import { determineTestResult, resultTypes } from './results.mjs' + +const templateEngine = new Liquid({ root: 'test-engine/lib/tpl', extname: '.liquid', cache: true }) +templateEngine.registerFilter('typeof', v => typeof (v)) +templateEngine.registerFilter('toLocaleString', v => v.toLocaleString()) +templateEngine.registerFilter('skipHeaders', v => { + if (v) { + return v.filter(hdr => hdr.length < 3 || hdr[2] !== false) + } else { + return [] + } +}) + +export function downloadTestResults (target, fileName, data, auto) { + const dataBlob = new Blob([JSON.stringify(data, null, 2)], { type: 'text/json' }) + target.setAttribute('href', window.URL.createObjectURL(dataBlob)) + target.setAttribute('download', fileName) + target.style.display = 'inherit' + if (auto) { + target.click() + } +} + +export function renderTestResults (testSuites, testResults, testUUIDs, target, useBrowserCache) { + let totalTests = 0 + let totalPassed = 0 + testSuites.forEach(testSuite => { + const headerElement = document.createElement('h3') + target.appendChild(headerElement) + const headerText = document.createTextNode(testSuite.name) + headerElement.appendChild(headerText) + const listElement = document.createElement('ul') + const resultList = target.appendChild(listElement) + let tests = 0 + let passed = 0 + testSuite.tests.forEach(test => { + if (test.browser_only === true && !useBrowserCache === true) return + if (test.cdn_only === true && useBrowserCache === true) return + if (test.browser_skip === true && useBrowserCache === true) return + test.suiteName = testSuite.name + const testElement = resultList.appendChild(document.createElement('li')) + testElement.appendChild(showTestResult(testSuites, test.id, testResults)) + testElement.appendChild(showTestName(test, testUUIDs[test.id])) + tests++ + if (testResults[test.id] === true) { + passed++ + } + }) + const summaryElement = document.createElement('p') + const suiteSummary = target.appendChild(summaryElement) + suiteSummary.appendChild(document.createTextNode(tests + ' tests, ' + passed + ' passed.')) + totalTests += tests + totalPassed += passed + }) + const totalElement = document.createElement('p') + const totalSummary = target.appendChild(totalElement) + const totalText = document.createTextNode('Total ' + totalTests + ' tests, ' + totalPassed + ' passed.') + totalSummary.appendChild(totalText) +} + +export function showTestName (test, uuid) { + const wrapper = document.createElement('span') + const span = document.createElement('span') + span.setAttribute('class', 'clickhint') + span.innerHTML = marked.parse(test.name).slice(3, -5) + span.addEventListener('click', event => { + copyTextToClipboard(test.id) + showTestDetails(test) + }) + wrapper.appendChild(span) + + if (uuid) { + const uuidLinkElement = document.createElement('a') + uuidLinkElement.appendChild(document.createTextNode('⚙︎')) + uuidLinkElement.setAttribute('class', 'uuid') + uuidLinkElement.addEventListener('click', event => { + copyTextToClipboard(uuid) + }) + uuidLinkElement.title = 'Test UUID (click to copy)' + wrapper.appendChild(uuidLinkElement) + } + return wrapper +} + +export function showKey (element) { + const spans = element.getElementsByClassName('fa') + for (const span of spans) { + const kind = span.getAttribute('data-kind') + const styling = resultTypes[kind] + const contentNode = document.createTextNode(styling[0]) + span.style.color = styling[1] + span.appendChild(contentNode) + } +} + +export function showTestResult (testSuites, testId, testResults) { + const result = testResults[testId] + const resultValue = determineTestResult(testSuites, testId, testResults) + const resultNode = document.createTextNode(` ${resultValue[0]} `) + const span = document.createElement('span') + span.className = 'fa' + span.style.color = resultValue[1] + span.appendChild(resultNode) + if (result && typeof (result[1]) === 'string') { + span.title = result[1] + } + return span +} + +export function showTestDetails (test) { + templateEngine + .renderFile('explain-test', { test }) + .then(result => { + console.log(result) + const html = marked.parse(result) + modalOpen(html) + }) + .catch(err => { + console.log(`Template error: ${err}`) + }) +} + +function copyTextToClipboard (text) { + const textArea = document.createElement('textarea') + textArea.style.position = 'fixed' + textArea.style.top = 0 + textArea.style.left = 0 + textArea.style.width = '2em' + textArea.style.height = '2em' + textArea.style.padding = 0 + textArea.style.border = 'none' + textArea.style.outline = 'none' + textArea.style.boxShadow = 'none' + textArea.style.background = 'transparent' + textArea.value = text + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + try { + const successful = document.execCommand('copy') + const msg = successful ? 'successful' : 'unsuccessful' + console.log(`Copying text "${text}" was ${msg}`) + } catch (err) { + console.log('Unable to copy') + } + document.body.removeChild(textArea) +} diff --git a/test/fixtures/cache-tests/test-engine/lib/header-fixup.mjs b/test/fixtures/cache-tests/test-engine/lib/header-fixup.mjs new file mode 100644 index 0000000..570e041 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/header-fixup.mjs @@ -0,0 +1,28 @@ +import { locationHeaders, dateHeaders } from './defines.mjs' +import { httpDate } from './utils.mjs' + +export function fixupHeader (header, respHeaders, reqConfig) { + const headerName = header[0].toLowerCase() + + // Date headers + const serverNow = parseInt(respHeaders['server-now']) + if (dateHeaders.has(headerName) && Number.isInteger(header[1])) { + let format + if ('rfc850date' in reqConfig && reqConfig.rfc850date.includes(headerName)) { + format = 'rfc850' + } + header[1] = httpDate(serverNow, header[1], format) + } + + // Location headers + const baseUrl = respHeaders['server-base-url'] + if (locationHeaders.has(headerName) && reqConfig.magic_locations) { + if (header[1]) { + header[1] = `${baseUrl}/${header[1]}` + } else { + header[1] = `${baseUrl}` + } + } + + return header +} diff --git a/test/fixtures/cache-tests/test-engine/lib/modal.mjs b/test/fixtures/cache-tests/test-engine/lib/modal.mjs new file mode 100644 index 0000000..76cb1da --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/modal.mjs @@ -0,0 +1,27 @@ +export function modalOpen (content) { + let modal = document.getElementById('modal') + if (!modal) { + modal = document.createElement('div') + modal.classList.add('modal') + modal.id = 'modal' + } + modal.classList.add('modal-open') + modal.innerHTML = content + const closeButton = document.createElement('button') + const closeText = document.createTextNode('❎') + closeButton.appendChild(closeText) + closeButton.classList.add('modal-exit') + closeButton.addEventListener('click', function (event) { + event.preventDefault() + modal.classList.remove('modal-open') + }) + modal.appendChild(closeButton) + document.body.appendChild(modal) + document.onkeydown = function (evt) { + evt = evt || window.event + if (evt.key === 'Escape' || evt.key === 'Esc') { + modal.classList.remove('modal-open') + document.onkeydown = function () {} + } + } +} diff --git a/test/fixtures/cache-tests/test-engine/lib/results.mjs b/test/fixtures/cache-tests/test-engine/lib/results.mjs new file mode 100644 index 0000000..236912f --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/results.mjs @@ -0,0 +1,73 @@ +export const resultTypes = { + untested: ['-', '', '-'], + pass: ['\uf058', '#1aa123', '✅'], + fail: ['\uf057', '#c33131', '⛔️'], + optional_fail: ['\uf05a', '#bbbd15', '⚠️'], + yes: ['\uf055', '#999696', 'Y'], + no: ['\uf056', '#999696', 'N'], + setup_fail: ['\uf059', '#4c61ae', '🔹'], + harness_fail: ['\uf06a', '#4c61ae', '⁉️'], + dependency_fail: ['\uf192', '#b4b2b2', '⚪️'], + retry: ['\uf01e', '#4c61ae', '↻'] +} +const passTypes = [resultTypes.pass, resultTypes.yes] + +export function determineTestResult (testSuites, testId, testResults, honorDependencies = true) { + const test = testLookup(testSuites, testId) + const result = testResults[testId] + if (result === undefined) { + return resultTypes.untested + } + if (honorDependencies && test.depends_on !== undefined) { + for (const dependencyId of test.depends_on) { + if (!passTypes.includes(determineTestResult(testSuites, dependencyId, testResults))) { + return resultTypes.dependency_fail + } + } + } + if (result[0] === 'Setup') { + if (result[1] === 'retry') { + return resultTypes.retry + } else { + return resultTypes.setup_fail + } + } + if (result === false && result[0] !== 'Assertion') { + return resultTypes.harness_fail + } + if (result[0] === 'AbortError') { + return resultTypes.harness_fail + } + if (test.kind === 'required' || test.kind === undefined) { + if (result === true) { + return resultTypes.pass + } else { + return resultTypes.fail + } + } else if (test.kind === 'optimal') { + if (result === true) { + return resultTypes.pass + } else { + return resultTypes.optional_fail + } + } else if (test.kind === 'check') { + if (result === true) { + return resultTypes.yes + } else { + return resultTypes.no + } + } else { + throw new Error(`Unrecognised test kind ${test.kind}`) + } +} + +export function testLookup (testSuites, testId) { + for (const testSuite of testSuites) { + for (const test of testSuite.tests) { + if (test.id === testId) { + return test + } + } + } + throw new Error(`Cannot find test ${testId}`) +} diff --git a/test/fixtures/cache-tests/test-engine/lib/summary.mjs b/test/fixtures/cache-tests/test-engine/lib/summary.mjs new file mode 100644 index 0000000..f9cd2f6 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/summary.mjs @@ -0,0 +1,178 @@ +/* global fetch marked */ + +import '../../asset/marked.min.js' +import * as display from './display.mjs' +import { testLookup } from './results.mjs' + +export function loadResults (index) { + return Promise.all(index.map(item => + fetch(`results/${item.file}`) + .then(response => { + return response.json() + }) + .then(results => { + item.results = results + return item + } + )) + ) +} + +export function showResults (target, testSuites, results, testIds, suiteIds) { + const isDefault = testIds.length === 0 && suiteIds.length === 0 + testSuites.forEach(testSuite => { + const selectedTests = [] + const suiteTestIds = [] + testSuite.tests.forEach(test => { + if (isDefault || suiteIds.includes(testSuite.id)) { + selectedTests.push(test) + suiteTestIds.push(test.id) + } + if (isDefault === 0 || testIds.includes(test.id)) { + if (!suiteTestIds.includes(test.id)) { + selectedTests.push(test) + } + } + }) + if (selectedTests.length) { + showHeader(testSuite, results).forEach(row => { + target.appendChild(row) + }) + selectedTests.forEach(test => { + const result = showTest(testSuites, test.id, results) + if (target.childElementCount % 2) { + result.setAttribute('class', 'shade') + } + target.appendChild(result) + }) + } + }) +} + +export function showToC (target, testSuites) { + testSuites.forEach(testSuite => { + const suiteLink = document.createElement('a') + suiteLink.href = '#' + testSuite.id + suiteLink.appendChild(document.createTextNode(testSuite.name)) + const suiteLi = document.createElement('li') + suiteLi.appendChild(suiteLink) + target.appendChild(suiteLi) + }) +} + +function showHeader (testSuite, results) { + const rows = [] + const numCols = results.length + 2 + const blankRow = tableRow() + blankRow.appendChild(emptyCell(numCols)) + rows.push(blankRow) + const headerRow = tableRow() + headerRow.appendChild(tableCell('th', '\xa0', 'name category')) + const headerLink = document.createElement('a') + headerLink.href = '#' + testSuite.id + headerLink.appendChild(document.createTextNode(testSuite.name)) + const firstHeader = tableCell('th', headerLink, 'name category') + firstHeader.id = testSuite.id + headerRow.appendChild(firstHeader) + results.forEach(implementation => { + headerRow.appendChild(tableCell('th', implementation.name, 'category', implementation.version, implementation.link)) + }) + rows.push(headerRow) + if (testSuite.description !== undefined) { + const descriptionRow = tableRow() + const drCells = emptyCell(numCols) + drCells.innerHTML = marked.parse(testSuite.description).slice(3, -5) + descriptionRow.appendChild(drCells) + rows.push(descriptionRow) + } + return rows +} + +function showTest (testSuites, testId, results) { + const test = testLookup(testSuites, testId) + const testRow = tableRow() + testRow.appendChild(tableCell('td', testSelector(test.id))) + testRow.appendChild(tableCell('th', display.showTestName(test), 'name')) + results.forEach(implementation => { + testRow.appendChild( + tableCell('th', display.showTestResult(testSuites, test.id, implementation.results))) + }) + return testRow +} + +function tableRow (CssClass) { + const rowElement = document.createElement('tr') + if (CssClass) { + rowElement.setAttribute('class', CssClass) + } + return rowElement +} + +function tableCell (cellType, content, CssClass, hint, link, colspan) { + const cellElement = document.createElement(cellType) + if (CssClass) { + cellElement.setAttribute('class', CssClass) + } + if (colspan) { + cellElement.colSpan = colspan + } + let contentNode + if (typeof (content) === 'string') { + contentNode = document.createTextNode(content) + } else { + contentNode = content + } + if (link) { + const linkElement = document.createElement('a') + linkElement.setAttribute('href', link) + linkElement.appendChild(contentNode) + cellElement.appendChild(linkElement) + } else { + cellElement.appendChild(contentNode) + } + if (hint) { + cellElement.title = hint + } + return cellElement +} + +function testSelector (testId) { + const checkbox = document.createElement('input') + checkbox.type = 'checkbox' + checkbox.name = 'id' + checkbox.value = testId + checkbox.style.display = 'none' + checkbox.setAttribute('class', 'select') + return checkbox +} + +export function selectClickListen () { + const select = document.getElementById('select') + select.addEventListener('click', selectClick, { + once: true + }) +} + +function selectClick () { + const selectBoxes = document.getElementsByClassName('select') + for (const selectBox of selectBoxes) { + selectBox.style.display = 'inherit' + } + const submit = document.createElement('input') + submit.type = 'submit' + submit.value = 'Show only selected tests' + const select = document.getElementById('select') + select.replaceWith(submit) +} + +export function selectClearShow () { + const clear = document.createElement('a') + clear.href = '?' + clear.appendChild(document.createTextNode('Clear selections')) + const select = document.getElementById('select') + select.replaceWith(clear) +} + +function emptyCell (numCols = 1) { + return tableCell('td', '\xa0', undefined, undefined, undefined, numCols) +} diff --git a/test/fixtures/cache-tests/test-engine/lib/testsuite-schema.json b/test/fixtures/cache-tests/test-engine/lib/testsuite-schema.json new file mode 100644 index 0000000..ad97cdd --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/testsuite-schema.json @@ -0,0 +1,445 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cache-tests.fyi/testsuite-schema.json", + "title": "Cache Tests", + "description": "A list of test suites", + "type": "array", + "items": { + "description": "A test suite", + "type": "object", + "required": [ "name", "id", "tests"], + "additionalProperties": false, + "properties": { + "name": { + "description": "The name of the suite", + "type": "string" + }, + "id": { + "description": "A unique identifier for the suite", + "type": "string" + }, + "description": { + "description": "A Markdown description of the suite", + "type": "string" + }, + "spec_anchors": { + "description": "Anchors in the HTTP caching spec that this suite applies to", + "type": "array", + "items": { + "type": "string" + } + }, + "tests": { + "description": "An array of tests in the suite", + "type": "array", + "items": { + "description": "A test", + "type": "object", + "additionalProperties": false, + "required": ["name", "id", "requests"], + "properties": { + "name": { + "description": "The test name; can contain Markdown.", + "type": "string" + }, + "id": { + "$ref": "#/definitions/test-id" + }, + "description": { + "description": "A longer, Markdown description of the test", + "type": "string" + }, + "kind": { + "description": "The kind of test", + "type": "string", + "enum": ["required", "optimal", "check"] + }, + "spec_anchors": { + "description": "Anchors in the HTTP caching spec that this test applies to", + "type": "array", + "items": { + "type": "string" + } + }, + "requests": { + "description": "An array of requests", + "type": "array", + "items": { + "description": "A request to send in the test", + "type": "object", + "additionalProperties": false, + "properties": { + "request_method": { + "description": "the HTTP method to be used", + "type": "string" + }, + "request_headers": { + "description": "headers to emit in the request", + "type": "array", + "items": { + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "$ref": "#/definitions/magic-field-value" + } + ] + } + }, + "request_body": { + "description": "the HTTP request body to be used", + "type": "string" + }, + "query_arg": { + "description": "query arguments to add to the URL", + "type": "string" + }, + "filename": { + "description": "filename to add to the URL", + "type": "string" + }, + "mode": { + "description": "the mode value to pass to fetch()", + "type": "string", + "enum": ["same-origin", "no-cors", "navigate", "websocket"] + }, + "credentials": { + "description": "the credentials value to pass to fetch()", + "type": "string", + "enum": ["omit", "same-origin", "include"] + }, + "cache": { + "description": "the cache value to pass to fetch()", + "type": "string", + "enum": ["default", "no-store", "reload", "no-cache", "force-cache", "only-if-cached"] + }, + "redirect": { + "description": "the redirect value to pass to fetch()", + "type": "string", + "enum": ["follow", "error", "manual"] + }, + "pause_after": { + "description": "Whether to pause for three seconds after the request completes", + "type": "boolean" + }, + "disconnect": { + "description": "Whether to disconnect the client when receiving thsi request", + "type": "boolean" + }, + "magic_locations": { + "description": "Whether to rewrite Location and Content-Location to full URLs", + "type": "boolean" + }, + "magic_ims": { + "description": "Whether to rewrite If-Modified-Since to a delta from the previous Last-Modified", + "type": "boolean" + }, + "rfc850date": { + "description": "Header names to use RFC850 format on when converting dates", + "type": "array", + "items": [ + { + "$ref": "#/definitions/date-headers" + } + ] + }, + "response_status": { + "description": "HTTP status code and phrase to return from origin", + "type": "array", + "items": [ + { + "$ref": "#/definitions/status-code" + }, + { + "description": "status phrase", + "type": "string" + } + ] + }, + "response_headers": { + "description": "Response header fields to be returned from origin", + "type": "array", + "items": { + "anyOf": [ + { + "description": "name and value", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "oneOf": [ + { "type": "string" }, + { "type": "integer" } + ] + } + ] + }, + { + "description": "name and value with control over checking", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "$ref": "#/definitions/magic-field-value" + }, + { + "type": "boolean" + } + ] + } + ] + } + }, + "response_body": { + "description": "Response body to be returned from origin; defaults to the test identifier", + "$ref": "#/definitions/response-or-null" + }, + "check_body": { + "description": "Whether to check the response body on the client", + "type": "boolean" + }, + "expected_type": { + "description": "What the test result is expected to be", + "type": "string", + "enum": ["cached", "not_cached", "lm_validated", "etag_validated"] + }, + "expected_method": { + "description": "Expected request method received by the server", + "type": "string" + }, + "expected_status": { + "description": "Expected response status received by the client", + "$ref": "#/definitions/status-code" + }, + "expected_request_headers": { + "description": "Request headers to check for on the server", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "name and value", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "field value", + "type": "string" + } + ] + } + ] + } + }, + "response_pause": { + "description": "Pause the response body by the server", + "type": "integer" + }, + "expected_request_headers_missing": { + "description": "Request headers to check for absence on the server", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "name and value", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "field value", + "type": "string" + } + ] + } + ] + } + }, + "expected_response_headers": { + "description": "Response headers to check for on the client", + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "name and value", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "$ref": "#/definitions/magic-field-value" + } + ] + }, + { + "description": "check two headers have the same value", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "const": "=" + }, + { + "description": "field name to check against", + "type": "string" + } + ] + }, + { + "description": "header value is greater than an integer", + "type": "array", + "additionalItems": false, + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "const": ">" + }, + { + "description": "integer to check against", + "type": "integer" + } + ] + } + ] + } + }, + "expected_response_headers_missing": { + "description": "Response headers to check are missing on the client", + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "name and value", + "type": "array", + "items": [ + { + "$ref": "#/definitions/field-name" + }, + { + "description": "field value substring", + "type": "string" + } + ] + } + ] + } + }, + "expected_response_text": { + "description": "Expected response body received by the client", + "$ref": "#/definitions/response-or-null" + }, + "setup": { + "description": "Whether this is a setup request; failures don't mean the actual test failed", + "type": "boolean" + }, + "setup_tests": { + "description": "List of checks that are considered setup", + "type": "array", + "items": { + "type": "string", + "enum": ["expected_type", "expected_method", "expected_status", "expected_response_headers", "expected_response_text", "expected_request_headers"] + } + } + } + } + }, + "browser_only": { + "description": "Whether the test will only run on browsers", + "type": "boolean" + }, + "cdn_only": { + "description": "Whether the test will only run on CDN caches", + "type": "boolean" + }, + "browser_skip": { + "description": "Whether the test will skip browsers", + "type": "boolean" + }, + "depends_on": { + "description": "List of Test IDs that this test depends on", + "type": "array", + "items": { + "$ref": "#/definitions/test-id" + } + } + } + } + } + } + }, + "definitions": { + "field-name": { + "description": "HTTP header field name", + "type": "string", + "pattern": "^[a-zA-Z0-9-_]+$" + }, + "magic-field-value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "test-id": { + "description": "A short identifer for a test", + "type": "string" + }, + "response-or-null": { + "oneOf": [ + { + "description": "The text of the response", + "type": "string" + }, + { + "description": "Do not check the response", + "type": "null" + } + ] + }, + "status-code": { + "description": "HTTP response status code", + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "date-headers": { + "type": "string", + "enum": ["date", "if-modified-since", "last-modified", "expires"] + } + } +} diff --git a/test/fixtures/cache-tests/test-engine/lib/tpl/checks.liquid b/test/fixtures/cache-tests/test-engine/lib/tpl/checks.liquid new file mode 100644 index 0000000..730290f --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/tpl/checks.liquid @@ -0,0 +1,52 @@ + +{%- if request.setup -%} + {%- assign setup_prop = '' -%} +{%- else -%} + {%- assign setup_prop = ' _(Failure will be considered a test setup issue)_' -%} +{%- endif %} + +### The following checks will be performed: + +{%- if request.expected_type %} +- The client will check that this response {% case request.expected_type %}{% when "cached" %}is cached{% when "not_cached" %}is not cached{% when "lm_validated" %}is validated using `Last-Modified`{% when "etag_validated" %}is validated using `ETag`{% endcase %} {% if test.setup_tests contains "expected_type" %}{{ setup_prop }}{% endif %}{% endif -%} + +{%- if request.expected_method %} +- The server will check that the request method is `{{ request.expected_method }}`.{% endif -%} + +{%- if request.expected_request_headers.size > 0 %} +- The server will check that the following request headers (and values, when specified) are present{% if test.setup_tests contains "expected_request_headers" %}{{ setup_prop }}{% endif %}: +{%- render 'header-list' with request.expected_request_headers as headers %}{% endif -%} + +{%- if request.expected_request_headers_missing.size > 0 %} +- The server will check that the following request headers (and values, when specified) are absent{% if test.setup_tests contains "expected_request_headers_missing" %}{{ setup_prop }}{% endif %}: +{%- render 'header-list' with request.expected_request_headers_missing as headers %}{% endif -%} + +{%- if request.expected_status %} +- The client will check that the status code is `{{ request.expected_status }}` {% if test.setup_tests contains "expected_status" %}{{ setup_prop }}{% endif %} +{%- elsif request.response_status %} +- The client will check that the status code is `{{ request.response_status | join: " " }}`{{ setup_prop }} +{%- else %} +- The client will check that the status code is `200 OK`{{ setup_prop }} +{%- endif -%} + +{%- assign response_headers = request.response_headers | skipHeaders -%} +{%- if request.expected_response_headers.size > 0 or response_headers.size > 0 %} +- The client will check that the following response headers (and values, when specified) are present{% if test.setup_tests contains "expected_response_headers" %}{{ setup_prop }}{% endif -%}: +{%- render 'header-list' with request.expected_response_headers as headers -%} +{%- render 'header-list' with response_headers as headers %}{% endif -%} + +{%- if request.expected_response_headers_missing.size > 0 %} +- The client will check that the following response headers (and values, when specified) are missing: +{%- render 'header-list' with request.expected_response_headers_missing as headers %}{% endif -%} + +{%- if request.check_body != false %} +- The client will check the body +{%- if request.expected_response text -%} +, expecting: `{{ request.expected_response_text }}` {% if test.setup_tests contains "expected_response_text" %}{{ setup_prop }}{% endif %} +{%- else -%} +, expecting the generated response body. +{%- endif -%} +{%- endif -%} + + + diff --git a/test/fixtures/cache-tests/test-engine/lib/tpl/explain-test.liquid b/test/fixtures/cache-tests/test-engine/lib/tpl/explain-test.liquid new file mode 100644 index 0000000..975a6ce --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/tpl/explain-test.liquid @@ -0,0 +1,90 @@ +# {{ test.name }} + +Test ID: `{{ test.id }}` + +{% case test.kind %} +{%- when "optimal" -%}This is an optional test for cache efficiency +{%- when "check" -%}This is an informational check +{%- else -%}This is a conformance test +{%- endcase -%} +{%- if test.browser_only %} run on browsers only{% endif -%} +{%- if test.cdn_only %} run on CDN caches only{% endif -%} +{%- if test.browser_skip %} not run on browsers{% endif -%} +. + +{%- if test.depends_on %} It depends on the following test IDs: +{%- for dependant in test.depends_on %} +- `{{ dependant }}` +{%- endfor %}{% endif %} + +{% for request in test.requests -%} +## Request {{ forloop.index }} + +{%- if request.setup == true %} + +_This is a setup request; if it fails, we can't perform the test._ +{%- endif -%} + +{%- if request.disconnect == true %} + +The server will disconnect the client when receiving this request. +{%- endif -%} + + +{%- if request.mode or request.credentials or request.cache or request.redirect %} + +### Fetch [init](https://fetch.spec.whatwg.org/#requestinit): +{%- if request.mode %} +- Mode: {{ request.mode }} +{%- endif -%} +{%- if request.credentials %} +- Credentials: {{ request.credentials }} +{%- endif -%} +{%- if request.cache %} +- Cache: {{ request.cache }} +{%- endif -%} +{%- if request.redirect %} +- Redirect: {{ request.redirect }} +{%- endif -%} +{%- endif %} + +### The client sends a request containing: +~~~ +{{ request.request_method | default: 'GET' }} [generated test URL]{% if request.filename %}/{% endif %}{{ request.filename }}{% if request.query_arg %}?{% endif %}{{ request.query_arg }} {{ magic_locations }} +{% for header in request.request_headers %}{% render 'header-magic' with header as header %} +{% endfor %} +{{ request.request_body }} +~~~ + +{%- if request.response_pause %} + +The server will pause for {{ request.response_pause }} seconds before responding.{% endif -%} + +{%- if request.response_status or request.response_headers or request.response_body %} + +### The server sends a response containing: +~~~ +{% if request.expected_type == "lm_validated" or request.expected_type = "etag_validated" -%} +HTTP/1.1 304 Not Modified +{%- else -%} +HTTP/1.1 {{ request.response_status[0] | default: 200 }} {{ request.response_status[1] | default: "OK" }} +{%- endif %} +{% for header in request.response_headers %}{% render 'header-magic' with header as header %} +{% endfor %} +{{ request.response_body | default: '[generated response body]' }} +~~~{% endif -%} + +{%- if request.rfc850date %} +All instances of the following headers will be send and checked for being in RFC850 date format: +{% for header in request.rfc850date %} + - `{{ header }}` +{% endfor %} +{% endif -%} + +{% render 'checks' with request as request %} + +{%- if request.pause_after == true %} + +The client will pause for three seconds after this request.{% endif %} + +{% endfor %} diff --git a/test/fixtures/cache-tests/test-engine/lib/tpl/header-list.liquid b/test/fixtures/cache-tests/test-engine/lib/tpl/header-list.liquid new file mode 100644 index 0000000..03acc22 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/tpl/header-list.liquid @@ -0,0 +1,16 @@ +{%- for header in headers %} + - {% if header.first -%} + {%- if header.size == 3 -%} + {%- case header[1] -%} + {%- when ">" -%} + `{{ header[0] }}` is greater than `{{ header[2] }}` + {%- when "=" -%} + `{{ header[0] }}` has the same value as `{{ header[2] }}` + {%- endcase -%} + {%- else -%} + `{% render 'header-magic' with header as header %}` + {%- endif -%} + {%- else -%} + `{{ header }}` + {%- endif -%} +{% endfor -%} diff --git a/test/fixtures/cache-tests/test-engine/lib/tpl/header-magic.liquid b/test/fixtures/cache-tests/test-engine/lib/tpl/header-magic.liquid new file mode 100644 index 0000000..843c3a4 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/tpl/header-magic.liquid @@ -0,0 +1,6 @@ +{%- assign mytype = header[1] | typeof -%} +{{ header[0] }}: {% if mytype == 'number' -%} + [ {{ header[1] | toLocaleString }} seconds delta ] +{%- else -%} + {{ header[1] }} +{%- endif -%} diff --git a/test/fixtures/cache-tests/test-engine/lib/utils.mjs b/test/fixtures/cache-tests/test-engine/lib/utils.mjs new file mode 100644 index 0000000..cc071ad --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/lib/utils.mjs @@ -0,0 +1,89 @@ +export function AssertionError (options) { + this.name = 'Assertion' + this.message = options.message +} + +export function SetupError (options) { + this.name = 'Setup' + this.message = options.message +} + +export function assert (isSetup, expr, message) { + if (expr) return + if (isSetup) { + throw new SetupError({ message }) + } else { + throw new AssertionError({ message }) + } +} + +export function token () { + return [toHex(randInt(32), 8), + toHex(randInt(16), 4), + toHex(0x4000 | randInt(12), 4), + toHex(0x8000 | randInt(14), 4), + toHex(randInt(48), 12)].join('-') +} + +function randInt (bits) { + if (bits < 1 || bits > 53) { + throw new TypeError() + } else { + if (bits >= 1 && bits <= 30) { + return 0 | ((1 << bits) * Math.random()) + } else { + const high = (0 | ((1 << (bits - 30)) * Math.random())) * (1 << 30) + const low = 0 | ((1 << 30) * Math.random()) + return high + low + } + } +} + +function toHex (x, length) { + let rv = x.toString(16) + while (rv.length < length) { + rv = '0' + rv + } + return rv +} + +const rfc850day = { + 0: 'Sunday', + 1: 'Monday', + 2: 'Tuesday', + 3: 'Wednesday', + 4: 'Thursday', + 5: 'Friday', + 6: 'Saturday' +} + +const rfc850month = { + 0: 'Jan', + 1: 'Feb', + 2: 'Mar', + 3: 'Apr', + 4: 'May', + 5: 'Jun', + 6: 'Jul', + 7: 'Aug', + 8: 'Sep', + 9: 'Oct', + 10: 'Nov', + 11: 'Dec' +} + +export function httpDate (now, deltaSecs, format) { + const instant = new Date(now + (deltaSecs * 1000)) + if (format && format === 'rfc850') { + const day = rfc850day[instant.getUTCDay()] + const date = instant.getUTCDate().toString().padStart(2, '0') + const month = rfc850month[instant.getUTCMonth()] + const year = instant.getUTCFullYear().toString().slice(2) + const hours = instant.getUTCHours().toString().padStart(2, '0') + const mins = instant.getUTCMinutes().toString().padStart(2, '0') + const secs = instant.getUTCSeconds().toString().padStart(2, '0') + // Sunday, 06-Nov-94 08:49:37 GMT + return `${day}, ${date}-${month}-${year} ${hours}:${mins}:${secs} GMT` + } + return instant.toGMTString() +} diff --git a/test/fixtures/cache-tests/test-engine/server/handle-config.mjs b/test/fixtures/cache-tests/test-engine/server/handle-config.mjs new file mode 100644 index 0000000..80c7492 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/server/handle-config.mjs @@ -0,0 +1,22 @@ +import { sendResponse, configs, setConfig } from './utils.mjs' + +export default function handleConfig (pathSegs, request, response) { + const uuid = pathSegs[0] + if (request.method !== 'PUT') { + sendResponse(response, 405, `${request.method} request to config for ${uuid}`) + return + } + if (configs.has(uuid)) { + sendResponse(response, 409, `Config already exists for ${uuid}`) + return + } + let body = '' + request.on('data', chunk => { + body += chunk + }) + request.on('end', () => { + setConfig(uuid, JSON.parse(body)) + response.statusCode = 201 + response.end('OK') + }) +} diff --git a/test/fixtures/cache-tests/test-engine/server/handle-file.mjs b/test/fixtures/cache-tests/test-engine/server/handle-file.mjs new file mode 100644 index 0000000..a92a9dd --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/server/handle-file.mjs @@ -0,0 +1,24 @@ +import fs from 'fs' +import path from 'path' +import process from 'process' + +import { sendResponse } from './utils.mjs' +import { mimeTypes } from '../lib/defines.mjs' + +export default function handleFile (url, request, response) { + let urlPath = path.normalize(url.pathname) + if (urlPath === '/') urlPath = '/index.html' + const filename = path.join(process.cwd(), urlPath) + let stat + try { + stat = fs.statSync(filename) + } catch {} + if (!stat || !stat.isFile()) { + sendResponse(response, 404, `${urlPath} Not Found`) + return + } + const mimeType = mimeTypes[path.extname(filename).split('.')[1]] || 'application/octet-stream' + const fileStream = fs.createReadStream(filename) + response.writeHead(200, { 'Content-Type': mimeType }) + fileStream.pipe(response) +} diff --git a/test/fixtures/cache-tests/test-engine/server/handle-state.mjs b/test/fixtures/cache-tests/test-engine/server/handle-state.mjs new file mode 100644 index 0000000..43b22ce --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/server/handle-state.mjs @@ -0,0 +1,12 @@ +import { sendResponse, stash } from './utils.mjs' + +export default function handleState (pathSegs, request, response) { + const uuid = pathSegs[0] + const state = stash.get(uuid) + if (state === undefined) { + sendResponse(response, 404, `State not found for ${uuid}`) + return + } + response.setHeader('Content-Type', 'text/plain') + response.end(JSON.stringify(state)) +} diff --git a/test/fixtures/cache-tests/test-engine/server/handle-test.mjs b/test/fixtures/cache-tests/test-engine/server/handle-test.mjs new file mode 100644 index 0000000..13aae63 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/server/handle-test.mjs @@ -0,0 +1,118 @@ +import { noBodyStatus } from '../lib/defines.mjs' +import { fixupHeader } from '../lib/header-fixup.mjs' +import { sendResponse, getHeader, configs, stash, setStash, logRequest, logResponse } from './utils.mjs' + +export default function handleTest (pathSegs, request, response) { + // identify the desired configuration for this request + const uuid = pathSegs[0] + if (!uuid) { + sendResponse(response, 404, `Config Not Found for ${uuid}`) + return + } + const requests = configs.get(uuid) + if (!requests) { + sendResponse(response, 409, `Requests not found for ${uuid}`) + return + } + + const serverState = stash.get(uuid) || [] + const srvReqNum = serverState.length + 1 + const cliReqNum = parseInt(request.headers['req-num']) + const reqNum = cliReqNum || srvReqNum + const reqConfig = requests[reqNum - 1] + + if (!reqConfig) { + sendResponse(response, 409, `${requests[0].id} config not found for request ${srvReqNum} (anticipating ${requests.length})`) + return + } + if (reqConfig.dump) logRequest(request, srvReqNum) + + // response_pause + if ('response_pause' in reqConfig) { + setTimeout(continueHandleTest, reqConfig.response_pause * 1000, uuid, request, response, requests, serverState) + } else { + continueHandleTest(uuid, request, response, requests, serverState) + } +} + +function continueHandleTest (uuid, request, response, requests, serverState) { + const srvReqNum = serverState.length + 1 + const cliReqNum = parseInt(request.headers['req-num']) + const reqNum = cliReqNum || srvReqNum + const reqConfig = requests[reqNum - 1] + const previousConfig = requests[reqNum - 2] + const now = Date.now() + + // Determine what the response status should be + let httpStatus = reqConfig.response_status || [200, 'OK'] + if ('expected_type' in reqConfig && reqConfig.expected_type.endsWith('validated')) { + const previousLm = getHeader(previousConfig.response_headers, 'Last-Modified') + if (previousLm && request.headers['if-modified-since'] === previousLm) { + httpStatus = [304, 'Not Modified'] + } + const previousEtag = getHeader(previousConfig.response_headers, 'ETag') + if (previousEtag && request.headers['if-none-match'] === previousEtag) { + httpStatus = [304, 'Not Modified'] + } + if (httpStatus[0] !== 304) { + httpStatus = [999, '304 Not Generated'] + } + } + response.statusCode = httpStatus[0] + response.statusPhrase = httpStatus[1] + + // header manipulation + const responseHeaders = reqConfig.response_headers || [] + const savedHeaders = new Map() + response.setHeader('Server-Base-Url', request.url) + response.setHeader('Server-Request-Count', srvReqNum) + response.setHeader('Client-Request-Count', cliReqNum) + response.setHeader('Server-Now', now, 0) + responseHeaders.forEach(header => { + header = fixupHeader(header, response.getHeaders(), reqConfig) + if (response.hasHeader(header[0])) { + const currentVal = response.getHeader(header[0]) + if (typeof currentVal === 'string') { + response.setHeader(header[0], [currentVal, header[1]]) + } else if (Array.isArray(currentVal)) { + response.setHeader(header[0], currentVal.concat(header[1])) + } else { + console.log(`ERROR: Unanticipated header type of ${typeof currentVal} for ${header[0]}`) + } + } else { + response.setHeader(header[0], header[1]) + } + if (header.length < 3 || header[2] === true) { + savedHeaders.set(header[0], response.getHeader(header[0])) + } + }) + + if (!response.hasHeader('content-type')) { + response.setHeader('Content-Type', 'text/plain') + } + + // stash information about this request for the client + serverState.push({ + request_num: cliReqNum, + request_method: request.method, + request_headers: request.headers, + response_headers: Array.from(savedHeaders.entries()) + }) + response.setHeader('Request-Numbers', serverState.map(item => item.request_num).join(' ')) + setStash(uuid, serverState) + + // Response body generation + if ('disconnect' in reqConfig && reqConfig.disconnect) { + // disconnect now because we want the state + response.socket.destroy() + response = 'disconnect' + } else if (noBodyStatus.has(response.statusCode)) { + response.end() + } else { + const content = reqConfig.response_body || uuid + response.end(content) + } + + // logging + if (reqConfig.dump) logResponse(response, srvReqNum) +} diff --git a/test/fixtures/cache-tests/test-engine/server/server.mjs b/test/fixtures/cache-tests/test-engine/server/server.mjs new file mode 100644 index 0000000..58f098b --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/server/server.mjs @@ -0,0 +1,54 @@ +/* global URL */ + +import fs from 'fs' +import http from 'http' +import https from 'https' +import process from 'process' + +import handleConfig from './handle-config.mjs' +import handleFile from './handle-file.mjs' +import handleState from './handle-state.mjs' +import handleTest from './handle-test.mjs' + +function handleMain (request, response) { + const url = new URL(request.url, baseUrl) + const pathSegs = url.pathname.split('/') + pathSegs.shift() + const dispatch = pathSegs.shift() + if (dispatch === 'config') { + handleConfig(pathSegs, request, response) + } else if (dispatch === 'test') { + handleTest(pathSegs, request, response) + } else if (dispatch === 'state') { + handleState(pathSegs, request, response) + } else { + handleFile(url, request, response) + } +} + +const protocol = process.env.npm_config_protocol || process.env.npm_package_config_protocol +const port = process.env.npm_config_port || process.env.npm_package_config_port +const baseUrl = `${protocol}://localhost:${port}/` +const pidfile = process.env.npm_config_pidfile || process.env.npm_package_config_pidfile + +fs.writeFile(pidfile, process.pid.toString(), 'ascii', function (err) { + if (err) { console.log(`PID file write error: ${err.message}`) } +}) + +let server +if (protocol.toLowerCase() === 'https') { + const options = { + key: fs.readFileSync(process.env.npm_config_keyfile), + cert: fs.readFileSync(process.env.npm_config_certfile) + } + server = https.createServer(options, handleMain) +} else { + server = http.createServer(handleMain) +} +server.on('listening', () => { + const host = (server.address().family === 'IPv6') + ? `[${server.address().address}]` + : server.address().address + console.log(`Listening on ${protocol.toLowerCase()}://${host}:${server.address().port}/`) +}) +server.listen(port) diff --git a/test/fixtures/cache-tests/test-engine/server/utils.mjs b/test/fixtures/cache-tests/test-engine/server/utils.mjs new file mode 100644 index 0000000..7f37a66 --- /dev/null +++ b/test/fixtures/cache-tests/test-engine/server/utils.mjs @@ -0,0 +1,54 @@ +import { BLUE, NC } from '../lib/defines.mjs' + +export function sendResponse (response, statusCode, message) { + console.log(`SERVER WARNING: ${message}`) + response.writeHead(statusCode, { 'Content-Type': 'text/plain' }) + response.write(`${message}\n`) + response.end() +} + +export function getHeader (headers, headerName) { + let result + headers.forEach(header => { + if (header[0].toLowerCase() === headerName.toLowerCase()) { + result = header[1] + } + }) + return result +} + +// stash for server state +export const stash = new Map() + +export function setStash (key, value) { + stash.set(key, value) +} + +// configurations +export const configs = new Map() + +export function setConfig (key, value) { + configs.set(key, value) +} + +export function logRequest (request, reqNum) { + console.log(`${BLUE}=== Server request ${reqNum}${NC}`) + console.log(` ${request.method} ${request.url}`) + for (const [key, value] of Object.entries(request.headers)) { + console.log(` ${key}: ${value}`) + } + console.log('') +} + +export function logResponse (response, resNum) { + console.log(`${BLUE}=== Server response ${resNum}${NC}`) + if (response === 'disconnect') { + console.log(' [ server disconnect ]') + } else { + console.log(` HTTP ${response.statusCode} ${response.statusPhrase}`) + for (const [key, value] of Object.entries(response.getHeaders())) { + console.log(` ${key}: ${value}`) + } + } + console.log('') +} diff --git a/test/fixtures/cache-tests/tests/age-parse.mjs b/test/fixtures/cache-tests/tests/age-parse.mjs new file mode 100644 index 0000000..f304b32 --- /dev/null +++ b/test/fixtures/cache-tests/tests/age-parse.mjs @@ -0,0 +1,301 @@ +export default + +{ + name: 'Age Parsing', + id: 'age-parse', + description: 'These tests check how caches parse the `Age` response header.', + spec_anchors: ['field.age', 'expiration.model'], + tests: [ + { + name: 'HTTP cache should ignore an `Age` header with a non-numeric value', + id: 'age-parse-nonnumeric', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', 'abc', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should ignore an `Age` header with a negative value', + id: 'age-parse-negative', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '-7200', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should ignore an `Age` header with a float value', + id: 'age-parse-float', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '7200.0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with a `Age` value of 2147483647 to be stale', + id: 'age-parse-large-minus-one', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '2147483647', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with a `Age` value of 2147483648 to be stale', + id: 'age-parse-large', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '2147483648', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with a `Age` value of 2147483649 to be stale', + id: 'age-parse-larger', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '2147483649', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with a single `Age` header line `old, 0` to be stale', + id: 'age-parse-suffix', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '7200, 0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with a single `Age` header line `0, old` to be fresh', + id: 'age-parse-prefix', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '0, 7200', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should use the first line in a response with multiple `Age` header lines: `old`, `0`', + id: 'age-parse-suffix-twoline', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '7200', false], + ['Age', '0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache should use the first line in a response with multiple `Age` header lines: `0`, `old`', + id: 'age-parse-prefix-twoline', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '0', false], + ['Age', '7200', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with a single line `Age: 0, 0` to be fresh', + id: 'age-parse-dup-0', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '0, 0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with two `Age: 0` header lines to be fresh', + id: 'age-parse-dup-0-twoline', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '0', false], + ['Age', '0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache should consider a response with two `Age: not_old` header lines to be fresh', + id: 'age-parse-dup-old', + depends_on: ['freshness-max-age-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=10000'], + ['Age', '3600', false], + ['Age', '3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache consider an alphabetic parameter on `Age` header to be valid?', + id: 'age-parse-parameter', + depends_on: ['freshness-max-age-age'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '7200;foo=bar', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache should consider a numeric parameter on `Age` header to be valid?', + id: 'age-parse-numeric-parameter', + depends_on: ['freshness-max-age-age'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '7200;foo=111', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/authorization.mjs b/test/fixtures/cache-tests/tests/authorization.mjs new file mode 100644 index 0000000..ba35a39 --- /dev/null +++ b/test/fixtures/cache-tests/tests/authorization.mjs @@ -0,0 +1,110 @@ +import * as templates from './lib/templates.mjs' + +export default + +{ + name: 'Storing Respones to Authenticated Requests', + id: 'auth', + description: 'These tests check for behaviours regarding authenticated HTTP responses.', + spec_anchors: ['caching.authenticated.responses'], + tests: [ + { + name: 'HTTP shared cache must not reuse a response to a request that contained `Authorization`, even with explicit freshness', + id: 'other-authorization', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({ + request_headers: [ + ['Authorization', 'FOO'] + ], + expected_request_headers: [ + ['Authorization', 'FOO'] + ] + }), + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP shared cache reuses a response to a request that contained `Authorization`, if it has `Cache-Control: public`', + id: 'other-authorization-public', + kind: 'optimal', + browser_skip: true, + depends_on: ['other-authorization'], + spec_anchors: ['cache-response-directive.public'], + requests: [ + { + request_headers: [ + ['Authorization', 'FOO'] + ], + expected_request_headers: [ + ['Authorization', 'FOO'] + ], + response_headers: [ + ['Cache-Control', 'max-age=3600, public'], + ['Date', 0] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP shared cache reuses a response to a request that contained `Authorization`, if it has `Cache-Control: must-revalidate`', + id: 'other-authorization-must-revalidate', + kind: 'optimal', + browser_skip: true, + depends_on: ['other-authorization'], + requests: [ + { + request_headers: [ + ['Authorization', 'FOO'] + ], + expected_request_headers: [ + ['Authorization', 'FOO'] + ], + response_headers: [ + ['Cache-Control', 'max-age=3600, must-revalidate'], + ['Date', 0] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP shared cache reuses a response to a request that contained `Authorization`, if it has `Cache-Control: s-maxage`', + id: 'other-authorization-smaxage', + kind: 'optimal', + browser_skip: true, + depends_on: ['other-authorization'], + requests: [ + { + request_headers: [ + ['Authorization', 'FOO'] + ], + expected_request_headers: [ + ['Authorization', 'FOO'] + ], + response_headers: [ + ['Cache-Control', 's-maxage=3600'], + ['Date', 0] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/cc-freshness.mjs b/test/fixtures/cache-tests/tests/cc-freshness.mjs new file mode 100644 index 0000000..64e5764 --- /dev/null +++ b/test/fixtures/cache-tests/tests/cc-freshness.mjs @@ -0,0 +1,468 @@ +import * as templates from './lib/templates.mjs' + +export default + +{ + name: 'Cache-Control Freshness', + id: 'cc-freshness', + description: 'These tests check how caches calculate freshness using `Cache-Control`.', + spec_anchors: ['expiration.model', 'cache-response-directive'], + tests: [ + { + name: 'Does HTTP cache avoid reusing a response without explict freshness information or a validator (reuse is allowed, but not common, and many tests rely upon a cache _not_ doing it)?', + id: 'freshness-none', + kind: 'check', + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with positive `Cache-Control: max-age`', + id: 'freshness-max-age', + kind: 'optimal', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with `Cache-Control: max-age` after it becomes stale', + id: 'freshness-max-age-stale', + kind: 'optimal', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + templates.becomeStale({}), + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with `Cache-Control: max-age=0`', + id: 'freshness-max-age-0', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=0'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with `Cache-Control: max-age: 2147483647`', + id: 'freshness-max-age-max-minus-1', + kind: 'optimal', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2147483647'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with `Cache-Control: max-age: 2147483648`', + id: 'freshness-max-age-max', + kind: 'optimal', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2147483648'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with `Cache-Control: max-age: 2147483649`', + id: 'freshness-max-age-max-plus-1', + kind: 'optimal', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2147483649'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with `Cache-Control: max-age: 99999999999`', + id: 'freshness-max-age-max-plus', + kind: 'optimal', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=99999999999'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response when the `Age` header is greater than its `Cache-Control: max-age` freshness lifetime', + id: 'freshness-max-age-age', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-response-directive.max-age', 'field.age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Cache-Control', 'max-age=3600'], + ['Age', '7200'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache consider `Date` when applying `Cache-Control: max-age` (i.e., is `apparent_age` used)?', + id: 'freshness-max-age-date', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-response-directive.max-age'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Date', -7200], + ['Cache-Control', 'max-age=3600'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with positive `Cache-Control: max-age` and a past `Expires`', + id: 'freshness-max-age-expires', + depends_on: ['freshness-max-age'], + kind: 'optimal', + spec_anchors: ['cache-response-directive.max-age', 'field.expires'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Expires', -7200], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with positive `Cache-Control: max-age` and an invalid `Expires`', + id: 'freshness-max-age-expires-invalid', + depends_on: ['freshness-max-age'], + kind: 'optimal', + spec_anchors: ['cache-response-directive.max-age', 'field.expires'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Expires', '0', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with `Cache-Control: max-age=0` and a future `Expires`', + id: 'freshness-max-age-0-expires', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age', 'field.expires'], + requests: [ + { + response_headers: [ + ['Expires', 3600], + ['Cache-Control', 'max-age=0'], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with positive `Cache-Control: max-age` and a CC extension present', + id: 'freshness-max-age-extension', + kind: 'optimal', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache.control.extensions'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'foobar, max-age=3600'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with positive `Cache-Control: MaX-AgE`', + id: 'freshness-max-age-case-insenstive', + kind: 'optimal', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'MaX-aGe=3600'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with negative `Cache-Control: max-age`', + id: 'freshness-max-age-negative', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=-3600'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Private HTTP cache must not prefer `Cache-Control: s-maxage` over shorter `Cache-Control: max-age`', + id: 'freshness-max-age-s-maxage-private', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 's-maxage=3600, max-age=1'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'not_cached' + } + ], + browser_only: true + }, + { + name: 'Private HTTP cache must not prefer `Cache-Control: s-maxage` over shorter `Cache-Control: max-age` (multiple headers)', + id: 'freshness-max-age-s-maxage-private-multiple', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 's-maxage=3600'], + ['Cache-Control', 'max-age=1'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'not_cached' + } + ], + browser_only: true + }, + { + name: 'An optimal shared HTTP cache reuses a response with positive `Cache-Control: s-maxage`', + id: 'freshness-s-maxage-shared', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 's-maxage=3600'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ], + browser_skip: true + }, + { + name: 'Shared HTTP cache must prefer short `Cache-Control: s-maxage` over a longer `Cache-Control: max-age`', + id: 'freshness-max-age-s-maxage-shared-longer', + depends_on: ['freshness-s-maxage-shared'], + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600, s-maxage=1'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'not_cached' + } + ], + browser_skip: true + }, + { + name: 'Shared HTTP cache must prefer short `Cache-Control: s-maxage` over a longer `Cache-Control: max-age` (reversed)', + id: 'freshness-max-age-s-maxage-shared-longer-reversed', + depends_on: ['freshness-s-maxage-shared'], + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 's-maxage=1, max-age=3600'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'not_cached' + } + ], + browser_skip: true + }, + { + name: 'Shared HTTP cache must prefer short `Cache-Control: s-maxage` over a longer `Cache-Control: max-age` (multiple headers)', + id: 'freshness-max-age-s-maxage-shared-longer-multiple', + depends_on: ['freshness-s-maxage-shared'], + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Cache-Control', 's-maxage=1'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'not_cached' + } + ], + browser_skip: true + }, + { + name: 'An optimal shared HTTP cache prefers long `Cache-Control: s-maxage` over a shorter `Cache-Control: max-age`', + id: 'freshness-max-age-s-maxage-shared-shorter', + depends_on: ['freshness-s-maxage-shared'], + kind: 'optimal', + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1, s-maxage=3600'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ], + browser_skip: true + }, + { + name: 'An optimal shared HTTP cache prefers long `Cache-Control: s-maxage` over `Cache-Control: max-age=0`, even with a past `Expires`', + id: 'freshness-max-age-s-maxage-shared-shorter-expires', + depends_on: ['freshness-s-maxage-shared'], + kind: 'optimal', + spec_anchors: ['cache-response-directive.max-age', 'cache-response-directive.s-maxage'], + requests: [ + { + response_headers: [ + ['Expires', -10], + ['Cache-Control', 'max-age=0, s-maxage=3600'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ], + browser_skip: true + } + ] +} diff --git a/test/fixtures/cache-tests/tests/cc-parse.mjs b/test/fixtures/cache-tests/tests/cc-parse.mjs new file mode 100644 index 0000000..58b9a63 --- /dev/null +++ b/test/fixtures/cache-tests/tests/cc-parse.mjs @@ -0,0 +1,278 @@ +export default + +{ + name: 'Cache-Control Parsing', + id: 'cc-parse', + description: 'These tests check how caches parse the `Cache-Control` response header.', + spec_anchors: ['field.cache-control'], + tests: [ + { + name: 'Does HTTP cache reuse a response when first `Cache-Control: max-age` is fresh, but second is stale (same line)?', + id: 'freshness-max-age-two-fresh-stale-sameline', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1800, max-age=1', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a response when first `Cache-Control: max-age` is fresh, but second is stale (separate lines)?', + id: 'freshness-max-age-two-fresh-stale-sepline', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1800', false], + ['Cache-Control', 'max-age=1', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a response when first `Cache-Control: max-age` is stale, but second is fresh (same line)?', + id: 'freshness-max-age-two-stale-fresh-sameline', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1, max-age=1800', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a response when first `Cache-Control: max-age` is stale, but second is fresh (separate lines)?', + id: 'freshness-max-age-two-stale-fresh-sepline', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1', false], + ['Cache-Control', 'max-age=1800', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a response with a quoted `Cache-Control: max-age`?', + id: 'freshness-max-age-quoted', + kind: 'check', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age="3600"', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with `max-age` in a quoted string (before the "real" `max-age`)', + id: 'freshness-max-age-ignore-quoted', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'extension="max-age=3600", max-age=1', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache mut not reuse a response with `max-age` in a quoted string (after the "real" `max-age`)', + id: 'freshness-max-age-ignore-quoted-rev', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1, extension="max-age=3600"', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache ignore max-age with space before the `=`?', + id: 'freshness-max-age-space-before-equals', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age =3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache ignore max-age with space after the `=`?', + id: 'freshness-max-age-space-after-equals', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age= 3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses max-age with the value `003600`', + id: 'freshness-max-age-leading-zero', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=003600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with a single-quoted `Cache-Control: max-age`', + id: 'freshness-max-age-single-quoted', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=\'3600\'', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache reuse max-age with `3600.0` value?', + id: 'freshness-max-age-decimal-zero', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600.0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse max-age with `3600.5` value?', + id: 'freshness-max-age-decimal-five', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600.5', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a response with an invalid `Cache-Control: max-age` (leading alpha)?', + id: 'freshness-max-age-a100', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=a3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a response with an invalid `Cache-Control: max-age` (trailing alpha)?', + id: 'freshness-max-age-100a', + kind: 'check', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600a', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/cc-request.mjs b/test/fixtures/cache-tests/tests/cc-request.mjs new file mode 100644 index 0000000..6a7d59c --- /dev/null +++ b/test/fixtures/cache-tests/tests/cc-request.mjs @@ -0,0 +1,241 @@ +import * as templates from './lib/templates.mjs' +import * as utils from './lib/utils.mjs' + +export default { + name: 'Cache-Control Request Directives', + id: 'cc-request', + description: 'These tests check to see if caches respect `Cache-Control` request directives. Note that HTTP does not require them to be supported.', + spec_anchors: ['cache-request-directive'], + tests: [ + { + name: 'Does HTTP cache honor request `Cache-Control: max-age=0` when it holds a fresh response?', + id: 'ccreq-ma0', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.max-age'], + requests: [ + templates.fresh({}), + { + request_headers: [ + ['Cache-Control', 'max-age=0'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: max-age=1` when it holds a fresh response?', + id: 'ccreq-ma1', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.max-age'], + requests: [ + templates.fresh({}), + { + request_headers: [ + ['Cache-Control', 'max-age=1'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: max-age` when it holds a fresh but `Age`d response that is not fresh enough?', + id: 'ccreq-magreaterage', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.max-age'], + requests: [ + templates.fresh({ + response_headers: [ + ['Age', '1800'] + ] + }), + { + request_headers: [ + ['Cache-Control', 'max-age=600'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a stale response when request `Cache-Control: max-stale` allows it?', + id: 'ccreq-max-stale', + kind: 'check', + depends_on: ['freshness-max-age-stale'], + spec_anchors: ['cache-request-directive.max-stale'], + requests: [ + templates.becomeStale({}), + { + request_headers: [ + ['Cache-Control', 'max-stale=1000'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a stale `Age`d response when request `Cache-Control: max-stale` allows it?', + id: 'ccreq-max-stale-age', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.max-stale'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1500'], + ['Age', '2000'] + ], + setup: true + }, + { + request_headers: [ + ['Cache-Control', 'max-stale=1000'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: min-fresh` when the response it holds is not fresh enough?', + id: 'ccreq-min-fresh', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.min-fresh'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1500'] + ], + setup: true + }, + { + request_headers: [ + ['Cache-Control', 'min-fresh=2000'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: min-fresh` when the `Age`d response it holds is not fresh enough?', + id: 'ccreq-min-fresh-age', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.min-fresh'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1500'], + ['Age', '1000'] + ], + setup: true + }, + { + request_headers: [ + ['Cache-Control', 'min-fresh=1000'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: no-cache` when it holds a fresh response?', + id: 'ccreq-no-cache', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'] + ], + setup: true + }, + { + request_headers: [ + ['Cache-Control', 'no-cache'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: no-cache` by validating a response with `Last-Modified`?', + id: 'ccreq-no-cache-lm', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Last-Modified', -10000], + ['Date', 0] + ], + setup: true + }, + { + request_headers: [ + ['Cache-Control', 'no-cache'] + ], + expected_type: 'lm_validated' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: no-cache` by validating a response with an `ETag`?', + id: 'ccreq-no-cache-etag', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['ETag', utils.httpContent('abc')] + ], + setup: true + }, + { + request_headers: [ + ['Cache-Control', 'no-cache'] + ], + expected_type: 'etag_validated' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: no-store` when it holds a fresh response?', + id: 'ccreq-no-store', + kind: 'check', + depends_on: ['freshness-max-age'], + spec_anchors: ['cache-request-directive.no-store'], + requests: [ + templates.fresh({}), + { + request_headers: [ + ['Cache-Control', 'no-store'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache honour request `Cache-Control: only-if-cached` by generating a `504` response when it does not have a stored response?', + id: 'ccreq-oic', + kind: 'check', + spec_anchors: ['cache-request-directive.only-if-cached'], + requests: [ + { + request_headers: [ + ['Cache-Control', 'only-if-cached'] + ], + expected_status: 504, + expected_response_text: null + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/cc-response.mjs b/test/fixtures/cache-tests/tests/cc-response.mjs new file mode 100644 index 0000000..1c81bec --- /dev/null +++ b/test/fixtures/cache-tests/tests/cc-response.mjs @@ -0,0 +1,375 @@ +export default + +{ + name: 'Cache-Control Response Directives', + id: 'cc-response', + description: 'These tests check how caches handle response `Cache-Control` directives other than those related to freshness, like `no-cache` and `no-store`.', + spec_anchors: ['cache-response-directive'], + tests: [ + { + name: 'Shared HTTP cache must not store a response with `Cache-Control: private`', + id: 'cc-resp-private-shared', + browser_skip: true, + spec_anchors: ['cache-response-directive.private'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'private, max-age=3600'] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal private HTTP cache reuses a fresh response with `Cache-Control: private`', + id: 'cc-resp-private-private', + browser_only: true, + kind: 'optimal', + spec_anchors: ['cache-response-directive.private'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'private, max-age=3600'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not store a response with `Cache-Control: no-store`', + id: 'cc-resp-no-store', + spec_anchors: ['cache-response-directive.no-store'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'no-store'] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not store a response with `Cache-Control: nO-StOrE`', + id: 'cc-resp-no-store-case-insensitive', + depends_on: ['cc-resp-no-store'], + spec_anchors: ['cache-response-directive.no-store'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'No-StOrE'] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not store a response with `Cache-Control: no-store`, even with `max-age` and `Expires`', + id: 'cc-resp-no-store-fresh', + depends_on: ['cc-resp-no-store'], + spec_anchors: ['cache-response-directive.no-store'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000, no-store'], + ['Expires', 10000], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does HTTP cache use older stored response when newer one came with `Cache-Control: no-store`?', + id: 'cc-resp-no-store-old-new', + depends_on: ['cc-resp-no-store'], + spec_anchors: ['cache-response-directive.no-store'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000'], + ['Expires', 10000], + ['Date', 0], + ['A', '1'] + ], + setup: true, + pause_after: true + }, + { + response_headers: [ + ['Cache-Control', 'no-store'], + ['Date', 0], + ['A', '2'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached', + expected_response_headers: [['a', '1']] + } + ] + }, + { + name: 'Does HTTP cache use older stored response when newer one came with `Cache-Control: no-store, max-age=0`?', + id: 'cc-resp-no-store-old-max-age', + depends_on: ['cc-resp-no-store'], + spec_anchors: ['cache-response-directive.no-store'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000'], + ['Expires', 10000], + ['Date', 0], + ['A', '1'] + ], + setup: true, + pause_after: true + }, + { + response_headers: [ + ['Cache-Control', 'no-store, max-age=0'], + ['Date', 0], + ['A', '2'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached', + expected_response_headers: [['a', '1']] + } + ] + }, + { + name: 'HTTP cache must not use a cached response with `Cache-Control: no-cache`, even with `max-age` and `Expires`', + id: 'cc-resp-no-cache', + spec_anchors: ['cache-response-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000, no-cache'], + ['Expires', 10000], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not use a cached response with `Cache-Control: No-CaChE`, even with `max-age` and `Expires`', + id: 'cc-resp-no-cache-case-insensitive', + depends_on: ['cc-resp-no-cache'], + spec_anchors: ['cache-response-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000, No-CaChE'], + ['Expires', 10000], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache stores a response with `Cache-Control: no-cache`, but revalidates it upon use', + id: 'cc-resp-no-cache-revalidate', + kind: 'optimal', + depends_on: ['cc-resp-no-cache'], + spec_anchors: ['cache-response-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'no-cache'], + ['ETag', '"abcd"'] + ], + setup: true + }, + { + expected_type: 'etag_validated' + } + ] + }, + { + name: 'An optimal HTTP cache stores a response with `Cache-Control: no-cache`, but revalidates it upon use, even with `max-age` and `Expires`', + id: 'cc-resp-no-cache-revalidate-fresh', + kind: 'optimal', + depends_on: ['cc-resp-no-cache'], + spec_anchors: ['cache-response-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000, no-cache'], + ['Expires', 10000], + ['Date', 0], + ['ETag', '"abcd"'] + ], + setup: true + }, + { + expected_type: 'etag_validated' + } + ] + }, + { + name: 'Does `Cache-Control: no-cache` inhibit storing a listed header?', + id: 'headers-omit-headers-listed-in-Cache-Control-no-cache-single', + kind: 'check', + depends_on: ['cc-resp-no-cache-revalidate'], + spec_anchors: ['cache-response-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'no-cache="a"'], + ['a', '1'], + ['b', '2'], + ['Cache-Control', 'max-age=3600'], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached', + expected_response_headers: [['b', '2']], + expected_response_headers_missing: ['a'], + setup_tests: ['expected_type'] + } + ] + }, + { + name: 'Does `Cache-Control: no-cache` inhibit storing multiple listed headers?', + id: 'headers-omit-headers-listed-in-Cache-Control-no-cache', + kind: 'check', + depends_on: ['cc-resp-no-cache-revalidate'], + spec_anchors: ['cache-response-directive.no-cache'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'no-cache="a, b"'], + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['Cache-Control', 'max-age=3600'], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached', + expected_response_headers: [['c', '3']], + expected_response_headers_missing: ['a', 'b'], + setup_tests: ['expected_type'] + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with positive `Cache-Control: max-age, must-revalidate`', + id: 'cc-resp-must-revalidate-fresh', + kind: 'optimal', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.must-revalidate'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000, must-revalidate'], + ['ETag', '"abcd"'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must revalidate a stale response with positive `Cache-Control: max-age, must-revalidate`', + id: 'cc-resp-must-revalidate-stale', + depends_on: ['freshness-none'], + spec_anchors: ['cache-response-directive.must-revalidate'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2, must-revalidate'], + ['ETag', '"abcd"'] + ], + setup: true + }, + { + expected_type: 'cached', + setup: true, + pause_after: true, + response_headers: [ + ['Cache-Control', 'max-age=2, must-revalidate'], + ['ETag', '"abcd"'] + ] + }, + { + expected_type: 'etag_validated' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a fresh response with `Cache-Control: immutable` without revalidation.', + id: 'cc-resp-immutable-fresh', + kind: 'optimal', + browser_only: true, + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000, immutable'], + ['ETag', '"abcd"'] + ], + setup: true, + pause_after: true + }, + { + cache: 'no-cache', + expected_type: 'cached' + } + ] + }, + { + name: 'A HTTP cache MUST revalidate a stale response with `Cache-Control: immutable`', + id: 'cc-resp-immutable-stale', + browser_only: true, + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2, immutable'], + ['ETag', '"abcd"'] + ], + setup: true, + pause_after: true + }, + { + cache: 'no-cache', + expected_type: 'etag_validated', + expected_request_headers: [['cache-control', 'max-age=0']] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/cdn-cache-control.mjs b/test/fixtures/cache-tests/tests/cdn-cache-control.mjs new file mode 100644 index 0000000..e7f0d13 --- /dev/null +++ b/test/fixtures/cache-tests/tests/cdn-cache-control.mjs @@ -0,0 +1,491 @@ +export default + +{ + name: 'CDN-Cache-Control', + id: 'cdn-cache-control', + description: 'These tests check non-browser caches for behaviours around the [`CDN-Cache-Control` response header](https://httpwg.org/specs/rfc9213.html).', + tests: [ + { + name: 'An optimal CDN reuses a response with positive `CDN-Cache-Control: max-age`', + id: 'cdn-max-age', + cdn_only: true, + depends_on: ['freshness-none'], + kind: 'optimal', + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal CDN reuses a response with `CDN-Cache-Control: max-age: 2147483648`', + id: 'cdn-max-age-max', + kind: 'optimal', + cdn_only: true, + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=2147483648', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal CDN reuses a response with `CDN-Cache-Control: max-age: 99999999999`', + id: 'cdn-max-age-max-plus', + kind: 'optimal', + cdn_only: true, + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=99999999999', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'CDN must not reuse a response when the `Age` header is greater than its `CDN-Cache-Control: max-age` freshness lifetime', + id: 'cdn-max-age-age', + cdn_only: true, + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['CDN-Cache-Control', 'max-age=3600', false], + ['Age', '7200'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does CDN ignore `CDN-Cache-Control: max-age` with space before the `=`?', + id: 'cdn-max-age-space-before-equals', + cdn_only: true, + kind: 'check', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1'], + ['CDN-Cache-Control', 'max-age =100', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does CDN ignore `CDN-Cache-Control: max-age` with space after the `=`?', + id: 'cdn-max-age-space-after-equals', + cdn_only: true, + kind: 'check', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1'], + ['CDN-Cache-Control', 'max-age= 100', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'CDN must not reuse a response with `CDN-Cache-Control: max-age=0`', + id: 'cdn-max-age-0', + cdn_only: true, + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=0', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal CDN reuses a response with a positive `CDN-Cache-Control: max-age` and an extension cache directive', + id: 'cdn-max-age-extension', + cdn_only: true, + kind: 'optimal', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'foobar, max-age=3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does CDN reuse a response with a positive `CDN-Cache-Control: MaX-aGe`?', + id: 'cdn-max-age-case-insensitive', + cdn_only: true, + kind: 'check', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'MaX-aGe=3600', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + + { + name: 'An optimal CDN reuses a response with a positive `CDN-Cache-Control: max-age` and a past `Expires`', + id: 'cdn-max-age-expires', + cdn_only: true, + kind: 'optimal', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=3600', false], + ['Expires', -10000], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal CDN reuses a response with a positive `CDN-Cache-Control: max-age` and an invalid `Expires`', + id: 'cdn-max-age-cc-max-age-invalid-expires', + cdn_only: true, + kind: 'optimal', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=3600', false], + ['Expires', '0', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'CDN must not reuse a response with a `CDN-Cache-Control: max-age=0` and a future `Expires`', + id: 'cdn-max-age-0-expires', + cdn_only: true, + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=0', false], + ['Expires', 10000], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal CDN prefers a long `CDN-Cache-Control: max-age` over a short `Cache-Control: max-age`', + id: 'cdn-max-age-short-cc-max-age', + cdn_only: true, + kind: 'optimal', + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1'], + ['CDN-Cache-Control', 'max-age=3600', false] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'CDN must prefer a short `CDN-Cache-Control: max-age` over a long `Cache-Control: max-age`', + id: 'cdn-max-age-long-cc-max-age', + cdn_only: true, + depends_on: ['cdn-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['CDN-Cache-Control', 'max-age=1', false] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'CDN must not reuse a cached response with `CDN-Cache-Control: private`, even with `Cache-Control: max-age` and `Expires`', + id: 'cdn-private', + cdn_only: true, + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'private'], + ['Cache-Control', 'max-age=10000'], + ['Expires', 10000], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'CDN must not reuse a cached response with `CDN-Cache-Control: no-cache`, even with `Cache-Control: max-age` and `Expires`', + id: 'cdn-no-cache', + cdn_only: true, + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'no-cache'], + ['Cache-Control', 'max-age=10000'], + ['Expires', 10000], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'CDN must not store a response with `CDN-Cache-Control: no-store`, even with `Cache-Control: max-age` and `Expires`', + id: 'cdn-no-store-cc-fresh', + cdn_only: true, + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=10000'], + ['CDN-Cache-Control', 'no-store', false], + ['Expires', 10000], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal CDN stores a response with a fresh `CDN-Cache-Control: max-age`, even with `Cache-Control: no-store`', + id: 'cdn-fresh-cc-nostore', + depends_on: ['freshness-none'], + cdn_only: true, + requests: [ + { + response_headers: [ + ['Cache-Control', 'no-store'], + ['CDN-Cache-Control', 'max-age=10000', false] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'CDN should ignore a `CDN-Cache-Control` that\'s an invalid Structured Field (unknown type)', + id: 'cdn-cc-invalid-sh-type-unknown', + depends_on: ['cdn-max-age'], + cdn_only: true, + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age=10000, &&&&&', false], + ['Cache-Control', 'no-store'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'CDN should ignore a `CDN-Cache-Control` that\'s an invalid Structured Field (wrong type)', + id: 'cdn-cc-invalid-sh-type-wrong', + depends_on: ['cdn-max-age'], + cdn_only: true, + requests: [ + { + response_headers: [ + ['CDN-Cache-Control', 'max-age="10000"', false], + ['Cache-Control', 'no-store'] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'Does the CDN forward the `CDN-Cache-Control` response header?', + id: 'cdn-remove-header', + cdn_only: true, + kind: 'check', + requests: [ + { + // only check for the header in expected_response_headers, so failing + // this is an assertion failure and not a setup error + response_headers: [ + ['Cache-Control', 'max-age=10000'], + ['CDN-Cache-Control', 'foo', false], + ['Expires', 10000], + ['Date', 0] + ], + expected_response_headers: [ + ['CDN-Cache-Control', 'foo'] + ] + } + ] + }, + { + name: 'Does the CDN send `Age` when `CDN-Cache-Control: max-age` exceeds `Cache-Control: max-age`?', + id: 'cdn-remove-age-exceed', + cdn_only: true, + depends_on: ['cdn-max-age'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1'], + ['CDN-Cache-Control', 'max-age=10000'], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_response_headers: [ + 'Age' + ] + } + ] + }, + { + name: 'Does the CDN preserve `Date` when `CDN-Cache-Control: max-age` exceeds `Cache-Control: max-age`?', + id: 'cdn-date-update-exceed', + cdn_only: true, + depends_on: ['cdn-max-age'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1'], + ['CDN-Cache-Control', 'max-age=10000'], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_response_headers: [ + ['Date', 0] + ] + } + ] + }, + { + name: 'Does the CDN preserve `Expires` when `CDN-Cache-Control: max-age` exceeds `Cache-Control: max-age`?', + id: 'cdn-expires-update-exceed', + cdn_only: true, + depends_on: ['cdn-max-age'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=1'], + ['Expires', 1], + ['CDN-Cache-Control', 'max-age=10000'], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_response_headers: [ + ['Expires', 1] + ] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/conditional-etag.mjs b/test/fixtures/cache-tests/tests/conditional-etag.mjs new file mode 100644 index 0000000..98d9177 --- /dev/null +++ b/test/fixtures/cache-tests/tests/conditional-etag.mjs @@ -0,0 +1,456 @@ +import * as templates from './lib/templates.mjs' + +export default { + name: 'Conditional Requests: If-None-Match and ETag', + id: 'conditional-inm', + description: 'These tests check handling of conditional requests using `If-None-Match` and `ETag`.', + spec_anchors: ['validation.model'], + tests: [ + { + name: 'An optimal HTTP cache responds to `If-None-Match` with a `304` when holding a fresh response with a matching strong `ETag`', + id: 'conditional-etag-strong-respond', + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache must include `ETag` in a `304 Not Modified`', + id: 'conditional-304-etag', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304, + expected_response_headers: [ + ['ETag', '"abcdef"'] + ] + } + ] + }, + { + name: 'HTTP cache must give precedence to `If-None-Match` over `If-Modified-Since`', + id: 'conditional-etag-precedence', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['Last-Modified', -5000], + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"abcdef"'], + ['If-Modified-Since', -1] + ], + magic_ims: true, + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'Does HTTP cache responds to `If-None-Match` with a `304` when holding a fresh response with a matching strong `ETag` containing obs-text?', + id: 'conditional-etag-strong-respond-obs-text', + kind: 'check', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdefü"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"abcdefü"'] + ], + expected_type: 'cached', + expected_status: 304, + expected_response_headers: [ + ['ETag', '"abcdefü"'] + ] + } + ] + }, + { + name: 'HTTP cache responds to unquoted `If-None-Match` with a `304` when holding a fresh response with a matching strong `ETag` that is quoted', + id: 'conditional-etag-quoted-respond-unquoted', + kind: 'check', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', 'abcdef'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache responds to unquoted `If-None-Match` with a `304` when holding a fresh response with a matching strong `ETag` that is unquoted', + id: 'conditional-etag-unquoted-respond-unquoted', + kind: 'check', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', 'abcdef'] + ] + }), + { + request_headers: [ + ['If-None-Match', 'abcdef'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache responds to quoted `If-None-Match` with a `304` when holding a fresh response with a matching strong `ETag` that is unquoted', + id: 'conditional-etag-unquoted-respond-quoted', + kind: 'check', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', 'abcdef'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-None-Match` with a `304` when holding a fresh response with a matching weak `ETag`', + id: 'conditional-etag-weak-respond', + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', 'W/"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', 'W/"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache responds to `If-None-Match` with a `304` when holding a fresh response with a matching weak `ETag`, and the entity-tag weakness flag is lowercase', + id: 'conditional-etag-weak-respond-lowercase', + kind: 'check', + depends_on: ['conditional-etag-weak-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', 'w/"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', 'w/"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache responds to `If-None-Match` with a `304` when holding a fresh response with a matching weak `ETag`, and the entity-tag weakness flag uses `\\` instead of `/`', + id: 'conditional-etag-weak-respond-backslash', + kind: 'check', + depends_on: ['conditional-etag-weak-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', 'W\\"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', 'W\\"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache responds to `If-None-Match` with a `304` when holding a fresh response with a matching weak `ETag`, and the entity-tag weakness flag omits `/`', + id: 'conditional-etag-weak-respond-omit-slash', + depends_on: ['conditional-etag-weak-respond'], + kind: 'check', + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', 'W"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', 'W"abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-None-Match` with a `304` when it contains multiple entity-tags (first one)', + id: 'conditional-etag-strong-respond-multiple-first', + kind: 'optimal', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"abcdef", "1234", "5678"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-None-Match` with a `304` when it contains multiple entity-tags (middle one)', + id: 'conditional-etag-strong-respond-multiple-second', + kind: 'optimal', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"1234", "abcdef", "5678"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-None-Match` with a `304` when it contains multiple entity-tags (last one)', + id: 'conditional-etag-strong-respond-multiple-last', + kind: 'optimal', + depends_on: ['conditional-etag-strong-respond'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + request_headers: [ + ['If-None-Match', '"1234", "5678", "abcdef"'] + ], + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'HTTP cache must include stored response headers identified by `Vary` in a conditional request it generates', + id: 'conditional-etag-vary-headers', + requests: [ + { + request_headers: [ + ['Abc', '123'] + ], + response_headers: [ + ['Expires', 1], + ['ETag', '"abcdef"'], + ['Date', 0], + ['Vary', 'Abc'] + ], + setup: true, + pause_after: true + }, + { + request_headers: [ + ['Abc', '123'] + ], + expected_type: 'etag_validated', + expected_request_headers: [ + ['Abc', '123'] + ], + setup_tests: ['expected_type'] + } + ] + }, + { + name: 'HTTP cache must not use a stored `ETag` to validate when the presented `Vary`ing request header differs', + id: 'conditional-etag-vary-headers-mismatch', + depends_on: ['conditional-etag-vary-headers', 'vary-no-match'], + requests: [ + { + request_headers: [ + ['Abc', '123'] + ], + response_headers: [ + ['Expires', 10000], + ['ETag', '"abcdef"'], + ['Date', 0], + ['Vary', 'Abc'] + ], + setup: true, + pause_after: true + }, + { + request_headers: [ + ['Abc', '456'] + ], + expected_request_headers_missing: [ + ['If-None-Match', '"abcdef"'] + ] + } + ] + }, + { + name: 'An optimal HTTP cache generates a `If-None-Match` request when holding a stale response with a matching strong `ETag`', + id: 'conditional-etag-strong-generate', + kind: 'optimal', + depends_on: ['freshness-max-age-stale'], + requests: [ + templates.becomeStale({ + response_headers: [ + ['ETag', '"abcdef"'] + ] + }), + { + expected_request_headers: [ + ['If-None-Match', '"abcdef"'] + ], + expected_type: 'etag_validated' + } + ] + }, + { + name: 'An optimal HTTP cache generates a `If-None-Match` request when holding a stale response with a matching weak `ETag`', + id: 'conditional-etag-weak-generate-weak', + kind: 'optimal', + depends_on: ['freshness-max-age-stale'], + requests: [ + templates.becomeStale({ + response_headers: [ + ['ETag', 'W/"abcdef"'] + ] + }), + { + expected_request_headers: [ + ['If-None-Match', 'W/"abcdef"'] + ], + expected_type: 'etag_validated' + } + ] + }, + { + name: 'Does HTTP cache generate a quoted `If-None-Match` request when holding a stale response with a matching, unquoted strong `ETag`?', + id: 'conditional-etag-strong-generate-unquoted', + kind: 'check', + depends_on: ['conditional-etag-strong-generate'], + requests: [ + templates.becomeStale({ + response_headers: [ + ['ETag', 'abcdef'] + ] + }), + { + expected_request_headers: [ + ['If-None-Match', '"abcdef"'] + ], + expected_type: 'etag_validated' + } + ] + }, + { + name: 'Does HTTP cache forward `If-None-Match` request header when no stored response is available?', + id: 'conditional-etag-forward', + kind: 'check', + requests: [ + { + request_headers: [ + ['If-None-Match', '"abcdef"'] + ], + expected_request_headers: [ + ['If-None-Match', '"abcdef"'] + ] + } + ] + }, + { + name: 'Does HTTP cache add quotes to an unquoted `If-None-Match` request when forwarding it?', + id: 'conditional-etag-forward-unquoted', + depends_on: ['conditional-etag-forward'], + kind: 'check', + requests: [ + { + request_headers: [ + ['If-None-Match', 'abcdef'] + ], + expected_request_headers: [ + ['If-None-Match', '"abcdef"'] + ] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/conditional-lm.mjs b/test/fixtures/cache-tests/tests/conditional-lm.mjs new file mode 100644 index 0000000..eae5c2b --- /dev/null +++ b/test/fixtures/cache-tests/tests/conditional-lm.mjs @@ -0,0 +1,119 @@ +import * as templates from './lib/templates.mjs' + +export default { + name: 'Conditional Requests: If-Modified-Since and Last-Modified', + id: 'conditional-lm', + description: 'These tests check handling of conditional requests using `If-Modified-Since` and `Last-Modified`.', + spec_anchors: ['validation.model'], + tests: [ + { + name: 'An optimal HTTP cache responds to `If-Modified-Since` with a `304` when holding a fresh response with a matching `Last-Modified`', + id: 'conditional-lm-fresh', + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['Last-Modified', -3000] + ] + }), + { + request_headers: [ + ['If-Modified-Since', -3000] + ], + magic_ims: true, + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-Modified-Since` with a `304` when holding a fresh response with an earlier `Last-Modified`', + id: 'conditional-lm-fresh-earlier', + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['Last-Modified', -3000] + ] + }), + { + request_headers: [ + ['If-Modified-Since', -2000] + ], + magic_ims: true, + expected_type: 'cached', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-Modified-Since` with a `304` when holding a stale response with a matching `Last-Modified`, after validation', + id: 'conditional-lm-stale', + kind: 'optimal', + depends_on: ['freshness-max-age-stale'], + browser_skip: true, + requests: [ + templates.becomeStale({ + response_headers: [ + ['Last-Modified', -3000] + ] + }), + { + request_headers: [ + ['If-Modified-Since', -3000] + ], + magic_ims: true, + expected_type: 'lm_validated', + expected_status: 304 + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-Modified-Since` with a `304` when holding a newer fresh response with no `Last-Modified`', + id: 'conditional-lm-fresh-no-lm', + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({}), + { + request_headers: [ + ['If-Modified-Since', -3000] + ], + magic_ims: true, + expected_type: 'cached', + expected_status: 304, + setup_tests: ['expected_type'] + } + ] + }, + { + name: 'An optimal HTTP cache responds to `If-Modified-Since` with a `304` when holding a newer fresh response when IMS uses an equivalent rfc850 date', + id: 'conditional-lm-fresh-rfc850', + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: true, + requests: [ + templates.fresh({ + response_headers: [ + ['Last-Modified', -3000] + ] + }), + { + request_headers: [ + ['If-Modified-Since', -3000] + ], + magic_ims: true, + rfc850date: ['if-modified-since'], + expected_type: 'cached', + expected_status: 304, + setup_tests: ['expected_type'] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/expires-freshness.mjs b/test/fixtures/cache-tests/tests/expires-freshness.mjs new file mode 100644 index 0000000..c0f6f9f --- /dev/null +++ b/test/fixtures/cache-tests/tests/expires-freshness.mjs @@ -0,0 +1,154 @@ +export default + +{ + name: 'Expires Freshness', + id: 'expires', + description: 'These tests check how caches calculate freshness using `Expires`.', + spec_anchors: ['expiration.model', 'field.expires'], + tests: [ + { + name: 'An optimal HTTP cache reuses a response with a future `Expires`', + id: 'freshness-expires-future', + kind: 'optimal', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Expires', 30 * 24 * 60 * 60], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with a past `Expires`', + id: 'freshness-expires-past', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', -30 * 24 * 60 * 60], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with a present `Expires`', + id: 'freshness-expires-present', + depends_on: ['freshness-none'], + requests: [ + { + response_headers: [ + ['Expires', 0], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an `Expires` older than `Date`, both fast', + id: 'freshness-expires-old-date', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 300], + ['Date', 400] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (0)', + id: 'freshness-expires-invalid', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', '0', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with `Expires`, even if `Date` is invalid', + id: 'freshness-expires-invalid-date', + depends_on: ['freshness-expires-future'], + kind: 'optimal', + requests: [ + { + response_headers: [ + ['Date', 'foo', false], + ['Expires', 10] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response when the `Age` header is greater than its `Expires` minus `Date`, and `Date` is slow', + id: 'freshness-expires-age-slow-date', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Date', -10], + ['Expires', 10], + ['Age', '25'] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response when the `Age` header is greater than its `Expires` minus `Date`, and `Date` is fast', + id: 'freshness-expires-age-fast-date', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Date', 10], + ['Expires', 20], + ['Age', '15'] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/expires-parse.mjs b/test/fixtures/cache-tests/tests/expires-parse.mjs new file mode 100644 index 0000000..74c93b5 --- /dev/null +++ b/test/fixtures/cache-tests/tests/expires-parse.mjs @@ -0,0 +1,301 @@ +export default + +{ + name: 'Expires Parsing', + id: 'expires-parse', + description: 'These tests check how caches parse the `Expires` response header.', + spec_anchors: ['field.expires'], + tests: [ + { + name: 'An optimal HTTP cache reuses a response with an `Expires` that is exactly 32 bits', + id: 'freshness-expires-32bit', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Tue, 19 Jan 2038 14:14:08 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with an `Expires` that is far in the future', + id: 'freshness-expires-far-future', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Sun, 21 Nov 2286 04:46:39 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with a future `Expires` in obsolete RFC 850 format', + id: 'freshness-expires-rfc850', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thursday, 18-Aug-50 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with a future `Expires` in ANSI C\'s asctime() format', + id: 'freshness-expires-ansi-c', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu Aug 8 02:01:18 2050', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with a future `Expires` using wrong case (weekday)', + id: 'freshness-expires-wrong-case-weekday', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'THU, 18 Aug 2050 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with a future `Expires` using wrong case (month)', + id: 'freshness-expires-wrong-case-month', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 AUG 2050 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a response with a future `Expires` using wrong case (tz)', + id: 'freshness-expires-wrong-case-tz', + kind: 'optimal', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 02:01:18 gMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (UTC)', + id: 'freshness-expires-invalid-utc', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 02:01:18 UTC', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (other tz)', + id: 'freshness-expires-invalid-aest', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 02:01:18 AEST', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (two-digit year)', + id: 'freshness-expires-invalid-2-digit-year', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 50 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (missing comma)', + id: 'freshness-expires-invalid-no-comma', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu 18 Aug 2050 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (multiple spaces)', + id: 'freshness-expires-invalid-multiple-spaces', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (date dashes)', + id: 'freshness-expires-invalid-date-dashes', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18-Aug-2050 02:01:18 GMT', false], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (time periods)', + id: 'freshness-expires-invalid-time-periods', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 02.01.18 GMT', false], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (1-digit hour)', + id: 'freshness-expires-invalid-1-digit-hour', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 2:01:18 GMT', false], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse a response with an invalid `Expires` (multiple lines)', + id: 'freshness-expires-invalid-multiple-lines', + depends_on: ['freshness-expires-future'], + requests: [ + { + response_headers: [ + ['Expires', 'Thu, 18 Aug 2050 2:01:18 GMT', false], + ['Expires', 'Thu, 18 Aug 2050 2:01:19 GMT', false], + ['Date', 0] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/headers.mjs b/test/fixtures/cache-tests/tests/headers.mjs new file mode 100644 index 0000000..e27aee2 --- /dev/null +++ b/test/fixtures/cache-tests/tests/headers.mjs @@ -0,0 +1,77 @@ +import * as templates from './lib/templates.mjs' +import * as utils from './lib/utils.mjs' +import headerList from './lib/header-list.mjs' + +const tests = [] + +tests.push({ + name: '`Connection` header must inhibit a HTTP cache from storing listed headers', + id: 'headers-omit-headers-listed-in-Connection', + kind: 'required', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({ + response_headers: [ + ['Connection', 'a, b', false], + ['a', '1', false], + ['b', '2', false], + ['c', '3', false] + ] + }), + { + expected_type: 'cached', + expected_response_headers: [['c', '3']], + expected_response_headers_missing: ['a', 'b'], + setup_tests: ['expected_type', 'expected_response_headers'] + } + ] +}) + +function checkStoreHeader (config) { + const id = `store-${config.name}` + const value = 'valB' in config ? config.valB : utils.httpContent(`${config.name}-store-value`) + const storeHeader = 'noStore' in config ? !config.noStore : true + const requirement = storeHeader ? 'must' : 'must not' + const expectedHeaders = storeHeader ? [[config.name, value]] : [] + const unexpectedHeaders = storeHeader ? [] : [[config.name, value]] + + const respHeaders = [ + ['Date', 0], + [config.name, value, storeHeader] + ] + if (config.name !== 'Cache-Control') { + respHeaders.push(['Cache-Control', 'max-age=3600']) + } + + tests.push({ + name: `HTTP cache ${requirement} store \`${config.name}\` header field`, + id: `headers-${id}`, + kind: 'required', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: respHeaders, + setup: true, + pause_after: true, + check_body: 'checkBody' in config ? config.checkBody : true + }, + { + expected_type: 'cached', + expected_response_headers: expectedHeaders, + expected_response_headers_missing: unexpectedHeaders, + setup_tests: ['expected_type'], + check_body: 'checkBody' in config ? config.checkBody : true + } + ] + }) +} + +headerList.forEach(checkStoreHeader) + +export default { + name: 'Storing Header Fields', + id: 'headers', + description: 'These tests examine how caches store headers in responses.', + spec_anchors: ['storing.fields'], + tests +} diff --git a/test/fixtures/cache-tests/tests/heuristic-freshness.mjs b/test/fixtures/cache-tests/tests/heuristic-freshness.mjs new file mode 100644 index 0000000..9b0c360 --- /dev/null +++ b/test/fixtures/cache-tests/tests/heuristic-freshness.mjs @@ -0,0 +1,95 @@ +import * as utils from './lib/utils.mjs' + +const tests = [] + +function checkStatus (status) { + const succeed = status[0] + const code = status[1] + const phrase = status[2] + let body = status[3] + if (body === undefined) { + body = utils.httpContent(code) + } + const extra = status[4] || '' + const extraHdr = status[5] + const specAnchors = status[6] || [] + let expectedType = 'not_cached' + let desired = 'HTTP cache must not reuse' + if (succeed === true) { + expectedType = 'cached' + desired = 'An optimal HTTP cache should reuse' + } + const responseHeaders = [ + ['Last-Modified', -24 * 60 * 60], + ['Date', 0] + ] + if (extraHdr) { + responseHeaders.push(extraHdr) + } + tests.push({ + name: `${desired} a \`${code} ${phrase}\` response with \`Last-Modified\` based upon heuristic freshness ${extra}`, + id: `heuristic-${code}-${expectedType}`, + kind: succeed ? 'optimal' : 'required', + spec_anchors: specAnchors, + requests: [{ + response_status: [code, phrase], + response_headers: responseHeaders, + response_body: body, + setup: true + }, { + expected_type: expectedType, + response_status: [code, phrase], + response_body: body + }] + }) +} + +[ + [true, 200, 'OK'], + [false, 201, 'Created'], + [false, 202, 'Accepted'], + [true, 203, 'Non-Authoritative Information'], + [true, 204, 'No Content', null], + [false, 403, 'Forbidden'], + [true, 404, 'Not Found'], + [true, 405, 'Method Not Allowed'], + [true, 410, 'Gone'], + [true, 414, 'URI Too Long'], + [true, 501, 'Not Implemented'], + [false, 502, 'Bad Gateway'], + [false, 503, 'Service Unavailable'], + [false, 504, 'Gateway Timeout'], + [false, 599, 'Unknown', undefined, 'when `Cache-Control: public` is not present', undefined, ['cache-response-directive.public']], + [true, 599, 'Unknown', undefined, 'when `Cache-Control: public` is present', ['Cache-Control', 'public'], ['cache-response-directive.public']] +].forEach(checkStatus) + +function checkHeuristic (delta) { + tests.push({ + name: `Does HTTP cache consider a \`Last-Modified\` ${delta} seconds ago heuristically fresh?`, + id: `heuristic-delta-${delta}`, + kind: 'check', + requests: [{ + response_headers: [ + ['Last-Modified', -delta], + ['Date', 0] + ], + setup: true, + pause_after: true + }, + { + expected_type: 'cached' + }] + }) +} + +[ + 5, 10, 30, 60, 300, 600, 1200, 1800, 3600, 3600 * 12, 3600 * 24 +].forEach(checkHeuristic) + +export default { + name: 'Heuristic Freshness', + id: 'heuristic', + description: 'These tests check how caches handle heuristic freshness.', + spec_anchors: ['heuristic.freshness'], + tests +} diff --git a/test/fixtures/cache-tests/tests/index.mjs b/test/fixtures/cache-tests/tests/index.mjs new file mode 100644 index 0000000..3b7378c --- /dev/null +++ b/test/fixtures/cache-tests/tests/index.mjs @@ -0,0 +1,26 @@ +import ccParse from './cc-parse.mjs' +import ccRequest from './cc-request.mjs' +import ccResponse from './cc-response.mjs' +import ccFreshness from './cc-freshness.mjs' +import ageParse from './age-parse.mjs' +import pragma from './pragma.mjs' +import expiresParse from './expires-parse.mjs' +import expires from './expires-freshness.mjs' +import stale from './stale.mjs' +import heuristic from './heuristic-freshness.mjs' +import methods from './method.mjs' +import statuses from './status.mjs' +import vary from './vary.mjs' +import varyParse from './vary-parse.mjs' +import conditionalLm from './conditional-lm.mjs' +import conditionalEtag from './conditional-etag.mjs' +import headers from './headers.mjs' +import update304 from './update304.mjs' +import updateHead from './updateHead.mjs' +import invalidation from './invalidation.mjs' +import partial from './partial.mjs' +import auth from './authorization.mjs' +import other from './other.mjs' +import cdncc from './cdn-cache-control.mjs' + +export default [ccFreshness, ccParse, ageParse, expires, expiresParse, ccResponse, stale, heuristic, methods, statuses, ccRequest, pragma, vary, varyParse, conditionalLm, conditionalEtag, headers, update304, updateHead, invalidation, partial, auth, other, cdncc] diff --git a/test/fixtures/cache-tests/tests/invalidation.mjs b/test/fixtures/cache-tests/tests/invalidation.mjs new file mode 100644 index 0000000..772e41a --- /dev/null +++ b/test/fixtures/cache-tests/tests/invalidation.mjs @@ -0,0 +1,121 @@ +import { makeTemplate, fresh } from './lib/templates.mjs' + +const contentLocation = makeTemplate({ + filename: 'content_location_target', + response_headers: [ + ['Cache-Control', 'max-age=100000'], + ['Last-Modified', 0], + ['Date', 0] + ] +}) + +const location = makeTemplate({ + filename: 'location_target', + response_headers: [ + ['Cache-Control', 'max-age=100000'], + ['Last-Modified', 0], + ['Date', 0] + ] +}) + +const lclResponse = makeTemplate({ + response_headers: [ + ['Location', 'location_target'], + ['Content-Location', 'content_location_target'] + ], + magic_locations: true +}) + +const tests = [] + +function checkInvalidation (method) { + tests.push({ + name: `HTTP cache must invalidate the URL after a successful response to a \`${method}\` request`, + id: `invalidate-${method}`, + depends_on: ['freshness-max-age'], + requests: [ + fresh({}), { + request_method: method, + request_body: 'abc', + setup: true + }, { + expected_type: 'not_cached' + } + ] + }) + tests.push({ + name: `An optimal HTTP cache does not invalidate the URL after a failed response to a \`${method}\` request`, + id: `invalidate-${method}-failed`, + kind: 'optimal', + depends_on: [`invalidate-${method}`], + requests: [ + fresh({}), { + request_method: method, + request_body: 'abc', + response_status: [500, 'Internal Server Error'], + setup: true + }, { + expected_type: 'cached' + } + ] + }) +} + +function checkLocationInvalidation (method) { + tests.push({ + name: `Does HTTP cache invalidate \`Location\` URL after a successful response to a \`${method}\` request?`, + id: `invalidate-${method}-location`, + kind: 'check', + depends_on: [`invalidate-${method}`], + requests: [ + location({ + setup: true + }), lclResponse({ + request_method: 'POST', + request_body: 'abc', + setup: true + }), location({ + expected_type: 'not_cached' + }) + ] + }) +} + +function checkClInvalidation (method) { + tests.push({ + name: `Does HTTP cache must invalidate \`Content-Location\` URL after a successful response to a \`${method}\` request?`, + id: `invalidate-${method}-cl`, + kind: 'check', + depends_on: [`invalidate-${method}`], + requests: [ + contentLocation({ + setup: true + }), lclResponse({ + request_method: method, + request_body: 'abc', + setup: true + }), contentLocation({ + expected_type: 'not_cached' + }) + ] + }) +} + +const methods = [ + 'POST', + 'PUT', + 'DELETE', + 'M-SEARCH' +] + +methods.forEach(checkInvalidation) +methods.forEach(checkLocationInvalidation) +methods.forEach(checkClInvalidation) + +export default { + name: 'Cache Invalidation', + id: 'invalidation', + description: 'These tests check how caches support invalidation, including when it is triggered by the `Location` and `Content-Location` response headers.', + spec_anchors: ['invalidation'], + tests +} diff --git a/test/fixtures/cache-tests/tests/lib/header-list.mjs b/test/fixtures/cache-tests/tests/lib/header-list.mjs new file mode 100644 index 0000000..dcdf3a2 --- /dev/null +++ b/test/fixtures/cache-tests/tests/lib/header-list.mjs @@ -0,0 +1,135 @@ +export default [ + { + name: 'Test-Header', + reqUpdate: true + }, + { + name: 'X-Test-Header', + reqUpdate: true + }, + { + name: 'Content-Foo', + reqUpdate: true + }, + { + name: 'X-Content-Foo', + reqUpdate: true + }, + { + name: 'Cache-Control', + valA: 'max-age=1', + valB: 'max-age=3600', + reqUpdate: true + }, + { + name: 'Connection', + noStore: true + }, + { + name: 'Content-Encoding' + }, + { + name: 'Content-Length', + valA: '36', + valB: '10', + noUpdate: true, + checkBody: false + }, + { + name: 'Content-Location', + valA: '/foo', + valB: '/bar' + }, + { + name: 'Content-MD5', + valA: 'rL0Y20zC+Fzt72VPzMSk2A==', + valB: 'N7UdGUp1E+RbVvZSTy1R8g==' + }, + { + name: 'Content-Range' + }, + { + name: 'Content-Security-Policy', + valA: 'default-src \'self\'', + valB: 'default-src \'self\' cdn.example.com' + }, + { + name: 'Content-Type', + valA: 'text/plain', + valB: 'text/plain;charset=utf-8' + }, + { + name: 'Clear-Site-Data', + valA: 'cache', + valB: 'cookies' + }, + { + name: 'ETag', + valA: '"abcdef"', + valB: '"ghijkl"' + }, + { + name: 'Expires', + valA: 'Fri, 01 Jan 2038 01:01:01 GMT', + valB: 'Mon, 11 Jan 2038 11:11:11 GMT' + }, + { + name: 'Keep-Alive', + noStore: true + }, + { + name: 'Proxy-Authenticate', + noStore: true + }, + { + name: 'Proxy-Authentication-Info', + noStore: true + }, + { + name: 'Proxy-Authorization', + noStore: true + }, + { + name: 'Proxy-Connection', + noStore: true + }, + { + name: 'Public-Key-Pins' + }, + { + name: 'Set-Cookie', + valA: 'a=b', + valB: 'a=c' + }, + { + name: 'Set-Cookie2', + valA: 'a=b', + valB: 'a=c' + }, + { + name: 'TE', + noStore: true + }, + // { + // name: 'Trailer', + // noStore: true + // }, + { + name: 'Transfer-Encoding', + noStore: true + }, + { + name: 'Upgrade', + noStore: true + }, + { + name: 'X-Frame-Options', + valA: 'deny', + valB: 'sameorigin' + }, + { + name: 'X-XSS-Protection', + valA: '1', + valB: '1; mode=block' + } +] diff --git a/test/fixtures/cache-tests/tests/lib/templates.mjs b/test/fixtures/cache-tests/tests/lib/templates.mjs new file mode 100644 index 0000000..56e87e2 --- /dev/null +++ b/test/fixtures/cache-tests/tests/lib/templates.mjs @@ -0,0 +1,74 @@ +/* +makeTemplate(template) + +templates take an optional request object; the template +will be updated with the request object in the following manner: + +- Object members will be assigned from the request +- Array members will be concatonated from the request +- Other members will be updated from the request +*/ +export function makeTemplate (template) { + return function (request) { + return mergeDeep({}, template, request) + } +} + +function isObject (item) { + return (item && typeof item === 'object' && !Array.isArray(item)) +} + +function mergeDeep (target, ...sources) { + if (!sources.length) return target + const source = sources.shift() + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + if (!target[key]) Object.assign(target, { [key]: {} }) + mergeDeep(target[key], source[key]) + } else if (Array.isArray(source[key])) { + if (!target[key]) Object.assign(target, { [key]: [] }) + Object.assign(target, { [key]: target[key].concat(source[key]) }) + } else { + Object.assign(target, { [key]: source[key] }) + } + } + } + + return mergeDeep(target, ...sources) +} + +/* + Templates below are shared between multiple suites; + suite-specific tests should go in that file. +*/ + +export const fresh = makeTemplate({ + response_headers: [ + ['Cache-Control', 'max-age=100000'], + ['Date', 0] + ], + setup: true, + pause_after: true +}) + +export const stale = makeTemplate({ + response_headers: [ + ['Expires', -5000], + ['Last-Modified', -100000], + ['Date', 0] + ], + setup: true, + pause_after: true +}) + +export const becomeStale = makeTemplate({ + response_headers: [ + ['Cache-Control', 'max-age=2'], + ['Date', 0], + ['Template-A', '1'] + ], + setup: true, + pause_after: true +}) diff --git a/test/fixtures/cache-tests/tests/lib/utils.mjs b/test/fixtures/cache-tests/tests/lib/utils.mjs new file mode 100644 index 0000000..affe7a3 --- /dev/null +++ b/test/fixtures/cache-tests/tests/lib/utils.mjs @@ -0,0 +1,20 @@ +const contentSeed = 1 +const contentStore = {} +export function httpContent (csKey, contentLength = 15) { + if (csKey in contentStore) { + return contentStore[csKey] + } else { + let keySeed = 0 + for (let i = 0; i < csKey.length; i++) { + keySeed += csKey.charCodeAt(i) + } + const contents = [] + for (let i = 0; i < contentLength; ++i) { + const idx = ((i * keySeed * contentSeed) % 26) + 97 + contents.push(String.fromCharCode(idx)) + } + const content = contents.join('') + contentStore[csKey] = content + return content + } +} diff --git a/test/fixtures/cache-tests/tests/method.mjs b/test/fixtures/cache-tests/tests/method.mjs new file mode 100644 index 0000000..31b2409 --- /dev/null +++ b/test/fixtures/cache-tests/tests/method.mjs @@ -0,0 +1,35 @@ +export default + +{ + name: 'Method-related Caching Requirements', + id: 'method', + description: 'These tests check how caches handle different HTTP methods.', + spec_anchors: ['response.cacheability'], + tests: [ + { + name: 'An optimal HTTP cache reuses a stored `POST` response (that has `Content-Location` with the same URL and explicit freshness) for subsequent `GET` requests', + id: 'method-POST', + kind: 'optimal', + requests: [ + { + request_method: 'POST', + request_body: '12345', + request_headers: [ + ['Content-Type', 'text/plain'] + ], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Content-Location', ''], + ['Date', 0] + ], + magic_locations: true, + pause_after: true, + setup: true + }, + { + expected_type: 'cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/other.mjs b/test/fixtures/cache-tests/tests/other.mjs new file mode 100644 index 0000000..6e84642 --- /dev/null +++ b/test/fixtures/cache-tests/tests/other.mjs @@ -0,0 +1,239 @@ +import * as templates from './lib/templates.mjs' +import * as utils from './lib/utils.mjs' + +export default + +{ + name: 'Other Caching Requirements', + id: 'other', + description: 'These tests check miscellaneous HTTP cache behaviours. ', + tests: [ + { + name: 'HTTP cache must generate an `Age` header field when using a stored response.', + id: 'other-age-gen', + depends_on: ['freshness-max-age'], + spec_anchors: ['field.age', 'constructing.responses.from.caches'], + requests: [ + templates.fresh({}), + { + expected_type: 'cached', + expected_response_headers: [ + ['Age', '>', 2] + ] + } + ] + }, + { + name: 'Does HTTP cache insert an `Age` header field when there is delay generating the response?', + id: 'other-age-delay', + kind: 'check', + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Date', 0] + ], + response_pause: 5, + expected_response_headers: [['age', '>', 0]] + } + ] + }, + { + name: 'HTTP cache must update the `Age` header field when freshness is based upon `Expires`', + id: 'other-age-update-expires', + depends_on: ['freshness-expires-future'], + spec_anchors: ['constructing.responses.from.caches', 'field.age'], + requests: [ + { + response_headers: [ + ['Expires', 30 * 24 * 60 * 60], + ['Date', 0], + ['Age', '30'] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached', + expected_response_headers: [ + ['Age', '>', 32] + ] + } + ] + }, + { + name: 'HTTP cache must update the `Age` header field when freshness is based upon `CC: max-age`', + id: 'other-age-update-max-age', + depends_on: ['freshness-max-age'], + spec_anchors: ['constructing.responses.from.caches', 'field.age'], + requests: [ + templates.fresh({ + response_headers: [ + ['Age', '30'] + ] + }), + { + expected_type: 'cached', + expected_response_headers: [ + ['Age', '>', 32] + ] + } + ] + }, + { + name: 'HTTP cache must not update the `Date` header field', + id: 'other-date-update', + depends_on: ['freshness-max-age'], + spec_anchors: ['field.date'], + requests: [ + templates.fresh({}), + { + expected_type: 'cached', + expected_response_headers: [ + ['Date', 0] + ] + } + ] + }, + { + name: 'HTTP cache must not update the `Date` header field when `Expires` is present', + id: 'other-date-update-expires', + depends_on: ['freshness-expires-future'], + spec_anchors: ['field.date'], + requests: [ + { + response_headers: [ + ['Expires', 30 * 24 * 60 * 60], + ['Date', 0] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached', + expected_response_headers: [ + ['Date', 0] + ] + } + ] + }, + { + name: 'Does HTTP cache leave the `Expires` header field alone?', + id: 'other-date-update-expires-update', + kind: 'check', + depends_on: ['freshness-expires-future'], + spec_anchors: ['field.date'], + requests: [ + { + response_headers: [ + ['Expires', 30 * 24 * 60 * 60], + ['Date', 0] + ], + pause_after: true, + setup: true + }, + { + expected_type: 'cached', + expected_response_headers: [ + ['Expires', 30 * 24 * 60 * 60] + ] + } + ] + }, + { + name: 'Different query arguments must be different cache keys', + id: 'query-args-different', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({ + query_arg: 'test=' + utils.httpContent('query-args-different-1') + }), + { + query_arg: 'test=' + utils.httpContent('query-args-different-2'), + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache should not be affected by the presence of a URL query', + id: 'query-args-same', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({ + query_arg: 'test=' + utils.httpContent('query-args-same') + }), + { + query_arg: 'test=' + utils.httpContent('query-args-same'), + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP heuristically cache a response with a `Content-Disposition: attachment` header?', + id: 'other-heuristic-content-disposition-attachment', + kind: 'check', + depends_on: ['heuristic-200-cached'], + requests: [ + { + response_headers: [ + ['Last-Modified', -100000], + ['Date', 0], + ['Content-Disposition', 'attachment; filename=example.txt'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP reuse a fresh response with a `Content-Disposition: attachment` header?', + id: 'other-fresh-content-disposition-attachment', + kind: 'check', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({ + response_headers: [ + ['Content-Disposition', 'attachment; filename=example.txt'] + ] + }), + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a fresh response with a `Set-Cookie` header', + id: 'other-set-cookie', + depends_on: ['freshness-max-age'], + kind: 'optimal', + requests: [ + templates.fresh({ + response_headers: [ + ['Set-Cookie', 'a=b'] + ] + }), + { + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a fresh response when the request has a `Cookie` header', + id: 'other-cookie', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({}), + { + request_headers: [ + ['Cookie', 'a=b'] + ], + expected_type: 'cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/partial.mjs b/test/fixtures/cache-tests/tests/partial.mjs new file mode 100644 index 0000000..5b3f4f5 --- /dev/null +++ b/test/fixtures/cache-tests/tests/partial.mjs @@ -0,0 +1,271 @@ +export default { + name: 'Combining Partial Content', + id: 'partial', + description: 'These tests check how caches handle partial content (also known as `Range` requests).', + spec_anchors: ['combining.responses'], + tests: [ + { + name: 'An optimal HTTP cache stores partial content and reuses it', + id: 'partial-store-partial-reuse-partial', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + request_headers: [ + ['Range', 'bytes=-5'] + ], + response_status: [206, 'Partial Content'], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Content-Range', 'bytes 4-9/10'] + ], + response_body: '01234', + expected_request_headers: [ + ['Range', 'bytes=-5'] + ], + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=-5'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '01234' + } + ] + }, + { + name: 'An optimal HTTP cache stores complete responses and serves smaller ranges from them (byte-range-spec)', + id: 'partial-store-complete-reuse-partial', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'] + ], + response_body: '01234567890', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=0-1'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '01' + } + ] + }, + { + name: 'An optimal HTTP cache stores complete responses and serves smaller ranges from them (absent last-byte-pos)', + id: 'partial-store-complete-reuse-partial-no-last', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'] + ], + response_body: '01234567890', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=1-'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '1234567890' + } + ] + }, + { + name: 'An optimal HTTP cache stores complete responses and serves smaller ranges from them (suffix-byte-range-spec)', + id: 'partial-store-complete-reuse-partial-suffix', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'] + ], + response_body: '0123456789A', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=-1'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: 'A' + } + ] + }, + { + name: 'An optimal HTTP cache stores partial responses and serves smaller ranges from them (byte-range-spec)', + id: 'partial-store-partial-reuse-partial-byterange', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + request_headers: [ + ['Range', 'bytes=-5'] + ], + response_status: [206, 'Partial Content'], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Content-Range', 'bytes 4-9/10'] + ], + response_body: '01234', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=6-8'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '234' + } + ] + }, + { + name: 'An optimal HTTP cache stores partial responses and serves smaller ranges from them (absent last-byte-pos)', + id: 'partial-store-partial-reuse-partial-absent', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + request_headers: [ + ['Range', 'bytes=-5'] + ], + response_status: [206, 'Partial Content'], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Content-Range', 'bytes 4-9/10'] + ], + response_body: '01234', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=6-'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '234' + } + ] + }, + { + name: 'An optimal HTTP cache stores partial responses and serves smaller ranges from them (suffix-byte-range-spec)', + id: 'partial-store-partial-reuse-partial-suffix', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + request_headers: [ + ['Range', 'bytes=-5'] + ], + response_status: [206, 'Partial Content'], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Content-Range', 'bytes 4-9/10'] + ], + response_body: '01234', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=-1'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '4' + } + ] + }, + { + name: 'An optimal HTTP cache stores partial content and completes it', + id: 'partial-store-partial-complete', + kind: 'optimal', + depends_on: ['freshness-max-age'], + requests: [ + { + request_headers: [ + ['Range', 'bytes=-5'] + ], + response_status: [206, 'Partial Content'], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Content-Range', 'bytes 0-4/10'] + ], + response_body: '01234', + setup: true + }, + { + expected_request_headers: [ + ['range', 'bytes=5-'] + ] + } + ] + }, + { + name: 'HTTP cache must use header fields from the new response', + id: 'partial-use-headers', + depends_on: ['partial-store-complete-reuse-partial'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['A', '1'] + ], + response_body: '01234567890', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=0-1'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '01', + setup_tests: ['expected_type', 'expected_status', 'expected_response_text'], + response_headers: [ + ['A', '2'] + ] + } + ] + }, + { + name: 'HTTP cache must preserve unupdated header fields from the stored response', + id: 'partial-use-stored-headers', + depends_on: ['partial-store-complete-reuse-partial'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['A', '1'] + ], + response_body: '01234567890', + setup: true + }, + { + request_headers: [ + ['Range', 'bytes=0-1'] + ], + expected_type: 'cached', + expected_status: 206, + expected_response_text: '01', + setup_tests: ['expected_type', 'expected_status', 'expected_response_text'], + expected_response_headers: [ + ['A', '1'] + ] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/pragma.mjs b/test/fixtures/cache-tests/tests/pragma.mjs new file mode 100644 index 0000000..0ced90b --- /dev/null +++ b/test/fixtures/cache-tests/tests/pragma.mjs @@ -0,0 +1,97 @@ +import * as templates from './lib/templates.mjs' + +export default + +{ + name: 'Pragma', + id: 'pragma', + description: 'These tests check how caches handle the deprecated `Pragma` header in reqeusts and responses. Note that This field is deprecated - it is not required to be supported.', + spec_anchors: ['field.pragma'], + tests: [ + { + name: 'Does HTTP cache use a stored fresh response when request contains `Pragma: no-cache`?', + id: 'pragma-request-no-cache', + kind: 'check', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({}), + { + request_headers: [ + ['Pragma', 'no-cache'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a stored fresh response when request contains `Pragma: unrecognised-extension`?', + id: 'pragma-request-extension', + kind: 'check', + depends_on: ['freshness-max-age'], + requests: [ + templates.fresh({}), + { + request_headers: [ + ['Pragma', 'unrecognised-extension'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a stored and otherwise fresh response when it contains `Pragma: no-cache`?', + id: 'pragma-response-no-cache', + kind: 'check', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Pragma', 'no-cache'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache reuse a stored and heuristically fresh response when it contains `Pragma: no-cache`?', + id: 'pragma-response-no-cache-heuristic', + kind: 'check', + depends_on: ['heuristic-200-cached'], + requests: [ + { + response_headers: [ + ['Date', 0], + ['Last-Modified', -10000], + ['Pragma', 'no-cache'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache use a stored and otherwise fresh response when it contains `Pragma: unrecognised-extension`?', + id: 'pragma-response-extension', + kind: 'check', + depends_on: ['freshness-max-age'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ['Pragma', 'unrecognised-extension'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/stale.mjs b/test/fixtures/cache-tests/tests/stale.mjs new file mode 100644 index 0000000..616966e --- /dev/null +++ b/test/fixtures/cache-tests/tests/stale.mjs @@ -0,0 +1,177 @@ +import * as templates from './lib/templates.mjs' + +function makeStaleCheckCC (cc, sharedOnly, value) { + const shared = sharedOnly === true ? 'Shared ' : '' + return { + name: `${shared}HTTP cache must not serve stale stored response when prohibited by \`Cache-Control: ${cc}\``, + id: `stale-close-${cc}${value || ''}`, + browser_skip: sharedOnly, + depends_on: ['stale-close'], + spec_anchors: [`cache-response-directive.${cc}`], + requests: [ + { + response_headers: [ + ['Cache-Control', `max-age=2, ${cc}${value || ''}`] + ], + setup: true, + pause_after: true + }, + { + disconnect: true, + expected_type: 'not_cached' + } + ] + } +} + +export default { + name: 'Serving Stale', + id: 'stale', + description: 'These tests check how caches serve stale content.', + spec_anchors: ['serving.stale.responses'], + tests: [ + { + name: 'Does HTTP cache serve stale stored response when server closes the connection?', + id: 'stale-close', + depends_on: ['freshness-max-age-stale'], + kind: 'check', + requests: [ + templates.becomeStale({}), + { + disconnect: true, + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache serve stale stored response when server sends a `503 Service Unavailable`?', + id: 'stale-503', + depends_on: ['freshness-max-age-stale'], + kind: 'check', + requests: [ + templates.becomeStale({}), + { + response_status: [503, 'Service Unavailable'], + expected_status: 200, + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal cache serves stale stored response with [`Cache-Control: stale-while-revalidate`](https://httpwg.org/specs/rfc5861.html)', + id: 'stale-while-revalidate', + depends_on: ['freshness-max-age-stale'], + kind: 'optimal', + requests: [ + { + setup: true, + pause_after: true, + response_headers: [ + ['Cache-Control', 'max-age=1, stale-while-revalidate=3600'], + ['ETag', '"abc"'] + ] + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not serve stale stored response after the [`stale-while-revalidate`](https://httpwg.org/specs/rfc5861.html) window', + id: 'stale-while-revalidate-window', + depends_on: ['stale-while-revalidate'], + requests: [ + { + setup: true, + pause_after: true, + response_headers: [ + ['Cache-Control', 'max-age=1, stale-while-revalidate=4'], + ['ETag', '"abc"'] + ] + }, + { + setup: true, + pause_after: true, + expected_type: 'cached' + }, + { + expected_response_headers: [ + ['client-request-count', '3'] + ] + } + ] + }, + { + name: 'Does HTTP cache serve stale stored response when server sends `Cache-Control: stale-if-error` and subsequently closes the connection?', + id: 'stale-sie-close', + depends_on: ['freshness-max-age-stale'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2, stale-if-error=60'] + ], + setup: true, + pause_after: true + }, + { + disconnect: true, + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache serve stale stored response when server sends `Cache-Control: stale-if-error` and subsequently a `503 Service Unavailable`?', + id: 'stale-sie-503', + depends_on: ['freshness-max-age-stale'], + kind: 'check', + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2, stale-if-error=60'] + ], + setup: true, + pause_after: true + }, + { + disconnect: true, + expected_type: 'cached' + } + ] + }, + makeStaleCheckCC('must-revalidate', false), + makeStaleCheckCC('proxy-revalidate', true), + makeStaleCheckCC('no-cache', false), + makeStaleCheckCC('s-maxage', true, '=2'), + { + name: 'Does HTTP cache generate a `Warning` header when using a response that was stored already stale?', + id: 'stale-warning-stored', + kind: 'check', + depends_on: ['stale-close'], + requests: [ + templates.stale({}), + { + disconnect: true, + expected_type: 'cached', + expected_response_headers: ['warning'], + setup_tests: ['expected_type'] + } + ] + }, + { + name: 'Does HTTP cache generate a `Warning` header when using a stored response that became stale?', + id: 'stale-warning-become', + kind: 'check', + depends_on: ['stale-close'], + requests: [ + templates.becomeStale({}), + { + disconnect: true, + expected_type: 'cached', + expected_response_headers: ['warning'], + setup_tests: ['expected_type'] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/status.mjs b/test/fixtures/cache-tests/tests/status.mjs new file mode 100644 index 0000000..e485056 --- /dev/null +++ b/test/fixtures/cache-tests/tests/status.mjs @@ -0,0 +1,118 @@ +import * as templates from './lib/templates.mjs' +import * as utils from './lib/utils.mjs' + +const tests = [] + +function checkStatus (status) { + const code = status[0] + const phrase = status[1] + let body = status[2] + if (body === undefined) { + body = utils.httpContent(code) + } + const is3xx = code > 299 && code < 400 + tests.push({ + name: 'An optimal HTTP cache reuses a fresh `' + code + '` response with explict freshness', + id: `status-${code}-fresh`, + kind: 'optimal', + depends_on: ['freshness-max-age'], + browser_skip: is3xx, + requests: [ + templates.fresh({ + response_status: [code, phrase], + response_body: body, + redirect: 'manual' + }), { + expected_type: 'cached', + response_status: [code, phrase], + redirect: 'manual', + response_body: body + } + ] + }) + tests.push({ + name: 'HTTP cache must not reuse a stale `' + code + '` response with explicit freshness', + id: `status-${code}-stale`, + depends_on: [`status-${code}-fresh`], + browser_skip: is3xx, + requests: [ + templates.stale({ + response_status: [code, phrase], + response_body: body, + redirect: 'manual', + setup: true + }), { + expected_type: 'not_cached', + redirect: 'manual', + response_body: body + } + ] + }) +} +[ + [200, 'OK'], + [203, 'Non-Authoritative Information'], + [204, 'No Content', null], + [299, 'Whatever'], + [301, 'Moved Permanently'], + [302, 'Found'], + [303, 'See Other'], + [307, 'Temporary Redirect'], + [308, 'Permanent Redirect'], + [400, 'Bad Request'], + [404, 'Not Found'], + [410, 'Gone'], + [499, 'Whatever'], + [500, 'Internal Server Error'], + [502, 'Bad Gateway'], + [503, 'Service Unavailable'], + [504, 'Gateway Timeout'], + [599, 'Whatever'] +].forEach(checkStatus) + +tests.push({ + name: 'HTTP cache must not reuse a fresh response with an unrecognised status code and `Cache-Control: no-store, must-understand`', + id: 'status-599-must-understand', + depends_on: ['status-599-fresh'], + spec_anchors: ['cache-response-directive.must-understand'], + requests: [ + { + response_status: [599, 'Whatever'], + response_headers: [ + ['Cache-Control', 'max-age=3600, no-store, must-understand'] + ], + setup: true + }, + { + expected_type: 'not_cached' + } + ] +}) + +tests.push({ + name: 'An optimal HTTP cache reuses a fresh response with a recognised status code and `Cache-Control: no-store, must-understand`', + id: 'status-200-must-understand', + kind: 'optimal', + depends_on: ['status-200-fresh', 'cc-resp-no-store-fresh'], + spec_anchors: ['cache-response-directive.must-understand'], + requests: [ + { + response_status: [200, 'OK'], + response_headers: [ + ['Cache-Control', 'max-age=3600, no-store, must-understand'] + ], + setup: true + }, + { + expected_type: 'cached' + } + ] +}) + +export default { + name: 'Status Code Cacheability', + id: 'status', + description: 'These tests check to see if a cache will store and reuse various status codes when they have explicit freshness information associated with them.', + spec_anchors: ['response.cacheability'], + tests +} diff --git a/test/fixtures/cache-tests/tests/update304.mjs b/test/fixtures/cache-tests/tests/update304.mjs new file mode 100644 index 0000000..b83f6d8 --- /dev/null +++ b/test/fixtures/cache-tests/tests/update304.mjs @@ -0,0 +1,124 @@ +import * as utils from './lib/utils.mjs' +import headerList from './lib/header-list.mjs' + +const tests = [] + +// first, check to see that the cache actually returns a stored header +const storedHeader = 'Test-Header' +const valueA = utils.httpContent(`${storedHeader}-value-A`) +const lm1 = 'Wed, 01 Jan 2020 00:00:00 GMT' +tests.push({ + name: `HTTP cache must return stored \`${storedHeader}\` from a \`304\` that omits it`, + id: `304-lm-use-stored-${storedHeader}`, + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=2'], + ['Last-Modified', lm1], + ['Date', 0], + [storedHeader, valueA] + ], + setup: true, + pause_after: true + }, + { + response_headers: [ + ['Last-Modified', lm1], + ['Date', 0] + ], + expected_type: 'lm_validated', + expected_response_headers: [ + [storedHeader, valueA] + ], + setup_tests: ['expected_type'] + } + ] +}) + +// now check headers in the list +function check304 (config) { + if (config.noStore) return + config.valueA = config.valA || utils.httpContent(`${config.name}-value-A`) + config.valueB = config.valB || utils.httpContent(`${config.name}-value-B`) + if (config.noUpdate === true) { + config.expectedValue = config.valueA + config.requirement = 'HTTP cache must not' + config.punctuation = '' + config.kind = 'required' + } else if (config.reqUpdate === true) { + config.expectedValue = config.valueB + config.requirement = 'HTTP cache must' + config.punctuation = '' + config.kind = 'required' + } else { + config.expectedValue = config.valueB + config.requirement = 'Does HTTP cache' + config.punctuation = '?' + config.kind = 'check' + } + config.etagVal = utils.httpContent(`${config.name}-etag-1`) + config.etag = `"${config.etagVal}"` + config.lm = 'Wed, 01 Jan 2020 00:00:00 GMT' + + tests.push({ + name: `${config.requirement} update and return \`${config.name}\` from a \`304\`${config.punctuation}`, + id: `304-etag-update-response-${config.name}`, + kind: config.kind, + depends_on: [`304-lm-use-stored-${storedHeader}`], + requests: makeRequests(config, 'ETag', config.etag) + }) +} + +function makeRequests (config, validatorType, validatorValue) { + return [ + { + response_headers: makeResponse(config, config.valueA, validatorType, validatorValue), + setup: true, + pause_after: true, + check_body: 'checkBody' in config ? config.checkBody : true + }, + { + response_headers: makeResponse(config, config.valueB, validatorType, validatorValue), + expected_type: validatorType === 'ETag' ? 'etag_validated' : 'lm_validated', + setup_tests: ['expected_type'], + expected_response_headers: [ + [config.name, config.expectedValue] + ], + check_body: 'checkBody' in config ? config.checkBody : true + }, + { + response_headers: makeResponse(config, config.expectedValue), + expected_type: 'cached', + setup_tests: ['expected_type'], + expected_response_headers: [ + [config.name, config.expectedValue] + ], + check_body: 'checkBody' in config ? config.checkBody : true + } + ] +} + +function makeResponse (config, value, validatorType, validatorValue) { + const checkHeader = 'noUpdate' in config ? !config.noUpdate : true + const responseHeaders = [ + ['Date', 0], + [config.name, value, checkHeader] + ] + if (config.name !== 'Cache-Control') { + responseHeaders.push(['Cache-Control', 'max-age=2']) + } + if (validatorType && validatorType !== config.name) { + responseHeaders.push([validatorType, validatorValue]) + } + return responseHeaders +} + +headerList.forEach(check304) + +export default { + name: 'Update Headers Upon a 304', + id: 'update304', + description: 'These tests check cache behaviour upon receiving a `304 Not Modified` response.', + spec_anchors: ['freshening.responses'], + tests +} diff --git a/test/fixtures/cache-tests/tests/updateHead.mjs b/test/fixtures/cache-tests/tests/updateHead.mjs new file mode 100644 index 0000000..9b8998c --- /dev/null +++ b/test/fixtures/cache-tests/tests/updateHead.mjs @@ -0,0 +1,109 @@ +import * as templates from './lib/templates.mjs' + +export default + +{ + name: 'HEAD updates', + id: 'updateHEAD', + description: 'These tests check how a cache updates stored responses when receiving a `HEAD` response.', + spec_anchors: ['head.effects'], + tests: [ + { + name: 'Does HTTP cache write through a HEAD when stored response is stale?', + id: 'head-writethrough', + kind: 'check', + depends_on: ['freshness-max-age-stale'], + requests: [ + templates.becomeStale({}), + { + request_method: 'HEAD', + expected_method: 'HEAD' + } + ] + }, + { + name: 'Does HTTP cache preserve stored fields not received in a `200` response to a `HEAD`?', + id: 'head-200-retain', + kind: 'check', + depends_on: ['head-writethrough'], + requests: [ + templates.becomeStale({}), + { + request_method: 'HEAD', + expected_method: 'HEAD', + expected_response_headers: [ + ['Template-A', '1'] + ] + } + ] + }, + { + name: 'Does HTTP cache update freshness lifetime recieved in a `200` response to a `HEAD`?', + id: 'head-200-freshness-update', + kind: 'check', + depends_on: ['head-writethrough'], + requests: [ + templates.becomeStale({}), + { + request_method: 'HEAD', + expected_method: 'HEAD', + response_headers: [ + ['Cache-Control', 'max-age=1000'] + ] + }, + { + expected_type: 'cached' + } + ] + }, + { + name: 'Does HTTP cache update stored fields recieved in a `200` response to a `HEAD`?', + id: 'head-200-update', + kind: 'check', + depends_on: ['head-200-freshness-update'], + requests: [ + templates.becomeStale({}), + { + request_method: 'HEAD', + expected_method: 'HEAD', + response_headers: [ + ['Template-A', '2'], + ['Cache-Control', 'max-age=1000'] + ] + }, + { + expected_type: 'cached', + setup_tests: ['expected_type'], + expected_response_headers: [ + ['Template-A', '2'] + ] + } + ] + }, + { + name: 'Does HTTP cache update stored fields recieved in a `410` response to a `HEAD`?', + id: 'head-410-update', + kind: 'check', + depends_on: ['head-200-freshness-update'], + requests: [ + templates.becomeStale({}), + { + request_method: 'HEAD', + expected_method: 'HEAD', + response_status: [410, 'Gone'], + response_headers: [ + ['Template-A', '2'], + ['Cache-Control', 'max-age=1000'] + ] + }, + { + expected_type: 'cached', + setup_tests: ['expected_type'], + expected_response_headers: [ + ['Template-A', '2'] + ] + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/vary-parse.mjs b/test/fixtures/cache-tests/tests/vary-parse.mjs new file mode 100644 index 0000000..4312461 --- /dev/null +++ b/test/fixtures/cache-tests/tests/vary-parse.mjs @@ -0,0 +1,157 @@ +import { makeTemplate } from './lib/templates.mjs' + +const varyParseSetup = makeTemplate({ + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0] + ], + setup: true +}) + +export default { + name: 'Vary Parsing', + id: 'vary-parse', + description: 'These tests check how caches parse the `Vary` response header.', + spec_anchors: ['caching.negotiated.responses'], + tests: [ + { + name: 'HTTP cache must not reuse `Vary` response with a value of `*`', + id: 'vary-syntax-star', + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', '*', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `*, *`', + id: 'vary-syntax-star-star', + depends_on: ['freshness-max-age'], + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', '*, *', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `*, *` on different lines', + id: 'vary-syntax-star-star-lines', + depends_on: ['freshness-max-age'], + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', '*', false], + ['Vary', '*', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `, *`', + id: 'vary-syntax-empty-star', + depends_on: ['freshness-max-age'], + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', ', *', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `, *` on different lines', + id: 'vary-syntax-empty-star-lines', + depends_on: ['freshness-max-age'], + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', '', false], + ['Vary', '*', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `*, Foo`', + id: 'vary-syntax-star-foo', + depends_on: ['freshness-max-age'], + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', '*, Foo', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `Foo, *`', + id: 'vary-syntax-foo-star', + depends_on: ['freshness-max-age'], + requests: [ + varyParseSetup({ + response_headers: [ + ['Vary', 'Foo, *', false] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + } + ] +} diff --git a/test/fixtures/cache-tests/tests/vary.mjs b/test/fixtures/cache-tests/tests/vary.mjs new file mode 100644 index 0000000..501b548 --- /dev/null +++ b/test/fixtures/cache-tests/tests/vary.mjs @@ -0,0 +1,470 @@ +import { makeTemplate } from './lib/templates.mjs' +import * as utils from './lib/utils.mjs' + +const varySetup = makeTemplate({ + request_headers: [ + ['Foo', '1'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo'] + ], + setup: true +}) + +const vary2Setup = makeTemplate({ + request_headers: [ + ['Foo', '1'], + ['Bar', 'abc'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo, Bar', false] + ], + setup: true +}) + +const vary3Setup = makeTemplate({ + request_headers: [ + ['Foo', '1'], + ['Bar', 'abc'], + ['Baz', '789'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo, Bar, Baz', false] + ], + setup: true +}) + +export default { + name: 'Vary and Cache Keys', + id: 'vary', + description: 'These tests check how caches calculate a cache key using `Vary`.', + spec_anchors: ['caching.negotiated.responses'], + tests: [ + { + name: 'An optimal HTTP cache reuses a `Vary` response when the request matches', + id: 'vary-match', + depends_on: ['freshness-max-age'], + kind: 'optimal', + requests: [ + varySetup({}), + { + request_headers: [ + ['Foo', '1'] + ], + expected_type: 'cached' + } + ] + }, + { + name: "HTTP cache must not reuse `Vary` response when request doesn't match", + id: 'vary-no-match', + depends_on: ['vary-match'], + requests: [ + varySetup({}), + { + request_headers: [ + ['Foo', '2'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response when stored request omits variant request header', + id: 'vary-omit-stored', + depends_on: ['vary-match'], + requests: [ + { + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo'] + ], + setup: true + }, + { + request_headers: [ + ['Foo', '1'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response when presented request omits variant request header', + id: 'vary-omit', + depends_on: ['vary-match'], + requests: [ + varySetup({}), + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache can store two different variants', + id: 'vary-invalidate', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + varySetup({ + response_body: utils.httpContent('foo_1') + }), + { + request_headers: [ + ['Foo', '2'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo'] + ], + expected_type: 'not_cached', + response_body: utils.httpContent('foo_2'), + setup: true + }, + { + request_headers: [ + ['Foo', '1'] + ], + response_body: utils.httpContent('foo_1'), + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache should not include headers not listed in `Vary` in the cache key', + id: 'vary-cache-key', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + varySetup({ + request_headers: [ + ['Other', '2'] + ] + }), + { + request_headers: [ + ['Foo', '1'], + ['Other', '3'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a two-way `Vary` response when request matches', + id: 'vary-2-match', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + vary2Setup({}), + { + request_headers: [ + ['Foo', '1'], + ['Bar', 'abc'] + ], + expected_type: 'cached' + } + ] + }, + { + name: "HTTP cache must not reuse two-way `Vary` response when request doesn't match", + id: 'vary-2-no-match', + depends_on: ['vary-2-match'], + requests: [ + vary2Setup({}), + { + request_headers: [ + ['Foo', '2'], + ['Bar', 'abc'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'HTTP cache must not reuse two-way `Vary` response when request omits variant request header', + id: 'vary-2-match-omit', + depends_on: ['vary-2-match'], + requests: [ + vary2Setup({}), + { + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a three-way `Vary` response when request matches', + id: 'vary-3-match', + depends_on: ['vary-2-match'], + kind: 'optimal', + requests: [ + vary3Setup({}), + { + request_headers: [ + ['Foo', '1'], + ['Bar', 'abc'], + ['Baz', '789'] + ], + expected_type: 'cached' + } + ] + }, + { + name: "HTTP cache must not reuse three-way `Vary` response when request doesn't match", + id: 'vary-3-no-match', + depends_on: ['vary-3-match'], + requests: [ + vary3Setup({}), + { + request_headers: [ + ['Foo', '2'], + ['Bar', 'abc'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: "HTTP cache must not reuse three-way `Vary` response when request doesn't match, regardless of header order", + id: 'vary-3-order', + depends_on: ['vary-3-match'], + requests: [ + vary3Setup({}), + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'], + ['Bar', 'abcde'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache reuses a three-way `Vary` response when both request and the original request omited a variant header', + id: 'vary-3-omit', + depends_on: ['vary-3-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Date', 0], + ['Last-Modified', -3000], + ['Vary', 'Foo, Bar, Baz', false] // FIXME: allow whitespace changes + ], + setup: true + }, + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'HTTP cache must not reuse `Vary` response with a value of `*`', + id: 'vary-star', + requests: [ + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', '*'] + ], + setup: true + }, + { + request_headers: [ + ['Foo', '1'], + ['Baz', '789'] + ], + expected_type: 'not_cached' + } + ] + }, + { + name: 'An optimal HTTP cache normalises unknown selecting headers by combining fields', + id: 'vary-normalise-combine', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Foo', '1, 2'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo'] + ], + setup: true + }, + { + request_headers: [ + ['Foo', '1'], + ['Foo', '2'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache normalises `Accept-Language` by ignoring language order', + id: 'vary-normalise-lang-order', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Accept-Language', 'en, de'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Accept-Language'] + ], + setup: true + }, + { + request_headers: [ + ['Accept-Language', 'de, en'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache normalises `Accept-Language` by ignoring language case', + id: 'vary-normalise-lang-case', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Accept-Language', 'en, de'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Accept-Language'] + ], + setup: true + }, + { + request_headers: [ + ['Accept-Language', 'eN, De'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache normalises `Accept-Language` by ignoring whitespace', + id: 'vary-normalise-lang-space', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Accept-Language', 'en, de'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Accept-Language'] + ], + setup: true + }, + { + request_headers: [ + ['Accept-Language', ' en , de'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache selects `Content-Language` by using the qvalue on `Accept-Language`', + id: 'vary-normalise-lang-select', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Accept-Language', 'en, de'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Accept-Language'], + ['Content-Language', 'de'] + ], + setup: true + }, + { + request_headers: [ + ['Accept-Language', 'fr;q=0.5, de;q=1.0'] + ], + expected_type: 'cached' + } + ] + }, + { + name: 'An optimal HTTP cache normalises unknown selecting headers by removing whitespace', + id: 'vary-normalise-space', + depends_on: ['vary-match'], + kind: 'optimal', + requests: [ + { + request_headers: [ + ['Foo', '1,2'] + ], + response_headers: [ + ['Cache-Control', 'max-age=5000'], + ['Last-Modified', -3000], + ['Date', 0], + ['Vary', 'Foo'] + ], + setup: true + }, + { + request_headers: [ + ['Foo', ' 1, 2 '] + ], + expected_type: 'cached' + } + ] + } + ] +} diff --git a/test/fixtures/cert.pem b/test/fixtures/cert.pem new file mode 100644 index 0000000..903d5f5 --- /dev/null +++ b/test/fixtures/cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- diff --git a/test/fixtures/fetch.js b/test/fixtures/fetch.js new file mode 100644 index 0000000..36e572c --- /dev/null +++ b/test/fixtures/fetch.js @@ -0,0 +1,22 @@ +'use strict' + +const { createServer } = require('node:http') +const { fetch } = require('../..') + +const server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('hello world') +}) + +server.listen(0, () => { + const { port, address, family } = server.address() + const hostname = family === 'IPv6' ? `[${address}]` : address + fetch(`http://${hostname}:${port}`) + .then( + res => res.body.cancel(), + () => {} + ) + .then(() => { + server.close() + }) +}) diff --git a/test/fixtures/interceptors/retry-event-loop.js b/test/fixtures/interceptors/retry-event-loop.js new file mode 100644 index 0000000..0a92d0d --- /dev/null +++ b/test/fixtures/interceptors/retry-event-loop.js @@ -0,0 +1,33 @@ +'use strict' + +const { createServer } = require('node:http') +const { once } = require('node:events') +const { + Client, + interceptors: { retry } +} = require('../../..') + +const server = createServer() + +server.on('request', (req, res) => { + res.writeHead(418, { 'Content-Type': 'text/plain' }) + res.end('teapot') +}) + +server.listen(0) +once(server, 'listening').then(() => { + const client = new Client( + `http://localhost:${server.address().port}` + ).compose( + retry({ + maxTimeout: 1000, + maxRetries: 3, + statusCodes: [418] + }) + ) + + return client.request({ + method: 'GET', + path: '/' + }) +}) diff --git a/test/fixtures/key.pem b/test/fixtures/key.pem new file mode 100644 index 0000000..facb311 --- /dev/null +++ b/test/fixtures/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +zhkPVDbGIYpzFskiOGNjvnvBhwVdVg== +-----END PRIVATE KEY----- diff --git a/test/fixtures/undici.js b/test/fixtures/undici.js new file mode 100644 index 0000000..6431425 --- /dev/null +++ b/test/fixtures/undici.js @@ -0,0 +1,19 @@ +'use strict' + +const { createServer } = require('node:http') +const { request } = require('../..') + +const server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('hello world') +}) + +server.listen(0, () => { + const { port, address, family } = server.address() + const hostname = family === 'IPv6' ? `[${address}]` : address + request(`http://${hostname}:${port}`) + .then(res => res.body.dump()) + .then(() => { + server.close() + }) +}) diff --git a/test/fixtures/websocket.js b/test/fixtures/websocket.js new file mode 100644 index 0000000..708808b --- /dev/null +++ b/test/fixtures/websocket.js @@ -0,0 +1,18 @@ +'use strict' + +const { WebSocketServer } = require('ws') +const { WebSocket } = require('../..') + +const server = new WebSocketServer({ port: 0 }) + +server.on('connection', ws => { + ws.close(1000, 'goodbye') +}) +server.on('listening', () => { + const { port } = server.address() + const ws = new WebSocket(`ws://localhost:${port}`, 'chat') + + ws.addEventListener('close', () => { + server.close() + }) +}) diff --git a/test/fixtures/wpt/LICENSE.md b/test/fixtures/wpt/LICENSE.md new file mode 100644 index 0000000..39c46d0 --- /dev/null +++ b/test/fixtures/wpt/LICENSE.md @@ -0,0 +1,11 @@ +# The 3-Clause BSD License + +Copyright © web-platform-tests contributors + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/test/fixtures/wpt/common/CustomCorsResponse.py b/test/fixtures/wpt/common/CustomCorsResponse.py new file mode 100644 index 0000000..fc4d122 --- /dev/null +++ b/test/fixtures/wpt/common/CustomCorsResponse.py @@ -0,0 +1,30 @@ +import json + +def main(request, response): + '''Handler for getting an HTTP response customised by the given query + parameters. + + The returned response will have + - HTTP headers defined by the 'headers' query parameter + - Must be a serialized JSON dictionary mapping header names to header + values + - HTTP status code defined by the 'status' query parameter + - Must be a positive serialized JSON integer like the string '200' + - Response content defined by the 'content' query parameter + - Must be a serialized JSON string representing the desired response body + ''' + def query_parameter_or_default(param, default): + return request.GET.first(param) if param in request.GET else default + + headers = json.loads(query_parameter_or_default(b'headers', b'"{}"')) + for k, v in headers.items(): + response.headers.set(k, v) + + # Note that, in order to have out-of-the-box support for tests that don't call + # setup({'allow_uncaught_exception': true}) + # we return a no-op JS payload. This approach will avoid syntax errors in + # script resources that would otherwise cause the test harness to fail. + response.content = json.loads(query_parameter_or_default(b'content', + b'"/* CustomCorsResponse.py content */"')) + response.status_code = json.loads(query_parameter_or_default(b'status', + b'200')) diff --git a/test/fixtures/wpt/common/META.yml b/test/fixtures/wpt/common/META.yml new file mode 100644 index 0000000..963dff9 --- /dev/null +++ b/test/fixtures/wpt/common/META.yml @@ -0,0 +1,2 @@ +suggested_reviewers: + - deniak diff --git a/test/fixtures/wpt/common/PrefixedLocalStorage.js b/test/fixtures/wpt/common/PrefixedLocalStorage.js new file mode 100644 index 0000000..2f4e7b6 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedLocalStorage.js @@ -0,0 +1,116 @@ +/** + * Supports pseudo-"namespacing" localStorage for a given test + * by generating and using a unique prefix for keys. Why trounce on other + * tests' localStorage items when you can keep it "separated"? + * + * PrefixedLocalStorageTest: Instantiate in testharness.js tests to generate + * a new unique-ish prefix + * PrefixedLocalStorageResource: Instantiate in supporting test resource + * files to use/share a prefix generated by a test. + */ +var PrefixedLocalStorage = function () { + this.prefix = ''; // Prefix for localStorage keys + this.param = 'prefixedLocalStorage'; // Param to use in querystrings +}; + +PrefixedLocalStorage.prototype.clear = function () { + if (this.prefix === '') { return; } + Object.keys(localStorage).forEach(sKey => { + if (sKey.indexOf(this.prefix) === 0) { + localStorage.removeItem(sKey); + } + }); +}; + +/** + * Append/replace prefix parameter and value in URI querystring + * Use to generate URLs to resource files that will share the prefix. + */ +PrefixedLocalStorage.prototype.url = function (uri) { + function updateUrlParameter (uri, key, value) { + var i = uri.indexOf('#'); + var hash = (i === -1) ? '' : uri.substr(i); + uri = (i === -1) ? uri : uri.substr(0, i); + var re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); + var separator = uri.indexOf('?') !== -1 ? '&' : '?'; + uri = (uri.match(re)) ? uri.replace(re, `$1${key}=${value}$2`) : + `${uri}${separator}${key}=${value}`; + return uri + hash; + } + return updateUrlParameter(uri, this.param, this.prefix); +}; + +PrefixedLocalStorage.prototype.prefixedKey = function (baseKey) { + return `${this.prefix}${baseKey}`; +}; + +PrefixedLocalStorage.prototype.setItem = function (baseKey, value) { + localStorage.setItem(this.prefixedKey(baseKey), value); +}; + +/** + * Listen for `storage` events pertaining to a particular key, + * prefixed with this object's prefix. Ignore when value is being set to null + * (i.e. removeItem). + */ +PrefixedLocalStorage.prototype.onSet = function (baseKey, fn) { + window.addEventListener('storage', e => { + var match = this.prefixedKey(baseKey); + if (e.newValue !== null && e.key.indexOf(match) === 0) { + fn.call(this, e); + } + }); +}; + +/***************************************************************************** + * Use in a testharnessjs test to generate a new key prefix. + * async_test(t => { + * var prefixedStorage = new PrefixedLocalStorageTest(); + * t.add_cleanup(() => prefixedStorage.cleanup()); + * /... + * }); + */ +var PrefixedLocalStorageTest = function () { + PrefixedLocalStorage.call(this); + this.prefix = `${document.location.pathname}-${Math.random()}-${Date.now()}-`; +}; +PrefixedLocalStorageTest.prototype = Object.create(PrefixedLocalStorage.prototype); +PrefixedLocalStorageTest.prototype.constructor = PrefixedLocalStorageTest; + +/** + * Use in a cleanup function to clear out prefixed entries in localStorage + */ +PrefixedLocalStorageTest.prototype.cleanup = function () { + this.setItem('closeAll', 'true'); + this.clear(); +}; + +/***************************************************************************** + * Use in test resource files to share a prefix generated by a + * PrefixedLocalStorageTest. Will look in URL querystring for prefix. + * Setting `close_on_cleanup` opt truthy will make this script's window listen + * for storage `closeAll` event from controlling test and close itself. + * + * var PrefixedLocalStorageResource({ close_on_cleanup: true }); + */ +var PrefixedLocalStorageResource = function (options) { + PrefixedLocalStorage.call(this); + this.options = Object.assign({}, { + close_on_cleanup: false + }, options || {}); + // Check URL querystring for prefix to use + var regex = new RegExp(`[?&]${this.param}(=([^&#]*)|&|#|$)`), + results = regex.exec(document.location.href); + if (results && results[2]) { + this.prefix = results[2]; + } + // Optionally have this window close itself when the PrefixedLocalStorageTest + // sets a `closeAll` item. + if (this.options.close_on_cleanup) { + this.onSet('closeAll', () => { + window.close(); + }); + } +}; +PrefixedLocalStorageResource.prototype = Object.create(PrefixedLocalStorage.prototype); +PrefixedLocalStorageResource.prototype.constructor = PrefixedLocalStorageResource; diff --git a/test/fixtures/wpt/common/PrefixedLocalStorage.js.headers b/test/fixtures/wpt/common/PrefixedLocalStorage.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedLocalStorage.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/PrefixedPostMessage.js b/test/fixtures/wpt/common/PrefixedPostMessage.js new file mode 100644 index 0000000..674b528 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedPostMessage.js @@ -0,0 +1,100 @@ +/** + * Supports pseudo-"namespacing" for window-posted messages for a given test + * by generating and using a unique prefix that gets wrapped into message + * objects. This makes it more feasible to have multiple tests that use + * `window.postMessage` in a single test file. Basically, make it possible + * for the each test to listen for only the messages that are pertinent to it. + * + * 'Prefix' not an elegant term to use here but this models itself after + * PrefixedLocalStorage. + * + * PrefixedMessageTest: Instantiate in testharness.js tests to generate + * a new unique-ish prefix that can be used by other test support files + * PrefixedMessageResource: Instantiate in supporting test resource + * files to use/share a prefix generated by a test. + */ +var PrefixedMessage = function () { + this.prefix = ''; + this.param = 'prefixedMessage'; // Param to use in querystrings +}; + +/** + * Generate a URL that adds/replaces param with this object's prefix + * Use to link to test support files that make use of + * PrefixedMessageResource. + */ +PrefixedMessage.prototype.url = function (uri) { + function updateUrlParameter (uri, key, value) { + var i = uri.indexOf('#'); + var hash = (i === -1) ? '' : uri.substr(i); + uri = (i === -1) ? uri : uri.substr(0, i); + var re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i'); + var separator = uri.indexOf('?') !== -1 ? '&' : '?'; + uri = (uri.match(re)) ? uri.replace(re, `$1${key}=${value}$2`) : + `${uri}${separator}${key}=${value}`; + return uri + hash; + } + return updateUrlParameter(uri, this.param, this.prefix); +}; + +/** + * Add an eventListener on `message` but only invoke the given callback + * for messages whose object contains this object's prefix. Remove the + * event listener once the anticipated message has been received. + */ +PrefixedMessage.prototype.onMessage = function (fn) { + window.addEventListener('message', e => { + if (typeof e.data === 'object' && e.data.hasOwnProperty('prefix')) { + if (e.data.prefix === this.prefix) { + // Only invoke callback when `data` is an object containing + // a `prefix` key with this object's prefix value + // Note fn is invoked with "unwrapped" data first, then the event `e` + // (which contains the full, wrapped e.data should it be needed) + fn.call(this, e.data.data, e); + window.removeEventListener('message', fn); + } + } + }); +}; + +/** + * Instantiate in a test file (e.g. during `setup`) to create a unique-ish + * prefix that can be shared by support files + */ +var PrefixedMessageTest = function () { + PrefixedMessage.call(this); + this.prefix = `${document.location.pathname}-${Math.random()}-${Date.now()}-`; +}; +PrefixedMessageTest.prototype = Object.create(PrefixedMessage.prototype); +PrefixedMessageTest.prototype.constructor = PrefixedMessageTest; + +/** + * Instantiate in a test support script to use a "prefix" generated by a + * PrefixedMessageTest in a controlling test file. It will look for + * the prefix in a URL param (see also PrefixedMessage#url) + */ +var PrefixedMessageResource = function () { + PrefixedMessage.call(this); + // Check URL querystring for prefix to use + var regex = new RegExp(`[?&]${this.param}(=([^&#]*)|&|#|$)`), + results = regex.exec(document.location.href); + if (results && results[2]) { + this.prefix = results[2]; + } +}; +PrefixedMessageResource.prototype = Object.create(PrefixedMessage.prototype); +PrefixedMessageResource.prototype.constructor = PrefixedMessageResource; + +/** + * This is how a test resource document can "send info" to its + * opener context. It will whatever message is being sent (`data`) in + * an object that injects the prefix. + */ +PrefixedMessageResource.prototype.postToOpener = function (data) { + if (window.opener) { + window.opener.postMessage({ + prefix: this.prefix, + data: data + }, '*'); + } +}; diff --git a/test/fixtures/wpt/common/PrefixedPostMessage.js.headers b/test/fixtures/wpt/common/PrefixedPostMessage.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/PrefixedPostMessage.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/README.md b/test/fixtures/wpt/common/README.md new file mode 100644 index 0000000..9aef19c --- /dev/null +++ b/test/fixtures/wpt/common/README.md @@ -0,0 +1,10 @@ +The files in this directory are non-infrastructure support files that can be used by tests. + +* `blank.html` - An empty HTML document. +* `domain-setter.sub.html` - An HTML document that sets `document.domain`. +* `dummy.xhtml` - An XHTML document. +* `dummy.xml` - An XML document. +* `text-plain.txt` - A text/plain document. +* `*.js` - Utility scripts. These are documented in the source. +* `*.py` - wptserve [Python Handlers](https://web-platform-tests.org/writing-tests/python-handlers/). These are documented in the source. +* `security-features` - Documented in `security-features/README.md`. diff --git a/test/fixtures/wpt/common/__init__.py b/test/fixtures/wpt/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/arrays.js b/test/fixtures/wpt/common/arrays.js new file mode 100644 index 0000000..2b31bb4 --- /dev/null +++ b/test/fixtures/wpt/common/arrays.js @@ -0,0 +1,31 @@ +/** + * Callback for checking equality of c and d. + * + * @callback equalityCallback + * @param {*} c + * @param {*} d + * @returns {boolean} + */ + +/** + * Returns true if the given arrays are equal. Optionally can pass an equality function. + * @param {Array} a + * @param {Array} b + * @param {equalityCallback} callbackFunction - defaults to `c === d` + * @returns {boolean} + */ +export function areArraysEqual(a, b, equalityFunction = (c, d) => { return c === d; }) { + try { + if (a.length !== b.length) + return false; + + for (let i = 0; i < a.length; i++) { + if (!equalityFunction(a[i], b[i])) + return false; + } + } catch (ex) { + return false; + } + + return true; +} diff --git a/test/fixtures/wpt/common/blank-with-cors.html b/test/fixtures/wpt/common/blank-with-cors.html new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/blank-with-cors.html.headers b/test/fixtures/wpt/common/blank-with-cors.html.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/fixtures/wpt/common/blank-with-cors.html.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/fixtures/wpt/common/blank.html b/test/fixtures/wpt/common/blank.html new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/custom-cors-response.js b/test/fixtures/wpt/common/custom-cors-response.js new file mode 100644 index 0000000..be9c7ce --- /dev/null +++ b/test/fixtures/wpt/common/custom-cors-response.js @@ -0,0 +1,32 @@ +const custom_cors_response = (payload, base_url) => { + base_url = base_url || new URL(location.href); + + // Clone the given `payload` so that, as we modify it, we won't be mutating + // the caller's value in unexpected ways. + payload = Object.assign({}, payload); + payload.headers = payload.headers || {}; + // Note that, in order to have out-of-the-box support for tests that don't + // call `setup({'allow_uncaught_exception': true})` we return a no-op JS + // payload. This approach will avoid hitting syntax errors if the resource is + // interpreted as script. Without this workaround, the SyntaxError would be + // caught by the test harness and trigger a test failure. + payload.content = payload.content || '/* custom-cors-response.js content */'; + payload.status_code = payload.status_code || 200; + + // Assume that we'll be doing a CORS-enabled fetch so we'll need to set ACAO. + const acao = "Access-Control-Allow-Origin"; + if (!(acao in payload.headers)) { + payload.headers[acao] = '*'; + } + + if (!("Content-Type" in payload.headers)) { + payload.headers["Content-Type"] = "text/javascript"; + } + + let ret = new URL("/common/CustomCorsResponse.py", base_url); + for (const key in payload) { + ret.searchParams.append(key, JSON.stringify(payload[key])); + } + + return ret; +}; diff --git a/test/fixtures/wpt/common/dispatcher/README.md b/test/fixtures/wpt/common/dispatcher/README.md new file mode 100644 index 0000000..cfaafb6 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/README.md @@ -0,0 +1,228 @@ +# `RemoteContext`: API for script execution in another context + +`RemoteContext` in `/common/dispatcher/dispatcher.js` provides an interface to +execute JavaScript in another global object (page or worker, the "executor"), +based on: + +- [WPT RFC 88: context IDs from uuid searchParams in URL](https://github.com/web-platform-tests/rfcs/pull/88), +- [WPT RFC 89: execute_script](https://github.com/web-platform-tests/rfcs/pull/89) and +- [WPT RFC 91: RemoteContext](https://github.com/web-platform-tests/rfcs/pull/91). + +Tests can send arbitrary javascript to executors to evaluate in its global +object, like: + +``` +// injector.html +const argOnLocalContext = ...; + +async function execute() { + window.open('executor.html?uuid=' + uuid); + const ctx = new RemoteContext(uuid); + await ctx.execute_script( + (arg) => functionOnRemoteContext(arg), + [argOnLocalContext]); +}; +``` + +and on executor: + +``` +// executor.html +function functionOnRemoteContext(arg) { ... } + +const uuid = new URLSearchParams(window.location.search).get('uuid'); +const executor = new Executor(uuid); +``` + +For concrete examples, see +[events.html](../../html/browsers/browsing-the-web/back-forward-cache/events.html) +and +[executor.html](../../html/browsers/browsing-the-web/back-forward-cache/resources/executor.html) +in back-forward cache tests. + +Note that `executor*` files under `/common/dispatcher/` are NOT for +`RemoteContext.execute_script()`. Use `remote-executor.html` instead. + +This is universal and avoids introducing many specific `XXX-helper.html` +resources. +Moreover, tests are easier to read, because the whole logic of the test can be +defined in a single file. + +## `new RemoteContext(uuid)` + +- `uuid` is a UUID string that identifies the remote context and should match + with the `uuid` parameter of the URL of the remote context. +- Callers should create the remote context outside this constructor (e.g. + `window.open('executor.html?uuid=' + uuid)`). + +## `RemoteContext.execute_script(fn, args)` + +- `fn` is a JavaScript function to execute on the remote context, which is + converted to a string using `toString()` and sent to the remote context. +- `args` is null or an array of arguments to pass to the function on the + remote context. Arguments are passed as JSON. +- If the return value of `fn` when executed in the remote context is a promise, + the promise returned by `execute_script` resolves to the resolved value of + that promise. Otherwise the `execute_script` promise resolves to the return + value of `fn`. + +Note that `fn` is evaluated on the remote context (`executor.html` in the +example above), while `args` are evaluated on the caller context +(`injector.html`) and then passed to the remote context. + +## Return value of injected functions and `execute_script()` + +If the return value of the injected function when executed in the remote +context is a promise, the promise returned by `execute_script` resolves to the +resolved value of that promise. Otherwise the `execute_script` promise resolves +to the return value of the function. + +When the return value of an injected script is a Promise, it should be resolved +before any navigation starts on the remote context. For example, it shouldn't +be resolved after navigating out and navigating back to the page again. +It's fine to create a Promise to be resolved after navigations, if it's not the +return value of the injected function. + +## Calling timing of `execute_script()` + +When `RemoteContext.execute_script()` is called when the remote context is not +active (for example before it is created, before navigation to the page, or +during the page is in back-forward cache), the injected script is evaluated +after the remote context becomes active. + +Multiple calls to `RemoteContext.execute_script()` will result in multiple scripts +being executed in remote context and ordering will be maintained. + +## Errors from `execute_script()` + +Errors from `execute_script()` will result in promise rejections, so it is +important to await the result. This can be `await ctx.execute_script(...)` for +every call but if there are multiple scripts to executed, it may be preferable +to wait on them in parallel to avoid incurring full round-trip time for each, +e.g. + +```js +await Promise.all( + ctx1.execute_script(...), + ctx1.execute_script(...), + ctx2.execute_script(...), + ctx2.execute_script(...), + ... +) +``` + +## Evaluation timing of injected functions + +The script injected by `RemoteContext.execute_script()` can be evaluated any +time during the remote context is active. +For example, even before DOMContentLoaded events or even during navigation. +It's the responsibility of test-specific code/helpers to ensure evaluation +timing constraints (which can be also test-specific), if any needed. + +### Ensuring evaluation timing around page load + +For example, to ensure that injected functions (`mainFunction` below) are +evaluated after the first `pageshow` event, we can use pure JavaScript code +like below: + +``` +// executor.html +window.pageShowPromise = new Promise(resolve => + window.addEventListener('pageshow', resolve, {once: true})); + + +// injector.html +const waitForPageShow = async () => { + while (!window.pageShowPromise) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + await window.pageShowPromise; +}; + +await ctx.execute(waitForPageShow); +await ctx.execute(mainFunction); +``` + +### Ensuring evaluation timing around navigation out/unloading + +It can be important to ensure there are no injected functions nor code behind +`RemoteContext` (such as Fetch APIs accessing server-side stash) running after +navigation is initiated, for example in the case of back-forward cache testing. + +To ensure this, + +- Do not call the next `RemoteContext.execute()` for the remote context after + triggering the navigation, until we are sure that the remote context is not + active (e.g. after we confirm that the new page is loaded). +- Call `Executor.suspend(callback)` synchronously within the injected script. + This suspends executor-related code, and calls `callback` when it is ready + to start navigation. + +The code on the injector side would be like: + +``` +// injector.html +await ctx.execute_script(() => { + executor.suspend(() => { + location.href = 'new-url.html'; + }); +}); +``` + +## Future Work: Possible integration with `test_driver` + +Currently `RemoteContext` is implemented by JavaScript and WPT-server-side +stash, and not integrated with `test_driver` nor `testharness`. +There is a proposal of `test_driver`-integrated version (see the RFCs listed +above). + +The API semantics and guidelines in this document are designed to be applicable +to both the current stash-based `RemoteContext` and `test_driver`-based +version, and thus the tests using `RemoteContext` will be migrated with minimum +modifications (mostly in `/common/dispatcher/dispatcher.js` and executors), for +example in a +[draft CL](https://chromium-review.googlesource.com/c/chromium/src/+/3082215/). + + +# `send()`/`receive()` Message passing APIs + +`dispatcher.js` (and its server-side backend `dispatcher.py`) provides a +universal queue-based message passing API. +Each queue is identified by a UUID, and accessed via the following APIs: + +- `send(uuid, message)` pushes a string `message` to the queue `uuid`. +- `receive(uuid)` pops the first item from the queue `uuid`. +- `showRequestHeaders(origin, uuid)` and + `cacheableShowRequestHeaders(origin, uuid)` return URLs, that push request + headers to the queue `uuid` upon fetching. + +It works cross-origin, and even access different browser context groups. + +Messages are queued, this means one doesn't need to wait for the receiver to +listen, before sending the first message +(but still need to wait for the resolution of the promise returned by `send()` +to ensure the order between `send()`s). + +## Executors + +Similar to `RemoteContext.execute_script()`, `send()`/`receive()` can be used +for sending arbitrary javascript to be evaluated in another page or worker. + +- `executor.html` (as a Document), +- `executor-worker.js` (as a Web Worker), and +- `executor-service-worker.js` (as a Service Worker) + +are examples of executors. +Note that these executors are NOT compatible with +`RemoteContext.execute_script()`. + +## Future Work + +`send()`, `receive()` and the executors below are kept for COEP/COOP tests. + +For remote script execution, new tests should use +`RemoteContext.execute_script()` instead. + +For message passing, +[WPT RFC 90](https://github.com/web-platform-tests/rfcs/pull/90) is still under +discussion. diff --git a/test/fixtures/wpt/common/dispatcher/dispatcher.js b/test/fixtures/wpt/common/dispatcher/dispatcher.js new file mode 100644 index 0000000..dab0100 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/dispatcher.js @@ -0,0 +1,281 @@ +// Define a universal message passing API. It works cross-origin and across +// browsing context groups. +const dispatcher_path = "/common/dispatcher/dispatcher.py"; + +// Finds the nearest ancestor window that has a non srcdoc location. This should +// give us a usable location for constructing further URLs. +function findLocationFromAncestors(w) { + if (w.location.href == 'about:srcdoc') { + return findLocationFromAncestors(w.parent); + } + return w.location; +} + +// Handles differences between workers vs frames (src vs srcdoc). +function findLocation() { + if (location.href == 'about:srcdoc') { + return findLocationFromAncestors(window.parent); + } + if (location.protocol == 'blob:' || location.protocol == 'data:') { + // Allows working around blob and data URLs. + if (self.document && self.document.baseURI) { + return self.document.baseURI; + } + } + return location; +} + +const dispatcherLocation = findLocation(); +const dispatcher_url = new URL(dispatcher_path, dispatcherLocation).href; + +// Return a promise, limiting the number of concurrent accesses to a shared +// resources to |max_concurrent_access|. +const concurrencyLimiter = (max_concurrency) => { + let pending = 0; + let waiting = []; + return async (task) => { + pending++; + if (pending > max_concurrency) + await new Promise(resolve => waiting.push(resolve)); + let result = await task(); + pending--; + waiting.shift()?.(); + return result; + }; +} + +// Wait for a random amount of time in the range [10ms,100ms]. +const randomDelay = () => { + return new Promise(resolve => setTimeout(resolve, 10 + 90*Math.random())); +} + +// Sending too many requests in parallel causes congestion. Limiting it improves +// throughput. +// +// Note: The following table has been determined on the test: +// ../cache-storage.tentative.https.html +// using Chrome with a 64 core CPU / 64GB ram, in release mode: +// ┌───────────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬────┐ +// │concurrency│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 10│ 15│ 20│ 30│ 50│ 100│ +// ├───────────┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼────┤ +// │time (s) │ 54│ 38│ 31│ 29│ 26│ 24│ 22│ 22│ 22│ 22│ 34│ 36 │ +// └───────────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴────┘ +const limiter = concurrencyLimiter(6); + +// While requests to different remote contexts can go in parallel, we need to +// ensure that requests to each remote context are done in order. This maps a +// uuid to a queue of requests to send. A queue is processed until it is empty +// and then is deleted from the map. +const sendQueues = new Map(); + +// Sends a single item (with rate-limiting) and calls the associated resolver +// when it is successfully sent. +const sendItem = async function (uuid, resolver, message) { + await limiter(async () => { + // Requests might be dropped. Retry until getting a confirmation it has been + // processed. + while(1) { + try { + let response = await fetch(dispatcher_url + `?uuid=${uuid}`, { + method: 'POST', + body: message + }) + if (await response.text() == "done") { + resolver(); + return; + } + } catch (fetch_error) {} + await randomDelay(); + }; + }); +} + +// While the queue is non-empty, send the next item. This is async and new items +// may be added to the queue while others are being sent. +const processQueue = async function (uuid, queue) { + while (queue.length) { + const [resolver, message] = queue.shift(); + await sendItem(uuid, resolver, message); + } + // The queue is empty, delete it. + sendQueues.delete(uuid); +} + +const send = async function (uuid, message) { + const itemSentPromise = new Promise((resolve) => { + const item = [resolve, message]; + if (sendQueues.has(uuid)) { + // There is already a queue for `uuid`, just add to it and it will be processed. + sendQueues.get(uuid).push(item); + } else { + // There is no queue for `uuid`, create it and start processing. + const queue = [item]; + sendQueues.set(uuid, queue); + processQueue(uuid, queue); + } + }); + // Wait until the item has been successfully sent. + await itemSentPromise; +} + +const receive = async function (uuid) { + while(1) { + let data = "not ready"; + try { + data = await limiter(async () => { + let response = await fetch(dispatcher_url + `?uuid=${uuid}`); + return await response.text(); + }); + } catch (fetch_error) {} + + if (data == "not ready") { + await randomDelay(); + continue; + } + + return data; + } +} + +// Returns an URL. When called, the server sends toward the `uuid` queue the +// request headers. Useful for determining if something was requested with +// Cookies. +const showRequestHeaders = function(origin, uuid) { + return origin + dispatcher_path + `?uuid=${uuid}&show-headers`; +} + +// Same as above, except for the response is cacheable. +const cacheableShowRequestHeaders = function(origin, uuid) { + return origin + dispatcher_path + `?uuid=${uuid}&cacheable&show-headers`; +} + +// This script requires +// - `/common/utils.js` for `token()`. + +// Returns the URL of a document that can be used as a `RemoteContext`. +// +// `uuid` should be a UUID uniquely identifying the given remote context. +// `options` has the following shape: +// +// { +// host: (optional) Sets the returned URL's `host` property. Useful for +// cross-origin executors. +// protocol: (optional) Sets the returned URL's `protocol` property. +// } +function remoteExecutorUrl(uuid, options) { + const url = new URL("/common/dispatcher/remote-executor.html", dispatcherLocation); + url.searchParams.set("uuid", uuid); + + if (options?.host) { + url.host = options.host; + } + + if (options?.protocol) { + url.protocol = options.protocol; + } + + return url; +} + +// Represents a remote executor. For more detailed explanation see `README.md`. +class RemoteContext { + // `uuid` is a UUID string that identifies the remote context and should + // match with the `uuid` parameter of the URL of the remote context. + constructor(uuid) { + this.context_id = uuid; + } + + // Evaluates the script `expr` on the executor. + // - If `expr` is evaluated to a Promise that is resolved with a value: + // `execute_script()` returns a Promise resolved with the value. + // - If `expr` is evaluated to a non-Promise value: + // `execute_script()` returns a Promise resolved with the value. + // - If `expr` throws an error or is evaluated to a Promise that is rejected: + // `execute_script()` returns a rejected Promise with the error's + // `message`. + // Note that currently the type of error (e.g. DOMException) is not + // preserved, except for `TypeError`. + // The values should be able to be serialized by JSON.stringify(). + async execute_script(fn, args) { + const receiver = token(); + await this.send({receiver: receiver, fn: fn.toString(), args: args}); + const response = JSON.parse(await receive(receiver)); + if (response.status === 'success') { + return response.value; + } + + // exception + if (response.name === 'TypeError') { + throw new TypeError(response.value); + } + throw new Error(response.value); + } + + async send(msg) { + return await send(this.context_id, JSON.stringify(msg)); + } +}; + +class Executor { + constructor(uuid) { + this.uuid = uuid; + + // If `suspend_callback` is not `null`, the executor should be suspended + // when there are no ongoing tasks. + this.suspend_callback = null; + + this.execute(); + } + + // Wait until there are no ongoing tasks nor fetch requests for polling + // tasks, and then suspend the executor and call `callback()`. + // Navigation from the executor page should be triggered inside `callback()`, + // to avoid conflict with in-flight fetch requests. + suspend(callback) { + this.suspend_callback = callback; + } + + resume() { + } + + async execute() { + while(true) { + if (this.suspend_callback !== null) { + this.suspend_callback(); + this.suspend_callback = null; + // Wait for `resume()` to be called. + await new Promise(resolve => this.resume = resolve); + + // Workaround for https://crbug.com/1244230. + // Without this workaround, the executor is resumed and the fetch + // request to poll the next task is initiated synchronously from + // pageshow event after the page restored from BFCache, and the fetch + // request promise is never resolved (and thus the test results in + // timeout) due to https://crbug.com/1244230. The root cause is not yet + // known, but setTimeout() with 0ms causes the resume triggered on + // another task and seems to resolve the issue. + await new Promise(resolve => setTimeout(resolve, 0)); + + continue; + } + + const task = JSON.parse(await receive(this.uuid)); + + let response; + try { + const value = await eval(task.fn).apply(null, task.args); + response = JSON.stringify({ + status: 'success', + value: value + }); + } catch(e) { + response = JSON.stringify({ + status: 'exception', + name: e.name, + value: e.message + }); + } + await send(task.receiver, response); + } + } +} diff --git a/test/fixtures/wpt/common/dispatcher/dispatcher.py b/test/fixtures/wpt/common/dispatcher/dispatcher.py new file mode 100644 index 0000000..9fe7a38 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/dispatcher.py @@ -0,0 +1,53 @@ +import json +from wptserve.utils import isomorphic_decode + +# A server used to store and retrieve arbitrary data. +# This is used by: ./dispatcher.js +def main(request, response): + # This server is configured so that is accept to receive any requests and + # any cookies the web browser is willing to send. + response.headers.set(b"Access-Control-Allow-Credentials", b"true") + response.headers.set(b'Access-Control-Allow-Methods', b'OPTIONS, GET, POST') + response.headers.set(b'Access-Control-Allow-Headers', b'Content-Type') + response.headers.set(b"Access-Control-Allow-Origin", request.headers.get(b"origin") or '*') + + if b"cacheable" in request.GET: + response.headers.set(b"Cache-Control", b"max-age=31536000") + else: + response.headers.set(b'Cache-Control', b'no-cache, no-store, must-revalidate') + + # CORS preflight + if request.method == u'OPTIONS': + return b'' + + uuid = request.GET[b'uuid'] + stash = request.server.stash; + + # The stash is accessed concurrently by many clients. A lock is used to + # avoid unterleaved read/write from different clients. + with stash.lock: + queue = stash.take(uuid, '/common/dispatcher') or []; + + # Push into the |uuid| queue, the requested headers. + if b"show-headers" in request.GET: + headers = {}; + for key, value in request.headers.items(): + headers[isomorphic_decode(key)] = isomorphic_decode(request.headers[key]) + headers = json.dumps(headers); + queue.append(headers); + ret = b''; + + # Push into the |uuid| queue, the posted data. + elif request.method == u'POST': + queue.append(request.body) + ret = b'done' + + # Pull from the |uuid| queue, the posted data. + else: + if len(queue) == 0: + ret = b'not ready' + else: + ret = queue.pop(0) + + stash.put(uuid, queue, '/common/dispatcher') + return ret; diff --git a/test/fixtures/wpt/common/dispatcher/executor-service-worker.js b/test/fixtures/wpt/common/dispatcher/executor-service-worker.js new file mode 100644 index 0000000..0b47d66 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/executor-service-worker.js @@ -0,0 +1,24 @@ +importScripts('./dispatcher.js'); + +const params = new URLSearchParams(location.search); +const uuid = params.get('uuid'); + +// The fetch handler must be registered before parsing the main script response. +// So do it here, for future use. +fetchHandler = () => {} +addEventListener('fetch', e => { + fetchHandler(e); +}); + +// Force ServiceWorker to immediately activate itself. +addEventListener('install', event => { + skipWaiting(); +}); + +let executeOrders = async function() { + while(true) { + let task = await receive(uuid); + eval(`(async () => {${task}})()`); + } +}; +executeOrders(); diff --git a/test/fixtures/wpt/common/dispatcher/executor-worker.js b/test/fixtures/wpt/common/dispatcher/executor-worker.js new file mode 100644 index 0000000..ea065a6 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/executor-worker.js @@ -0,0 +1,12 @@ +importScripts('./dispatcher.js'); + +const params = new URLSearchParams(location.search); +const uuid = params.get('uuid'); + +let executeOrders = async function() { + while(true) { + let task = await receive(uuid); + eval(`(async () => {${task}})()`); + } +}; +executeOrders(); diff --git a/test/fixtures/wpt/common/dispatcher/executor.html b/test/fixtures/wpt/common/dispatcher/executor.html new file mode 100644 index 0000000..5fe6a95 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/executor.html @@ -0,0 +1,15 @@ + + diff --git a/test/fixtures/wpt/common/dispatcher/remote-executor.html b/test/fixtures/wpt/common/dispatcher/remote-executor.html new file mode 100644 index 0000000..8b00303 --- /dev/null +++ b/test/fixtures/wpt/common/dispatcher/remote-executor.html @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/test/fixtures/wpt/common/domain-setter.sub.html b/test/fixtures/wpt/common/domain-setter.sub.html new file mode 100644 index 0000000..ad3b9f8 --- /dev/null +++ b/test/fixtures/wpt/common/domain-setter.sub.html @@ -0,0 +1,8 @@ + + +A page that will likely be same-origin-domain but not same-origin + + diff --git a/test/fixtures/wpt/common/dummy.json b/test/fixtures/wpt/common/dummy.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/fixtures/wpt/common/dummy.json @@ -0,0 +1 @@ +{} diff --git a/test/fixtures/wpt/common/dummy.xhtml b/test/fixtures/wpt/common/dummy.xhtml new file mode 100644 index 0000000..dba6945 --- /dev/null +++ b/test/fixtures/wpt/common/dummy.xhtml @@ -0,0 +1,2 @@ + +Dummy XHTML document diff --git a/test/fixtures/wpt/common/dummy.xml b/test/fixtures/wpt/common/dummy.xml new file mode 100644 index 0000000..4a60c30 --- /dev/null +++ b/test/fixtures/wpt/common/dummy.xml @@ -0,0 +1 @@ +Dummy XML document diff --git a/test/fixtures/wpt/common/echo.py b/test/fixtures/wpt/common/echo.py new file mode 100644 index 0000000..911b54a --- /dev/null +++ b/test/fixtures/wpt/common/echo.py @@ -0,0 +1,6 @@ +def main(request, response): + # Without X-XSS-Protection to disable non-standard XSS protection the functionality this + # resource offers is useless + response.headers.set(b"X-XSS-Protection", b"0") + response.headers.set(b"Content-Type", b"text/html") + response.content = request.GET.first(b"content") diff --git a/test/fixtures/wpt/common/gc.js b/test/fixtures/wpt/common/gc.js new file mode 100644 index 0000000..ac43a4c --- /dev/null +++ b/test/fixtures/wpt/common/gc.js @@ -0,0 +1,52 @@ +/** + * Does a best-effort attempt at invoking garbage collection. Attempts to use + * the standardized `TestUtils.gc()` function, but falls back to other + * environment-specific nonstandard functions, with a final result of just + * creating a lot of garbage (in which case you will get a console warning). + * + * This should generally only be used to attempt to trigger bugs and crashes + * inside tests, i.e. cases where if garbage collection happened, then this + * should not trigger some misbehavior. You cannot rely on garbage collection + * successfully trigger, or that any particular unreachable object will be + * collected. + * + * @returns {Promise} A promise you should await to ensure garbage + * collection has had a chance to complete. + */ +self.garbageCollect = async () => { + // https://testutils.spec.whatwg.org/#the-testutils-namespace + if (self.TestUtils?.gc) { + return TestUtils.gc(); + } + + // Use --expose_gc for V8 (and Node.js) + // to pass this flag at chrome launch use: --js-flags="--expose-gc" + // Exposed in SpiderMonkey shell as well + if (self.gc) { + return self.gc(); + } + + // Present in some WebKit development environments + if (self.GCController) { + return GCController.collect(); + } + + console.warn( + 'Tests are running without the ability to do manual garbage collection. ' + + 'They will still work, but coverage will be suboptimal.'); + + for (var i = 0; i < 1000; i++) { + gcRec(10); + } + + function gcRec(n) { + if (n < 1) { + return {}; + } + + let temp = { i: "ab" + i + i / 100000 }; + temp += "foo"; + + gcRec(n - 1); + } +}; diff --git a/test/fixtures/wpt/common/get-host-info.sub.js b/test/fixtures/wpt/common/get-host-info.sub.js new file mode 100644 index 0000000..9b8c2b5 --- /dev/null +++ b/test/fixtures/wpt/common/get-host-info.sub.js @@ -0,0 +1,63 @@ +/** + * Host information for cross-origin tests. + * @returns {Object} with properties for different host information. + */ +function get_host_info() { + + var HTTP_PORT = '{{ports[http][0]}}'; + var HTTP_PORT2 = '{{ports[http][1]}}'; + var HTTPS_PORT = '{{ports[https][0]}}'; + var HTTPS_PORT2 = '{{ports[https][1]}}'; + var PROTOCOL = self.location.protocol; + var IS_HTTPS = (PROTOCOL == "https:"); + var PORT = IS_HTTPS ? HTTPS_PORT : HTTP_PORT; + var PORT2 = IS_HTTPS ? HTTPS_PORT2 : HTTP_PORT2; + var HTTP_PORT_ELIDED = HTTP_PORT == "80" ? "" : (":" + HTTP_PORT); + var HTTP_PORT2_ELIDED = HTTP_PORT2 == "80" ? "" : (":" + HTTP_PORT2); + var HTTPS_PORT_ELIDED = HTTPS_PORT == "443" ? "" : (":" + HTTPS_PORT); + var PORT_ELIDED = IS_HTTPS ? HTTPS_PORT_ELIDED : HTTP_PORT_ELIDED; + var ORIGINAL_HOST = '{{host}}'; + var REMOTE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('www1.' + ORIGINAL_HOST); + var OTHER_HOST = '{{domains[www2]}}'; + var NOTSAMESITE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('{{hosts[alt][]}}'); + + return { + HTTP_PORT: HTTP_PORT, + HTTP_PORT2: HTTP_PORT2, + HTTPS_PORT: HTTPS_PORT, + HTTPS_PORT2: HTTPS_PORT2, + PORT: PORT, + PORT2: PORT2, + ORIGINAL_HOST: ORIGINAL_HOST, + REMOTE_HOST: REMOTE_HOST, + + ORIGIN: PROTOCOL + "//" + ORIGINAL_HOST + PORT_ELIDED, + HTTP_ORIGIN: 'http://' + ORIGINAL_HOST + HTTP_PORT_ELIDED, + HTTPS_ORIGIN: 'https://' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTPS_ORIGIN_WITH_CREDS: 'https://foo:bar@' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTP_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + ORIGINAL_HOST + HTTP_PORT2_ELIDED, + REMOTE_ORIGIN: PROTOCOL + "//" + REMOTE_HOST + PORT_ELIDED, + OTHER_ORIGIN: PROTOCOL + "//" + OTHER_HOST + PORT_ELIDED, + HTTP_REMOTE_ORIGIN: 'http://' + REMOTE_HOST + HTTP_PORT_ELIDED, + HTTP_NOTSAMESITE_ORIGIN: 'http://' + NOTSAMESITE_HOST + HTTP_PORT_ELIDED, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + REMOTE_HOST + HTTP_PORT2_ELIDED, + HTTPS_REMOTE_ORIGIN: 'https://' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_REMOTE_ORIGIN_WITH_CREDS: 'https://foo:bar@' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_NOTSAMESITE_ORIGIN: 'https://' + NOTSAMESITE_HOST + HTTPS_PORT_ELIDED, + UNAUTHENTICATED_ORIGIN: 'http://' + OTHER_HOST + HTTP_PORT_ELIDED, + AUTHENTICATED_ORIGIN: 'https://' + OTHER_HOST + HTTPS_PORT_ELIDED + }; +} + +/** + * When a default port is used, location.port returns the empty string. + * This function attempts to provide an exact port, assuming we are running under wptserve. + * @param {*} loc - can be Location///URL, but assumes http/https only. + * @returns {string} The port number. + */ +function get_port(loc) { + if (loc.port) { + return loc.port; + } + return loc.protocol === 'https:' ? '443' : '80'; +} diff --git a/test/fixtures/wpt/common/get-host-info.sub.js.headers b/test/fixtures/wpt/common/get-host-info.sub.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/get-host-info.sub.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/media.js b/test/fixtures/wpt/common/media.js new file mode 100644 index 0000000..a5a8e95 --- /dev/null +++ b/test/fixtures/wpt/common/media.js @@ -0,0 +1,57 @@ +/** + * Returns the URL of a supported video source based on the user agent + * @param {string} base - media URL without file extension + * @returns {string} + */ +function getVideoURI(base) +{ + var extension = '.mp4'; + + var videotag = document.createElement("video"); + + if ( videotag.canPlayType ) + { + if (videotag.canPlayType('video/webm; codecs="vp9, opus"') ) + { + extension = '.webm'; + } + } + + return base + extension; +} + +/** + * Returns the URL of a supported audio source based on the user agent + * @param {string} base - media URL without file extension + * @returns {string} + */ +function getAudioURI(base) +{ + var extension = '.mp3'; + + var audiotag = document.createElement("audio"); + + if ( audiotag.canPlayType && + audiotag.canPlayType('audio/ogg') ) + { + extension = '.oga'; + } + + return base + extension; +} + +/** + * Returns the MIME type for a media URL based on the file extension. + * @param {string} url + * @returns {string} + */ +function getMediaContentType(url) { + var extension = new URL(url, location).pathname.split(".").pop(); + var map = { + "mp4" : "video/mp4", + "webm": "video/webm", + "mp3" : "audio/mp3", + "oga" : "application/ogg", + }; + return map[extension]; +} diff --git a/test/fixtures/wpt/common/media.js.headers b/test/fixtures/wpt/common/media.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/media.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/object-association.js b/test/fixtures/wpt/common/object-association.js new file mode 100644 index 0000000..669c17c --- /dev/null +++ b/test/fixtures/wpt/common/object-association.js @@ -0,0 +1,74 @@ +"use strict"; + +// This is for testing whether an object (e.g., a global property) is associated with Window, or +// with Document. Recall that Window and Document are 1:1 except when doing a same-origin navigation +// away from the initial about:blank. In that case the Window object gets reused for the new +// Document. +// +// So: +// - If something is per-Window, then it should maintain its identity across an about:blank +// navigation. +// - If something is per-Document, then it should be recreated across an about:blank navigation. + +window.testIsPerWindow = propertyName => { + runTests(propertyName, assert_equals, "must not"); +}; + +window.testIsPerDocument = propertyName => { + runTests(propertyName, assert_not_equals, "must"); +}; + +function runTests(propertyName, equalityOrInequalityAsserter, mustOrMustNotReplace) { + async_test(t => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const frame = iframe.contentWindow; + + const before = frame[propertyName]; + assert_implements(before, `window.${propertyName} must be implemented`); + + iframe.onload = t.step_func_done(() => { + const after = frame[propertyName]; + equalityOrInequalityAsserter(after, before); + }); + + iframe.src = "/common/blank.html"; + }, `Navigating from the initial about:blank ${mustOrMustNotReplace} replace window.${propertyName}`); + + // Per spec, discarding a browsing context should not change any of the global objects. + test(() => { + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const frame = iframe.contentWindow; + + const before = frame[propertyName]; + assert_implements(before, `window.${propertyName} must be implemented`); + + iframe.remove(); + + const after = frame[propertyName]; + assert_equals(after, before, `window.${propertyName} should not change after iframe.remove()`); + }, `Discarding the browsing context must not change window.${propertyName}`); + + // Per spec, document.open() should not change any of the global objects. In historical versions + // of the spec, it did, so we test here. + async_test(t => { + const iframe = document.createElement("iframe"); + + iframe.onload = t.step_func_done(() => { + const frame = iframe.contentWindow; + const before = frame[propertyName]; + assert_implements(before, `window.${propertyName} must be implemented`); + + frame.document.open(); + + const after = frame[propertyName]; + assert_equals(after, before); + + frame.document.close(); + }); + + iframe.src = "/common/blank.html"; + document.body.appendChild(iframe); + }, `document.open() must not replace window.${propertyName}`); +} diff --git a/test/fixtures/wpt/common/object-association.js.headers b/test/fixtures/wpt/common/object-association.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/object-association.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/performance-timeline-utils.js b/test/fixtures/wpt/common/performance-timeline-utils.js new file mode 100644 index 0000000..b20241c --- /dev/null +++ b/test/fixtures/wpt/common/performance-timeline-utils.js @@ -0,0 +1,56 @@ +/* +author: W3C http://www.w3.org/ +help: http://www.w3.org/TR/navigation-timing/#sec-window.performance-attribute +*/ +var performanceNamespace = window.performance; +var namespace_check = false; +function wp_test(func, msg, properties) +{ + // only run the namespace check once + if (!namespace_check) + { + namespace_check = true; + + if (performanceNamespace === undefined || performanceNamespace == null) + { + // show a single error that window.performance is undefined + // The window.performance attribute provides a hosting area for performance related attributes. + test(function() { assert_true(performanceNamespace !== undefined && performanceNamespace != null, "window.performance is defined and not null"); }, "window.performance is defined and not null."); + } + } + + test(func, msg, properties); +} + +function test_true(value, msg, properties) +{ + wp_test(function () { assert_true(value, msg); }, msg, properties); +} + +function test_equals(value, equals, msg, properties) +{ + wp_test(function () { assert_equals(value, equals, msg); }, msg, properties); +} + +// assert for every entry in `expectedEntries`, there is a matching entry _somewhere_ in `actualEntries` +function test_entries(actualEntries, expectedEntries) { + test_equals(actualEntries.length, expectedEntries.length) + expectedEntries.forEach(function (expectedEntry) { + var foundEntry = actualEntries.find(function (actualEntry) { + return typeof Object.keys(expectedEntry).find(function (key) { + return actualEntry[key] !== expectedEntry[key] + }) === 'undefined' + }) + test_true(!!foundEntry, `Entry ${JSON.stringify(expectedEntry)} could not be found.`) + if (foundEntry) { + assert_object_equals(foundEntry.toJSON(), expectedEntry) + } + }) +} + +function delayedLoadListener(callback) { + window.addEventListener('load', function() { + // TODO(cvazac) Remove this setTimeout when spec enforces sync entries. + step_timeout(callback, 0) + }) +} diff --git a/test/fixtures/wpt/common/performance-timeline-utils.js.headers b/test/fixtures/wpt/common/performance-timeline-utils.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/performance-timeline-utils.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/proxy-all.sub.pac b/test/fixtures/wpt/common/proxy-all.sub.pac new file mode 100644 index 0000000..de601e5 --- /dev/null +++ b/test/fixtures/wpt/common/proxy-all.sub.pac @@ -0,0 +1,3 @@ +function FindProxyForURL(url, host) { + return "PROXY {{host}}:{{ports[http][0]}}" +} diff --git a/test/fixtures/wpt/common/redirect-opt-in.py b/test/fixtures/wpt/common/redirect-opt-in.py new file mode 100644 index 0000000..b5e674a --- /dev/null +++ b/test/fixtures/wpt/common/redirect-opt-in.py @@ -0,0 +1,20 @@ +def main(request, response): + """Simple handler that causes redirection. + + The request should typically have two query parameters: + status - The status to use for the redirection. Defaults to 302. + location - The resource to redirect to. + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + + location = request.GET.first(b"location") + + response.headers.set(b"Location", location) + response.headers.set(b"Timing-Allow-Origin", b"*") diff --git a/test/fixtures/wpt/common/redirect.py b/test/fixtures/wpt/common/redirect.py new file mode 100644 index 0000000..f2fd1eb --- /dev/null +++ b/test/fixtures/wpt/common/redirect.py @@ -0,0 +1,19 @@ +def main(request, response): + """Simple handler that causes redirection. + + The request should typically have two query parameters: + status - The status to use for the redirection. Defaults to 302. + location - The resource to redirect to. + """ + status = 302 + if b"status" in request.GET: + try: + status = int(request.GET.first(b"status")) + except ValueError: + pass + + response.status = status + + location = request.GET.first(b"location") + + response.headers.set(b"Location", location) diff --git a/test/fixtures/wpt/common/refresh.py b/test/fixtures/wpt/common/refresh.py new file mode 100644 index 0000000..0d30990 --- /dev/null +++ b/test/fixtures/wpt/common/refresh.py @@ -0,0 +1,11 @@ +def main(request, response): + """ + Respond with a blank HTML document and a `Refresh` header which describes + an immediate redirect to the URL specified by the requests `location` query + string parameter + """ + headers = [ + (b'Content-Type', b'text/html'), + (b'Refresh', b'0; URL=' + request.GET.first(b'location')) + ] + return (200, headers, b'') diff --git a/test/fixtures/wpt/common/reftest-wait.js b/test/fixtures/wpt/common/reftest-wait.js new file mode 100644 index 0000000..64fe9bf --- /dev/null +++ b/test/fixtures/wpt/common/reftest-wait.js @@ -0,0 +1,39 @@ +/** + * Remove the `reftest-wait` class on the document element. + * The reftest runner will wait with taking a screenshot while + * this class is present. + * + * See https://web-platform-tests.org/writing-tests/reftests.html#controlling-when-comparison-occurs + */ +function takeScreenshot() { + document.documentElement.classList.remove("reftest-wait"); +} + +/** + * Call `takeScreenshot()` after a delay of at least |timeout| milliseconds. + * @param {number} timeout - milliseconds + */ +function takeScreenshotDelayed(timeout) { + setTimeout(function() { + takeScreenshot(); + }, timeout); +} + +/** + * Ensure that a precondition is met before waiting for a screenshot. + * @param {bool} condition - Fail the test if this evaluates to false + * @param {string} msg - Error message to write to the screenshot + */ +function failIfNot(condition, msg) { + const fail = () => { + (document.body || document.documentElement).textContent = `Precondition Failed: ${msg}`; + takeScreenshot(); + }; + if (!condition) { + if (document.readyState == "interactive") { + fail(); + } else { + document.addEventListener("DOMContentLoaded", fail, false); + } + } +} diff --git a/test/fixtures/wpt/common/reftest-wait.js.headers b/test/fixtures/wpt/common/reftest-wait.js.headers new file mode 100644 index 0000000..6805c32 --- /dev/null +++ b/test/fixtures/wpt/common/reftest-wait.js.headers @@ -0,0 +1 @@ +Content-Type: text/javascript; charset=utf-8 diff --git a/test/fixtures/wpt/common/rendering-utils.js b/test/fixtures/wpt/common/rendering-utils.js new file mode 100644 index 0000000..46283bd --- /dev/null +++ b/test/fixtures/wpt/common/rendering-utils.js @@ -0,0 +1,19 @@ +"use strict"; + +/** + * Waits until we have at least one frame rendered, regardless of the engine. + * + * @returns {Promise} + */ +function waitForAtLeastOneFrame() { + return new Promise(resolve => { + // Different web engines work slightly different on this area but waiting + // for two requestAnimationFrames() to happen, one after another, should be + // sufficient to ensure at least one frame has been generated anywhere. + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} diff --git a/test/fixtures/wpt/common/sab.js b/test/fixtures/wpt/common/sab.js new file mode 100644 index 0000000..a3ea610 --- /dev/null +++ b/test/fixtures/wpt/common/sab.js @@ -0,0 +1,21 @@ +const createBuffer = (() => { + // See https://github.com/whatwg/html/issues/5380 for why not `new SharedArrayBuffer()` + let sabConstructor; + try { + sabConstructor = new WebAssembly.Memory({ shared:true, initial:0, maximum:0 }).buffer.constructor; + } catch(e) { + sabConstructor = null; + } + return (type, length, opts) => { + if (type === "ArrayBuffer") { + return new ArrayBuffer(length, opts); + } else if (type === "SharedArrayBuffer") { + if (sabConstructor && sabConstructor.name !== "SharedArrayBuffer") { + throw new Error("WebAssembly.Memory does not support shared:true"); + } + return new sabConstructor(length, opts); + } else { + throw new Error("type has to be ArrayBuffer or SharedArrayBuffer"); + } + } +})(); diff --git a/test/fixtures/wpt/common/security-features/README.md b/test/fixtures/wpt/common/security-features/README.md new file mode 100644 index 0000000..f957541 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/README.md @@ -0,0 +1,460 @@ +This directory contains the common infrastructure for the following tests (also referred below as projects). + +- referrer-policy/ +- mixed-content/ +- upgrade-insecure-requests/ + +Subdirectories: + +- `resources`: + Serves JavaScript test helpers. +- `subresource`: + Serves subresources, with support for redirects, stash, etc. + The subresource paths are managed by `subresourceMap` and + fetched in `requestVia*()` functions in `resources/common.js`. +- `scope`: + Serves nested contexts, such as iframe documents or workers. + Used from `invokeFrom*()` functions in `resources/common.js`. +- `tools`: + Scripts that generate test HTML files. Not used while running tests. +- `/referrer-policy/generic/subresource-test`: + Sanity checking tests for subresource invocation + (This is still placed outside common/) + +# Test generator + +The test generator ([common/security-features/tools/generate.py](tools/generate.py)) generates test HTML files from templates and a seed (`spec.src.json`) that defines all the test scenarios. + +The project (i.e. a WPT subdirectory, for example `referrer-policy/`) that uses the generator should define per-project data and invoke the common generator logic in `common/security-features/tools`. + +This is the overview of the project structure: + +``` +common/security-features/ +└── tools/ - the common test generator logic + ├── spec.src.json + └── template/ - the test files templates +project-directory/ (e.g. referrer-policy/) +├── spec.src.json +├── generic/ +│ ├── test-case.sub.js - Per-project test helper +│ ├── sanity-checker.js (Used by debug target only) +│ └── spec_json.js (Used by debug target only) +└── gen/ - generated tests +``` + +## Generating the tests + +Note: When the repository already contains generated tests, [remove all generated tests](#removing-all-generated-tests) first. + +```bash +# Install json5 module if needed. +pip install --user json5 + +# Generate the test files under gen/ (HTMLs and .headers files). +path/to/common/security-features/tools/generate.py --spec path/to/project-directory/ + +# Add all generated tests to the repo. +git add path/to/project-directory/gen/ && git commit -m "Add generated tests" +``` + +This will parse the spec JSON5 files and determine which tests to generate (or skip) while using templates. + +- The default spec JSON5: `common/security-features/tools/spec.src.json`. + - Describes common configurations, such as subresource types, source context types, etc. +- The per-project spec JSON5: `project-directory/spec.src.json`. + - Describes project-specific configurations, particularly those related to test generation patterns (`specification`), policy deliveries (e.g. `delivery_type`, `delivery_value`) and `expectation`. + +For how these two spec JSON5 files are merged, see [Sub projects](#sub-projects) section. + +Note: `spec.src.json` is transitioning to JSON5 [#21710](https://github.com/web-platform-tests/wpt/issues/21710). + +During the generation, the spec is validated by ```common/security-features/tools/spec_validator.py```. This is specially important when you're making changes to `spec.src.json`. Make sure it's a valid JSON (no comments or trailing commas). The validator reports specific errors (missing keys etc.), if any. + +### Removing all generated tests + +Simply remove all files under `project-directory/gen/`. + +```bash +rm -r path/to/project-directory/gen/ +``` + +### Options for generating tests + +Note: this section is currently obsolete. Only the release template is working. + +The generator script has two targets: ```release``` and ```debug```. + +* Using **release** for the target will produce tests using a template for optimizing size and performance. The release template is intended for the official web-platform-tests and possibly other test suites. No sanity checking is done in release mode. Use this option whenever you're checking into web-platform-tests. + +* When generating for ```debug```, the produced tests will contain more verbosity and sanity checks. Use this target to identify problems with the test suites when making changes locally. Make sure you don't check in tests generated with the debug target. + +Note that **release** is the default target when invoking ```generate.py```. + + +## Sub projects + +Projects can be nested, for example to reuse a single `spec.src.json` across similar but slightly different sets of generated tests. +The directory structure would look like: + +``` +project-directory/ (e.g. referrer-policy/) +├── spec.src.json - Parent project's spec JSON +├── generic/ +│ └── test-case.sub.js - Parent project's test helper +├── gen/ - parent project's generated tests +└── sub-project-directory/ (e.g. 4K) + ├── spec.src.json - Child project's spec JSON + ├── generic/ + │ └── test-case.sub.js - Child project's test helper + └── gen/ - child project's generated tests +``` + +`generate.py --spec project-directory/sub-project-directory` generates test files under `project-directory/sub-project-directory/gen`, based on `project-directory/spec.src.json` and `project-directory/sub-project-directory/spec.src.json`. + +- The child project's `spec.src.json` is merged into parent project's `spec.src.json`. + - Two spec JSON objects are merged recursively. + - If a same key exists in both objects, the child's value overwrites the parent's value. + - If both (child's and parent's) values are arrays, then the child's value is concatenated to the parent's value. + - For debugging, `generate.py` dumps the merged spec JSON object as `generic/debug-output.spec.src.json`. +- The child project's generated tests include both of the parent and child project's `test-case.sub.js`: + ```html + + + + ``` + + +## Updating the tests + +The main test logic lives in ```project-directory/generic/test-case.sub.js``` with helper functions defined in ```/common/security-features/resources/common.js``` so you should probably start there. + +For updating the test suites you will most likely do **a subset** of the following: + +* Add a new subresource type: + + * Add a new sub-resource python script to `/common/security-features/subresource/`. + * Add a sanity check test for a sub-resource to `referrer-policy/generic/subresource-test/`. + * Add a new entry to `subresourceMap` in `/common/security-features/resources/common.js`. + * Add a new entry to `valid_subresource_names` in `/common/security-features/tools/spec_validator.py`. + * Add a new entry to `subresource_schema` in `spec.src.json`. + * Update `source_context_schema` to specify in which source context the subresource can be used. + +* Add a new subresource redirection type + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18939](https://github.com/web-platform-tests/wpt/pull/18939) + +* Add a new subresource origin type + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18940](https://github.com/web-platform-tests/wpt/pull/18940) + +* Add a new source context (e.g. "module sharedworker global scope") + + * TODO: to be documented. Example: [https://github.com/web-platform-tests/wpt/pull/18904](https://github.com/web-platform-tests/wpt/pull/18904) + +* Add a new source context list (e.g. "subresource request from a dedicated worker in a ` + invoker: invokeFromIframe, + }, + "iframe": { // + invoker: invokeFromIframe, + }, + "iframe-blank": { // + invoker: invokeFromIframe, + }, + "worker-classic": { + // Classic dedicated worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "worker", false, {}), + }, + "worker-classic-data": { + // Classic dedicated worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "worker", true, {}), + }, + "worker-module": { + // Module dedicated worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "worker", false, {type: 'module'}), + }, + "worker-module-data": { + // Module dedicated worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "worker", true, {type: 'module'}), + }, + "sharedworker-classic": { + // Classic shared worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "sharedworker", false, {}), + }, + "sharedworker-classic-data": { + // Classic shared worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "sharedworker", true, {}), + }, + "sharedworker-module": { + // Module shared worker loaded from same-origin. + invoker: invokeFromWorker.bind(undefined, "sharedworker", false, {type: 'module'}), + }, + "sharedworker-module-data": { + // Module shared worker loaded from data: URL. + invoker: invokeFromWorker.bind(undefined, "sharedworker", true, {type: 'module'}), + }, + }; + + return sourceContextMap[sourceContextList[0].sourceContextType].invoker( + subresource, sourceContextList); +} + +// Quick hack to expose invokeRequest when common.sub.js is loaded either +// as a classic or module script. +self.invokeRequest = invokeRequest; + +/** + invokeFrom*() functions are helper functions with the same parameters + and return values as invokeRequest(), that are tied to specific types + of top-most environment settings objects. + For example, invokeFromIframe() is the helper function for the cases where + sourceContextList[0] is an iframe. +*/ + +/** + @param {string} workerType + "worker" (for dedicated worker) or "sharedworker". + @param {boolean} isDataUrl + true if the worker script is loaded from data: URL. + Otherwise, the script is loaded from same-origin. + @param {object} workerOptions + The `options` argument for Worker constructor. + + Other parameters and return values are the same as those of invokeRequest(). +*/ +function invokeFromWorker(workerType, isDataUrl, workerOptions, + subresource, sourceContextList) { + const currentSourceContext = sourceContextList[0]; + let workerUrl = + "/common/security-features/scope/worker.py?policyDeliveries=" + + encodeURIComponent(JSON.stringify( + currentSourceContext.policyDeliveries || [])); + if (workerOptions.type === 'module') { + workerUrl += "&type=module"; + } + + let promise; + if (isDataUrl) { + promise = fetch(workerUrl) + .then(r => r.text()) + .then(source => { + return 'data:text/javascript;base64,' + btoa(source); + }); + } else { + promise = Promise.resolve(workerUrl); + } + + return promise + .then(url => { + if (workerType === "worker") { + const worker = new Worker(url, workerOptions); + worker.postMessage({subresource: subresource, + sourceContextList: sourceContextList.slice(1)}); + return bindEvents2(worker, "message", worker, "error", window, "error"); + } else if (workerType === "sharedworker") { + const worker = new SharedWorker(url, workerOptions); + worker.port.start(); + worker.port.postMessage({subresource: subresource, + sourceContextList: sourceContextList.slice(1)}); + return bindEvents2(worker.port, "message", worker, "error", window, "error"); + } else { + throw new Error('Invalid worker type: ' + workerType); + } + }) + .then(event => { + if (event.data.error) + return Promise.reject(event.data.error); + return event.data; + }); +} + +function invokeFromIframe(subresource, sourceContextList) { + const currentSourceContext = sourceContextList[0]; + const frameUrl = + "/common/security-features/scope/document.py?policyDeliveries=" + + encodeURIComponent(JSON.stringify( + currentSourceContext.policyDeliveries || [])); + + let iframe; + let promise; + if (currentSourceContext.sourceContextType === 'srcdoc') { + promise = fetch(frameUrl) + .then(r => r.text()) + .then(srcdoc => { + iframe = createElement( + "iframe", {srcdoc: srcdoc}, document.body, true); + return iframe.eventPromise; + }); + } else if (currentSourceContext.sourceContextType === 'iframe') { + iframe = createElement("iframe", {src: frameUrl}, document.body, true); + promise = iframe.eventPromise; + } else if (currentSourceContext.sourceContextType === 'iframe-blank') { + let frameContent; + promise = fetch(frameUrl) + .then(r => r.text()) + .then(t => { + frameContent = t; + iframe = createElement("iframe", {}, document.body, true); + return iframe.eventPromise; + }) + .then(() => { + // Reinitialize `iframe.eventPromise` with a new promise + // that catches the load event for the document.write() below. + bindEvents(iframe); + + iframe.contentDocument.write(frameContent); + iframe.contentDocument.close(); + return iframe.eventPromise; + }); + } + + return promise + .then(() => { + const promise = bindEvents2( + window, "message", iframe, "error", window, "error"); + iframe.contentWindow.postMessage( + {subresource: subresource, + sourceContextList: sourceContextList.slice(1)}, + "*"); + return promise; + }) + .then(event => { + if (event.data.error) + return Promise.reject(event.data.error); + return event.data; + }); +} + +// SanityChecker does nothing in release mode. See sanity-checker.js for debug +// mode. +function SanityChecker() {} +SanityChecker.prototype.checkScenario = function() {}; +SanityChecker.prototype.setFailTimeout = function(test, timeout) {}; +SanityChecker.prototype.checkSubresourceResult = function() {}; diff --git a/test/fixtures/wpt/common/security-features/resources/common.sub.js.headers b/test/fixtures/wpt/common/security-features/resources/common.sub.js.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/fixtures/wpt/common/security-features/resources/common.sub.js.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/fixtures/wpt/common/security-features/scope/__init__.py b/test/fixtures/wpt/common/security-features/scope/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/security-features/scope/document.py b/test/fixtures/wpt/common/security-features/scope/document.py new file mode 100644 index 0000000..9a9f045 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/document.py @@ -0,0 +1,36 @@ +import os, sys, json + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +import importlib +util = importlib.import_module("common.security-features.scope.util") + +def main(request, response): + policyDeliveries = json.loads(request.GET.first(b"policyDeliveries", b"[]")) + maybe_additional_headers = {} + meta = u'' + error = u'' + for delivery in policyDeliveries: + if delivery[u'deliveryType'] == u'meta': + if delivery[u'key'] == u'referrerPolicy': + meta += u'' % delivery[u'value'] + else: + error = u'invalid delivery key' + elif delivery[u'deliveryType'] == u'http-rp': + if delivery[u'key'] == u'referrerPolicy': + maybe_additional_headers[b'Referrer-Policy'] = isomorphic_encode(delivery[u'value']) + else: + error = u'invalid delivery key' + else: + error = u'invalid deliveryType' + + handler = lambda: util.get_template(u"document.html.template") % ({ + u"meta": meta, + u"error": error + }) + util.respond( + request, + response, + payload_generator=handler, + content_type=b"text/html", + maybe_additional_headers=maybe_additional_headers) diff --git a/test/fixtures/wpt/common/security-features/scope/template/document.html.template b/test/fixtures/wpt/common/security-features/scope/template/document.html.template new file mode 100644 index 0000000..37e29f8 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/template/document.html.template @@ -0,0 +1,30 @@ + + + + %(meta)s + + + + diff --git a/test/fixtures/wpt/common/security-features/scope/template/worker.js.template b/test/fixtures/wpt/common/security-features/scope/template/worker.js.template new file mode 100644 index 0000000..7a2a6e0 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/template/worker.js.template @@ -0,0 +1,29 @@ +%(import)s + +if ('DedicatedWorkerGlobalScope' in self && + self instanceof DedicatedWorkerGlobalScope) { + self.onmessage = event => onMessageFromParent(event, self); +} else if ('SharedWorkerGlobalScope' in self && + self instanceof SharedWorkerGlobalScope) { + onconnect = event => { + const port = event.ports[0]; + port.onmessage = event => onMessageFromParent(event, port); + }; +} + +// Receive a message from the parent and start the test. +function onMessageFromParent(event, port) { + const configurationError = "%(error)s"; + if (configurationError.length > 0) { + port.postMessage({error: configurationError}); + return; + } + + invokeRequest(event.data.subresource, + event.data.sourceContextList) + .then(result => port.postMessage(result)) + .catch(e => { + const message = (e.error && e.error.stack) || e.message || "Error"; + port.postMessage({error: message}); + }); +} diff --git a/test/fixtures/wpt/common/security-features/scope/util.py b/test/fixtures/wpt/common/security-features/scope/util.py new file mode 100644 index 0000000..da5aacf --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/util.py @@ -0,0 +1,43 @@ +import os + +from wptserve.utils import isomorphic_decode + +def get_template(template_basename): + script_directory = os.path.dirname(os.path.abspath(isomorphic_decode(__file__))) + template_directory = os.path.abspath( + os.path.join(script_directory, u"template")) + template_filename = os.path.join(template_directory, template_basename) + + with open(template_filename, "r") as f: + return f.read() + + +def __noop(request, response): + return u"" + + +def respond(request, + response, + status_code=200, + content_type=b"text/html", + payload_generator=__noop, + cache_control=b"no-cache; must-revalidate", + access_control_allow_origin=b"*", + maybe_additional_headers=None): + response.add_required_headers = False + response.writer.write_status(status_code) + + if access_control_allow_origin != None: + response.writer.write_header(b"access-control-allow-origin", + access_control_allow_origin) + response.writer.write_header(b"content-type", content_type) + response.writer.write_header(b"cache-control", cache_control) + + additional_headers = maybe_additional_headers or {} + for header, value in additional_headers.items(): + response.writer.write_header(header, value) + + response.writer.end_headers() + + payload = payload_generator() + response.writer.write(payload) diff --git a/test/fixtures/wpt/common/security-features/scope/worker.py b/test/fixtures/wpt/common/security-features/scope/worker.py new file mode 100644 index 0000000..6b321e7 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/scope/worker.py @@ -0,0 +1,44 @@ +import os, sys, json + +from wptserve.utils import isomorphic_decode, isomorphic_encode +import importlib +util = importlib.import_module("common.security-features.scope.util") + +def main(request, response): + policyDeliveries = json.loads(request.GET.first(b'policyDeliveries', b'[]')) + worker_type = request.GET.first(b'type', b'classic') + commonjs_url = u'%s://%s:%s/common/security-features/resources/common.sub.js' % ( + request.url_parts.scheme, request.url_parts.hostname, + request.url_parts.port) + if worker_type == b'classic': + import_line = u'importScripts("%s");' % commonjs_url + else: + import_line = u'import "%s";' % commonjs_url + + maybe_additional_headers = {} + error = u'' + for delivery in policyDeliveries: + if delivery[u'deliveryType'] == u'meta': + error = u' cannot be used in WorkerGlobalScope' + elif delivery[u'deliveryType'] == u'http-rp': + if delivery[u'key'] == u'referrerPolicy': + maybe_additional_headers[b'Referrer-Policy'] = isomorphic_encode(delivery[u'value']) + elif delivery[u'key'] == u'mixedContent' and delivery[u'value'] == u'opt-in': + maybe_additional_headers[b'Content-Security-Policy'] = b'block-all-mixed-content' + elif delivery[u'key'] == u'upgradeInsecureRequests' and delivery[u'value'] == u'upgrade': + maybe_additional_headers[b'Content-Security-Policy'] = b'upgrade-insecure-requests' + else: + error = u'invalid delivery key for http-rp: %s' % delivery[u'key'] + else: + error = u'invalid deliveryType: %s' % delivery[u'deliveryType'] + + handler = lambda: util.get_template(u'worker.js.template') % ({ + u'import': import_line, + u'error': error + }) + util.respond( + request, + response, + payload_generator=handler, + content_type=b'text/javascript', + maybe_additional_headers=maybe_additional_headers) diff --git a/test/fixtures/wpt/common/security-features/subresource/__init__.py b/test/fixtures/wpt/common/security-features/subresource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/common/security-features/subresource/audio.py b/test/fixtures/wpt/common/security-features/subresource/audio.py new file mode 100644 index 0000000..f16a0f7 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/audio.py @@ -0,0 +1,18 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + file = os.path.join(request.doc_root, u"webaudio", u"resources", + u"sin_440Hz_-6dBFS_1s.wav") + return open(file, "rb").read() + + +def main(request, response): + handler = lambda data: generate_payload(request, data) + subresource.respond(request, + response, + payload_generator = handler, + access_control_allow_origin = b"*", + content_type = b"audio/wav") diff --git a/test/fixtures/wpt/common/security-features/subresource/document.py b/test/fixtures/wpt/common/security-features/subresource/document.py new file mode 100644 index 0000000..52b684a --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/document.py @@ -0,0 +1,12 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"document.html.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload) diff --git a/test/fixtures/wpt/common/security-features/subresource/empty.py b/test/fixtures/wpt/common/security-features/subresource/empty.py new file mode 100644 index 0000000..312e12c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/empty.py @@ -0,0 +1,14 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return u'' + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + access_control_allow_origin = b"*", + content_type = b"text/plain") diff --git a/test/fixtures/wpt/common/security-features/subresource/font.py b/test/fixtures/wpt/common/security-features/subresource/font.py new file mode 100644 index 0000000..7900079 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/font.py @@ -0,0 +1,76 @@ +import os, sys +from base64 import decodebytes + +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + # Simple base64 encoded .tff font + return decodebytes(b"AAEAAAANAIAAAwBQRkZUTU6u6MkAAAXcAAAAHE9TLzJWYW" + b"QKAAABWAAAAFZjbWFwAA8D7wAAAcAAAAFCY3Z0IAAhAnkA" + b"AAMEAAAABGdhc3D//wADAAAF1AAAAAhnbHlmCC6aTwAAAx" + b"QAAACMaGVhZO8ooBcAAADcAAAANmhoZWEIkAV9AAABFAAA" + b"ACRobXR4EZQAhQAAAbAAAAAQbG9jYQBwAFQAAAMIAAAACm" + b"1heHAASQA9AAABOAAAACBuYW1lehAVOgAAA6AAAAIHcG9z" + b"dP+uADUAAAWoAAAAKgABAAAAAQAAMhPyuV8PPPUACwPoAA" + b"AAAMU4Lm0AAAAAxTgubQAh/5wFeAK8AAAACAACAAAAAAAA" + b"AAEAAAK8/5wAWgXcAAAAAAV4AAEAAAAAAAAAAAAAAAAAAA" + b"AEAAEAAAAEAAwAAwAAAAAAAgAAAAEAAQAAAEAALgAAAAAA" + b"AQXcAfQABQAAAooCvAAAAIwCigK8AAAB4AAxAQIAAAIABg" + b"kAAAAAAAAAAAABAAAAAAAAAAAAAAAAUGZFZABAAEEAQQMg" + b"/zgAWgK8AGQAAAABAAAAAAAABdwAIQAAAAAF3AAABdwAZA" + b"AAAAMAAAADAAAAHAABAAAAAAA8AAMAAQAAABwABAAgAAAA" + b"BAAEAAEAAABB//8AAABB////wgABAAAAAAAAAQYAAAEAAA" + b"AAAAAAAQIAAAACAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + b"AAAAAAAAAAAAAAAAAAAhAnkAAAAqACoAKgBGAAAAAgAhAA" + b"ABKgKaAAMABwAusQEALzyyBwQA7TKxBgXcPLIDAgDtMgCx" + b"AwAvPLIFBADtMrIHBgH8PLIBAgDtMjMRIREnMxEjIQEJ6M" + b"fHApr9ZiECWAAAAwBk/5wFeAK8AAMABwALAAABNSEVATUh" + b"FQE1IRUB9AH0/UQDhPu0BRQB9MjI/tTIyP7UyMgAAAAAAA" + b"4ArgABAAAAAAAAACYATgABAAAAAAABAAUAgQABAAAAAAAC" + b"AAYAlQABAAAAAAADACEA4AABAAAAAAAEAAUBDgABAAAAAA" + b"AFABABNgABAAAAAAAGAAUBUwADAAEECQAAAEwAAAADAAEE" + b"CQABAAoAdQADAAEECQACAAwAhwADAAEECQADAEIAnAADAA" + b"EECQAEAAoBAgADAAEECQAFACABFAADAAEECQAGAAoBRwBD" + b"AG8AcAB5AHIAaQBnAGgAdAAgACgAYwApACAAMgAwADAAOA" + b"AgAE0AbwB6AGkAbABsAGEAIABDAG8AcgBwAG8AcgBhAHQA" + b"aQBvAG4AAENvcHlyaWdodCAoYykgMjAwOCBNb3ppbGxhIE" + b"NvcnBvcmF0aW9uAABNAGEAcgBrAEEAAE1hcmtBAABNAGUA" + b"ZABpAHUAbQAATWVkaXVtAABGAG8AbgB0AEYAbwByAGcAZQ" + b"AgADIALgAwACAAOgAgAE0AYQByAGsAQQAgADoAIAA1AC0A" + b"MQAxAC0AMgAwADAAOAAARm9udEZvcmdlIDIuMCA6IE1hcm" + b"tBIDogNS0xMS0yMDA4AABNAGEAcgBrAEEAAE1hcmtBAABW" + b"AGUAcgBzAGkAbwBuACAAMAAwADEALgAwADAAMAAgAABWZX" + b"JzaW9uIDAwMS4wMDAgAABNAGEAcgBrAEEAAE1hcmtBAAAA" + b"AgAAAAAAAP+DADIAAAABAAAAAAAAAAAAAAAAAAAAAAAEAA" + b"AAAQACACQAAAAAAAH//wACAAAAAQAAAADEPovuAAAAAMU4" + b"Lm0AAAAAxTgubQ==") + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'application/x-font-truetype' + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type, + access_control_allow_origin = b"*") diff --git a/test/fixtures/wpt/common/security-features/subresource/image.py b/test/fixtures/wpt/common/security-features/subresource/image.py new file mode 100644 index 0000000..5c9a0c0 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/image.py @@ -0,0 +1,116 @@ +import os, sys, array, math + +from io import BytesIO + +from wptserve.utils import isomorphic_decode + +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +class Image: + """This class partially implements the interface of the PIL.Image.Image. + One day in the future WPT might support the PIL module or another imaging + library, so this hacky BMP implementation will no longer be required. + """ + def __init__(self, width, height): + self.width = width + self.height = height + self.img = bytearray([0 for i in range(3 * width * height)]) + + @staticmethod + def new(mode, size, color=0): + return Image(size[0], size[1]) + + def _int_to_bytes(self, number): + packed_bytes = [0, 0, 0, 0] + for i in range(4): + packed_bytes[i] = number & 0xFF + number >>= 8 + + return packed_bytes + + def putdata(self, color_data): + for y in range(self.height): + for x in range(self.width): + i = x + y * self.width + if i > len(color_data) - 1: + return + + self.img[i * 3: i * 3 + 3] = color_data[i][::-1] + + def save(self, f, type): + assert type == "BMP" + # 54 bytes of preambule + image color data. + filesize = 54 + 3 * self.width * self.height + # 14 bytes of header. + bmpfileheader = bytearray([ord('B'), ord('M')] + self._int_to_bytes(filesize) + + [0, 0, 0, 0, 54, 0, 0, 0]) + # 40 bytes of info. + bmpinfoheader = bytearray([40, 0, 0, 0] + + self._int_to_bytes(self.width) + + self._int_to_bytes(self.height) + + [1, 0, 24] + (25 * [0])) + + padlength = (4 - (self.width * 3) % 4) % 4 + bmppad = bytearray([0, 0, 0]) + padding = bmppad[0 : padlength] + + f.write(bmpfileheader) + f.write(bmpinfoheader) + + for i in range(self.height): + offset = self.width * (self.height - i - 1) * 3 + f.write(self.img[offset : offset + 3 * self.width]) + f.write(padding) + +def encode_string_as_bmp_image(string_data): + data_bytes = array.array("B", string_data.encode("utf-8")) + + num_bytes = len(data_bytes) + + # Encode data bytes to color data (RGB), one bit per channel. + # This is to avoid errors due to different color spaces used in decoding. + color_data = [] + for byte in data_bytes: + p = [int(x) * 255 for x in '{0:08b}'.format(byte)] + color_data.append((p[0], p[1], p[2])) + color_data.append((p[3], p[4], p[5])) + color_data.append((p[6], p[7], 0)) + + # Render image. + num_pixels = len(color_data) + sqrt = int(math.ceil(math.sqrt(num_pixels))) + img = Image.new("RGB", (sqrt, sqrt), "black") + img.putdata(color_data) + + # Flush image to string. + f = BytesIO() + img.save(f, "BMP") + f.seek(0) + + return f.read() + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + data = encode_string_as_bmp_image(data) + return data + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'image/bmp' + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type, + access_control_allow_origin = b"*") diff --git a/test/fixtures/wpt/common/security-features/subresource/referrer.py b/test/fixtures/wpt/common/security-features/subresource/referrer.py new file mode 100644 index 0000000..e366314 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/referrer.py @@ -0,0 +1,4 @@ +def main(request, response): + referrer = request.headers.get(b"referer", b"") + response_headers = [(b"Content-Type", b"text/javascript")] + return (200, response_headers, b"window.referrer = '" + referrer + b"'") diff --git a/test/fixtures/wpt/common/security-features/subresource/script.py b/test/fixtures/wpt/common/security-features/subresource/script.py new file mode 100644 index 0000000..9701816 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/script.py @@ -0,0 +1,14 @@ +import os, sys +from wptserve.utils import isomorphic_decode + +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"script.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/shared-worker.py b/test/fixtures/wpt/common/security-features/subresource/shared-worker.py new file mode 100644 index 0000000..bdfb61b --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/shared-worker.py @@ -0,0 +1,13 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"shared-worker.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/static-import.py b/test/fixtures/wpt/common/security-features/subresource/static-import.py new file mode 100644 index 0000000..3c3a6f6 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/static-import.py @@ -0,0 +1,61 @@ +import os, sys, json +from urllib.parse import unquote + +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def get_csp_value(value): + ''' + Returns actual CSP header values (e.g. "worker-src 'self'") for the + given string used in PolicyDelivery's value (e.g. "worker-src-self"). + ''' + + # script-src + # Test-related scripts like testharness.js and inline scripts containing + # test bodies. + # 'unsafe-inline' is added as a workaround here. This is probably not so + # bad, as it shouldn't intefere non-inline-script requests that we want to + # test. + if value == 'script-src-wildcard': + return "script-src * 'unsafe-inline'" + if value == 'script-src-self': + return "script-src 'self' 'unsafe-inline'" + # Workaround for "script-src 'none'" would be more complicated, because + # - "script-src 'none' 'unsafe-inline'" is handled somehow differently from + # "script-src 'none'", i.e. + # https://w3c.github.io/webappsec-csp/#match-url-to-source-list Step 3 + # handles the latter but not the former. + # - We need nonce- or path-based additional values to allow same-origin + # test scripts like testharness.js. + # Therefore, we disable 'script-src-none' tests for now in + # `/content-security-policy/spec.src.json`. + if value == 'script-src-none': + return "script-src 'none'" + + # worker-src + if value == 'worker-src-wildcard': + return 'worker-src *' + if value == 'worker-src-self': + return "worker-src 'self'" + if value == 'worker-src-none': + return "worker-src 'none'" + raise Exception('Invalid delivery_value: %s' % value) + +def generate_payload(request): + import_url = unquote(isomorphic_decode(request.GET[b'import_url'])) + return subresource.get_template(u"static-import.js.template") % { + u"import_url": import_url + } + +def main(request, response): + def payload_generator(_): return generate_payload(request) + maybe_additional_headers = {} + if b'contentSecurityPolicy' in request.GET: + csp = unquote(isomorphic_decode(request.GET[b'contentSecurityPolicy'])) + maybe_additional_headers[b'Content-Security-Policy'] = get_csp_value(csp) + subresource.respond(request, + response, + payload_generator = payload_generator, + content_type = b"application/javascript", + maybe_additional_headers = maybe_additional_headers) diff --git a/test/fixtures/wpt/common/security-features/subresource/stylesheet.py b/test/fixtures/wpt/common/security-features/subresource/stylesheet.py new file mode 100644 index 0000000..05db249 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/stylesheet.py @@ -0,0 +1,61 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + type = b'image' + if b"type" in request.GET: + type = request.GET[b"type"] + + if b"id" in request.GET: + request.server.stash.put(request.GET[b"id"], data) + + if type == b'image': + return subresource.get_template(u"image.css.template") % {u"id": isomorphic_decode(request.GET[b"id"])} + + elif type == b'font': + return subresource.get_template(u"font.css.template") % {u"id": isomorphic_decode(request.GET[b"id"])} + + elif type == b'svg': + return subresource.get_template(u"svg.css.template") % { + u"id": isomorphic_decode(request.GET[b"id"]), + u"property": isomorphic_decode(request.GET[b"property"])} + + # A `'stylesheet-only'`-type stylesheet has no nested resources; this is + # useful in tests that cover referrers for stylesheet fetches (e.g. fetches + # triggered by `@import` statements). + elif type == b'stylesheet-only': + return u'' + +def generate_import_rule(request, server_data): + return u"@import url('%(url)s');" % { + u"url": subresource.create_url(request, swap_origin=True, + query_parameter_to_remove=u"import-rule") + } + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + payload_generator = lambda data: generate_payload(request, data) + content_type = b"text/css" + referrer_policy = b"unsafe-url" + if b"import-rule" in request.GET: + payload_generator = lambda data: generate_import_rule(request, data) + + if b"report-headers" in request.GET: + payload_generator = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + if b"referrer-policy" in request.GET: + referrer_policy = request.GET[b"referrer-policy"] + + subresource.respond( + request, + response, + payload_generator = payload_generator, + content_type = content_type, + maybe_additional_headers = { b"Referrer-Policy": referrer_policy }) diff --git a/test/fixtures/wpt/common/security-features/subresource/subresource.py b/test/fixtures/wpt/common/security-features/subresource/subresource.py new file mode 100644 index 0000000..b3c055a --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/subresource.py @@ -0,0 +1,199 @@ +import os, json +from urllib.parse import parse_qsl, SplitResult, urlencode, urlsplit, urlunsplit + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def get_template(template_basename): + script_directory = os.path.dirname(os.path.abspath(isomorphic_decode(__file__))) + template_directory = os.path.abspath(os.path.join(script_directory, + u"template")) + template_filename = os.path.join(template_directory, template_basename) + + with open(template_filename, "r") as f: + return f.read() + + +def redirect(url, response): + response.add_required_headers = False + response.writer.write_status(301) + response.writer.write_header(b"access-control-allow-origin", b"*") + response.writer.write_header(b"location", isomorphic_encode(url)) + response.writer.end_headers() + response.writer.write(u"") + + +# TODO(kristijanburnik): subdomain_prefix is a hardcoded value aligned with +# referrer-policy-test-case.js. The prefix should be configured in one place. +def __get_swapped_origin_netloc(netloc, subdomain_prefix = u"www1."): + if netloc.startswith(subdomain_prefix): + return netloc[len(subdomain_prefix):] + else: + return subdomain_prefix + netloc + + +# Creates a URL (typically a redirect target URL) that is the same as the +# current request URL `request.url`, except for: +# - When `swap_scheme` or `swap_origin` is True, its scheme/origin is changed +# to the other one. (http <-> https, ws <-> wss, etc.) +# - For `downgrade`, we redirect to a URL that would be successfully loaded +# if and only if upgrade-insecure-request is applied. +# - `query_parameter_to_remove` parameter is removed from query part. +# Its default is "redirection" to avoid redirect loops. +def create_url(request, + swap_scheme=False, + swap_origin=False, + downgrade=False, + query_parameter_to_remove=u"redirection"): + parsed = urlsplit(request.url) + destination_netloc = parsed.netloc + + scheme = parsed.scheme + if swap_scheme: + scheme = u"http" if parsed.scheme == u"https" else u"https" + hostname = parsed.netloc.split(u':')[0] + port = request.server.config[u"ports"][scheme][0] + destination_netloc = u":".join([hostname, str(port)]) + + if downgrade: + # These rely on some unintuitive cleverness due to WPT's test setup: + # 'Upgrade-Insecure-Requests' does not upgrade the port number, + # so we use URLs in the form `http://[domain]:[https-port]`, + # which will be upgraded to `https://[domain]:[https-port]`. + # If the upgrade fails, the load will fail, as we don't serve HTTP over + # the secure port. + if parsed.scheme == u"https": + scheme = u"http" + elif parsed.scheme == u"wss": + scheme = u"ws" + else: + raise ValueError(u"Downgrade redirection: Invalid scheme '%s'" % + parsed.scheme) + hostname = parsed.netloc.split(u':')[0] + port = request.server.config[u"ports"][parsed.scheme][0] + destination_netloc = u":".join([hostname, str(port)]) + + if swap_origin: + destination_netloc = __get_swapped_origin_netloc(destination_netloc) + + parsed_query = parse_qsl(parsed.query, keep_blank_values=True) + parsed_query = [x for x in parsed_query if x[0] != query_parameter_to_remove] + + destination_url = urlunsplit(SplitResult( + scheme = scheme, + netloc = destination_netloc, + path = parsed.path, + query = urlencode(parsed_query), + fragment = None)) + + return destination_url + + +def preprocess_redirection(request, response): + if b"redirection" not in request.GET: + return False + + redirection = request.GET[b"redirection"] + + if redirection == b"no-redirect": + return False + elif redirection == b"keep-scheme": + redirect_url = create_url(request, swap_scheme=False) + elif redirection == b"swap-scheme": + redirect_url = create_url(request, swap_scheme=True) + elif redirection == b"downgrade": + redirect_url = create_url(request, downgrade=True) + elif redirection == b"keep-origin": + redirect_url = create_url(request, swap_origin=False) + elif redirection == b"swap-origin": + redirect_url = create_url(request, swap_origin=True) + else: + raise ValueError(u"Invalid redirection type '%s'" % isomorphic_decode(redirection)) + + redirect(redirect_url, response) + return True + + +def preprocess_stash_action(request, response): + if b"action" not in request.GET: + return False + + action = request.GET[b"action"] + + key = request.GET[b"key"] + stash = request.server.stash + path = request.GET[b"path"] if b"path" in request.GET \ + else isomorphic_encode(request.url.split(u'?')[0]) + + if action == b"put": + value = isomorphic_decode(request.GET[b"value"]) + stash.take(key=key, path=path) + stash.put(key=key, value=value, path=path) + response_data = json.dumps({u"status": u"success", u"result": isomorphic_decode(key)}) + elif action == b"purge": + value = stash.take(key=key, path=path) + return False + elif action == b"take": + value = stash.take(key=key, path=path) + if value is None: + status = u"allowed" + else: + status = u"blocked" + response_data = json.dumps({u"status": status, u"result": value}) + else: + return False + + response.add_required_headers = False + response.writer.write_status(200) + response.writer.write_header(b"content-type", b"text/javascript") + response.writer.write_header(b"cache-control", b"no-cache; must-revalidate") + response.writer.end_headers() + response.writer.write(response_data) + return True + + +def __noop(request, response): + return u"" + + +def respond(request, + response, + status_code = 200, + content_type = b"text/html", + payload_generator = __noop, + cache_control = b"no-cache; must-revalidate", + access_control_allow_origin = b"*", + maybe_additional_headers = None): + if preprocess_redirection(request, response): + return + + if preprocess_stash_action(request, response): + return + + response.add_required_headers = False + response.writer.write_status(status_code) + + if access_control_allow_origin != None: + response.writer.write_header(b"access-control-allow-origin", + access_control_allow_origin) + response.writer.write_header(b"content-type", content_type) + response.writer.write_header(b"cache-control", cache_control) + + additional_headers = maybe_additional_headers or {} + for header, value in additional_headers.items(): + response.writer.write_header(header, value) + + response.writer.end_headers() + + new_headers = {} + new_val = [] + for key, val in request.headers.items(): + if len(val) == 1: + new_val = isomorphic_decode(val[0]) + else: + new_val = [isomorphic_decode(x) for x in val] + new_headers[isomorphic_decode(key)] = new_val + + server_data = {u"headers": json.dumps(new_headers, indent = 4)} + + payload = payload_generator(server_data) + response.writer.write(payload) diff --git a/test/fixtures/wpt/common/security-features/subresource/svg.py b/test/fixtures/wpt/common/security-features/subresource/svg.py new file mode 100644 index 0000000..9c569e3 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/svg.py @@ -0,0 +1,37 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + data = (u'{"headers": %(headers)s}') % server_data + if b"id" in request.GET: + with request.server.stash.lock: + request.server.stash.take(request.GET[b"id"]) + request.server.stash.put(request.GET[b"id"], data) + return u"" + +def generate_payload_embedded(request, server_data): + return subresource.get_template(u"svg.embedded.template") % { + u"id": isomorphic_decode(request.GET[b"id"]), + u"property": isomorphic_decode(request.GET[b"property"])} + +def generate_report_headers_payload(request, server_data): + stashed_data = request.server.stash.take(request.GET[b"id"]) + return stashed_data + +def main(request, response): + handler = lambda data: generate_payload(request, data) + content_type = b'image/svg+xml' + + if b"embedded-svg" in request.GET: + handler = lambda data: generate_payload_embedded(request, data) + + if b"report-headers" in request.GET: + handler = lambda data: generate_report_headers_payload(request, data) + content_type = b'application/json' + + subresource.respond(request, + response, + payload_generator = handler, + content_type = content_type) diff --git a/test/fixtures/wpt/common/security-features/subresource/template/document.html.template b/test/fixtures/wpt/common/security-features/subresource/template/document.html.template new file mode 100644 index 0000000..141711c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/document.html.template @@ -0,0 +1,16 @@ + + + + This page reports back it's request details to the parent frame + + + + + diff --git a/test/fixtures/wpt/common/security-features/subresource/template/font.css.template b/test/fixtures/wpt/common/security-features/subresource/template/font.css.template new file mode 100644 index 0000000..9d1e9c4 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/font.css.template @@ -0,0 +1,9 @@ +@font-face { + font-family: 'wpt'; + font-style: normal; + font-weight: normal; + src: url(/common/security-features/subresource/font.py?id=%(id)s) format('truetype'); +} +body { + font-family: 'wpt'; +} diff --git a/test/fixtures/wpt/common/security-features/subresource/template/image.css.template b/test/fixtures/wpt/common/security-features/subresource/template/image.css.template new file mode 100644 index 0000000..dfe41f1 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/image.css.template @@ -0,0 +1,3 @@ +div.styled::before { + content:url(/common/security-features/subresource/image.py?id=%(id)s) +} diff --git a/test/fixtures/wpt/common/security-features/subresource/template/script.js.template b/test/fixtures/wpt/common/security-features/subresource/template/script.js.template new file mode 100644 index 0000000..e2edf21 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/script.js.template @@ -0,0 +1,3 @@ +postMessage({ + "headers": %(headers)s +}, "*"); diff --git a/test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template b/test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template new file mode 100644 index 0000000..c3f109e --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/shared-worker.js.template @@ -0,0 +1,5 @@ +onconnect = function(e) { + e.ports[0].postMessage({ + "headers": %(headers)s + }); +}; diff --git a/test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template b/test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template new file mode 100644 index 0000000..095459b --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/static-import.js.template @@ -0,0 +1 @@ +import '%(import_url)s'; diff --git a/test/fixtures/wpt/common/security-features/subresource/template/svg.css.template b/test/fixtures/wpt/common/security-features/subresource/template/svg.css.template new file mode 100644 index 0000000..c2e509c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/svg.css.template @@ -0,0 +1,3 @@ +path { + %(property)s: url(/common/security-features/subresource/svg.py?id=%(id)s#invalidFragment); +} diff --git a/test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template b/test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template new file mode 100644 index 0000000..5986c48 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/svg.embedded.template @@ -0,0 +1,5 @@ + + + + + diff --git a/test/fixtures/wpt/common/security-features/subresource/template/worker.js.template b/test/fixtures/wpt/common/security-features/subresource/template/worker.js.template new file mode 100644 index 0000000..817dd8c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/template/worker.js.template @@ -0,0 +1,3 @@ +postMessage({ + "headers": %(headers)s +}); diff --git a/test/fixtures/wpt/common/security-features/subresource/video.py b/test/fixtures/wpt/common/security-features/subresource/video.py new file mode 100644 index 0000000..9db8e9f --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/video.py @@ -0,0 +1,17 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(request, server_data): + file = os.path.join(request.doc_root, u"media", u"movie_5.webm") + return open(file, "rb").read() + + +def main(request, response): + handler = lambda data: generate_payload(request, data) + subresource.respond(request, + response, + payload_generator = handler, + access_control_allow_origin = b"*", + content_type = b"video/webm") diff --git a/test/fixtures/wpt/common/security-features/subresource/worker.py b/test/fixtures/wpt/common/security-features/subresource/worker.py new file mode 100644 index 0000000..f655633 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/worker.py @@ -0,0 +1,13 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + return subresource.get_template(u"worker.js.template") % server_data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + content_type = b"application/javascript") diff --git a/test/fixtures/wpt/common/security-features/subresource/xhr.py b/test/fixtures/wpt/common/security-features/subresource/xhr.py new file mode 100644 index 0000000..75921e9 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/subresource/xhr.py @@ -0,0 +1,16 @@ +import os, sys +from wptserve.utils import isomorphic_decode +import importlib +subresource = importlib.import_module("common.security-features.subresource.subresource") + +def generate_payload(server_data): + data = (u'{"headers": %(headers)s}') % server_data + return data + +def main(request, response): + subresource.respond(request, + response, + payload_generator = generate_payload, + access_control_allow_origin = b"*", + content_type = b"application/json", + cache_control = b"no-store") diff --git a/test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py b/test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py new file mode 100644 index 0000000..d1bf581 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/format_spec_src_json.py @@ -0,0 +1,24 @@ +import collections +import json +import os + + +def main(): + '''Formats spec.src.json.''' + script_directory = os.path.dirname(os.path.abspath(__file__)) + for dir in [ + 'mixed-content', 'referrer-policy', 'referrer-policy/4K-1', + 'referrer-policy/4K', 'referrer-policy/4K+1', + 'upgrade-insecure-requests' + ]: + filename = os.path.join(script_directory, '..', '..', '..', dir, + 'spec.src.json') + spec = json.load( + open(filename, 'r'), object_pairs_hook=collections.OrderedDict) + with open(filename, 'w') as f: + f.write(json.dumps(spec, indent=2, separators=(',', ': '))) + f.write('\n') + + +if __name__ == '__main__': + main() diff --git a/test/fixtures/wpt/common/security-features/tools/generate.py b/test/fixtures/wpt/common/security-features/tools/generate.py new file mode 100755 index 0000000..d1cd331 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/generate.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 + +import argparse +import collections +import copy +import json +import os +import sys + +import spec_validator +import util + + +def expand_pattern(expansion_pattern, test_expansion_schema): + expansion = {} + for artifact_key in expansion_pattern: + artifact_value = expansion_pattern[artifact_key] + if artifact_value == '*': + expansion[artifact_key] = test_expansion_schema[artifact_key] + elif isinstance(artifact_value, list): + expansion[artifact_key] = artifact_value + elif isinstance(artifact_value, dict): + # Flattened expansion. + expansion[artifact_key] = [] + values_dict = expand_pattern(artifact_value, + test_expansion_schema[artifact_key]) + for sub_key in values_dict.keys(): + expansion[artifact_key] += values_dict[sub_key] + else: + expansion[artifact_key] = [artifact_value] + + return expansion + + +def permute_expansion(expansion, + artifact_order, + selection={}, + artifact_index=0): + assert isinstance(artifact_order, list), "artifact_order should be a list" + + if artifact_index >= len(artifact_order): + yield selection + return + + artifact_key = artifact_order[artifact_index] + + for artifact_value in expansion[artifact_key]: + selection[artifact_key] = artifact_value + for next_selection in permute_expansion(expansion, artifact_order, + selection, artifact_index + 1): + yield next_selection + + +# Dumps the test config `selection` into a serialized JSON string. +def dump_test_parameters(selection): + return json.dumps( + selection, + indent=2, + separators=(',', ': '), + sort_keys=True, + cls=util.CustomEncoder) + + +def get_test_filename(spec_directory, spec_json, selection): + '''Returns the filname for the main test HTML file''' + + selection_for_filename = copy.deepcopy(selection) + # Use 'unset' rather than 'None' in test filenames. + if selection_for_filename['delivery_value'] is None: + selection_for_filename['delivery_value'] = 'unset' + + return os.path.join( + spec_directory, + spec_json['test_file_path_pattern'] % selection_for_filename) + + +def get_csp_value(value): + ''' + Returns actual CSP header values (e.g. "worker-src 'self'") for the + given string used in PolicyDelivery's value (e.g. "worker-src-self"). + ''' + + # script-src + # Test-related scripts like testharness.js and inline scripts containing + # test bodies. + # 'unsafe-inline' is added as a workaround here. This is probably not so + # bad, as it shouldn't intefere non-inline-script requests that we want to + # test. + if value == 'script-src-wildcard': + return "script-src * 'unsafe-inline'" + if value == 'script-src-self': + return "script-src 'self' 'unsafe-inline'" + # Workaround for "script-src 'none'" would be more complicated, because + # - "script-src 'none' 'unsafe-inline'" is handled somehow differently from + # "script-src 'none'", i.e. + # https://w3c.github.io/webappsec-csp/#match-url-to-source-list Step 3 + # handles the latter but not the former. + # - We need nonce- or path-based additional values to allow same-origin + # test scripts like testharness.js. + # Therefore, we disable 'script-src-none' tests for now in + # `/content-security-policy/spec.src.json`. + if value == 'script-src-none': + return "script-src 'none'" + + # worker-src + if value == 'worker-src-wildcard': + return 'worker-src *' + if value == 'worker-src-self': + return "worker-src 'self'" + if value == 'worker-src-none': + return "worker-src 'none'" + raise Exception('Invalid delivery_value: %s' % value) + +def handle_deliveries(policy_deliveries): + ''' + Generate elements and HTTP headers for the given list of + PolicyDelivery. + TODO(hiroshige): Merge duplicated code here, scope/document.py, etc. + ''' + + meta = '' + headers = {} + + for delivery in policy_deliveries: + if delivery.value is None: + continue + if delivery.key == 'referrerPolicy': + if delivery.delivery_type == 'meta': + meta += \ + '' % delivery.value + elif delivery.delivery_type == 'http-rp': + headers['Referrer-Policy'] = delivery.value + # TODO(kristijanburnik): Limit to WPT origins. + headers['Access-Control-Allow-Origin'] = '*' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'mixedContent': + assert (delivery.value == 'opt-in') + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers['Content-Security-Policy'] = 'block-all-mixed-content' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'contentSecurityPolicy': + csp_value = get_csp_value(delivery.value) + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers['Content-Security-Policy'] = csp_value + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + elif delivery.key == 'upgradeInsecureRequests': + # https://w3c.github.io/webappsec-upgrade-insecure-requests/#delivery + assert (delivery.value == 'upgrade') + if delivery.delivery_type == 'meta': + meta += '' + elif delivery.delivery_type == 'http-rp': + headers[ + 'Content-Security-Policy'] = 'upgrade-insecure-requests' + else: + raise Exception( + 'Invalid delivery_type: %s' % delivery.delivery_type) + else: + raise Exception('Invalid delivery_key: %s' % delivery.key) + return {"meta": meta, "headers": headers} + + +def generate_selection(spec_json, selection): + ''' + Returns a scenario object (with a top-level source_context_list entry, + which will be removed in generate_test_file() later). + ''' + + target_policy_delivery = util.PolicyDelivery(selection['delivery_type'], + selection['delivery_key'], + selection['delivery_value']) + del selection['delivery_type'] + del selection['delivery_key'] + del selection['delivery_value'] + + # Parse source context list and policy deliveries of source contexts. + # `util.ShouldSkip()` exceptions are raised if e.g. unsuppported + # combinations of source contexts and policy deliveries are used. + source_context_list_scheme = spec_json['source_context_list_schema'][ + selection['source_context_list']] + selection['source_context_list'] = [ + util.SourceContext.from_json(source_context, target_policy_delivery, + spec_json['source_context_schema']) + for source_context in source_context_list_scheme['sourceContextList'] + ] + + # Check if the subresource is supported by the innermost source context. + innermost_source_context = selection['source_context_list'][-1] + supported_subresource = spec_json['source_context_schema'][ + 'supported_subresource'][innermost_source_context.source_context_type] + if supported_subresource != '*': + if selection['subresource'] not in supported_subresource: + raise util.ShouldSkip() + + # Parse subresource policy deliveries. + selection[ + 'subresource_policy_deliveries'] = util.PolicyDelivery.list_from_json( + source_context_list_scheme['subresourcePolicyDeliveries'], + target_policy_delivery, spec_json['subresource_schema'] + ['supported_delivery_type'][selection['subresource']]) + + # Generate per-scenario test description. + selection['test_description'] = spec_json[ + 'test_description_template'] % selection + + return selection + + +def generate_test_file(spec_directory, test_helper_filenames, + test_html_template_basename, test_filename, scenarios): + ''' + Generates a test HTML file (and possibly its associated .headers file) + from `scenarios`. + ''' + + # Scenarios for the same file should have the same `source_context_list`, + # including the top-level one. + # Note: currently, non-top-level source contexts aren't necessarily required + # to be the same, but we set this requirement as it will be useful e.g. when + # we e.g. reuse a worker among multiple scenarios. + for scenario in scenarios: + assert (scenario['source_context_list'] == scenarios[0] + ['source_context_list']) + + # We process the top source context below, and do not include it in + # the JSON objects (i.e. `scenarios`) in generated HTML files. + top_source_context = scenarios[0]['source_context_list'].pop(0) + assert (top_source_context.source_context_type == 'top') + for scenario in scenarios[1:]: + assert (scenario['source_context_list'].pop(0) == top_source_context) + + parameters = {} + + # Sort scenarios, to avoid unnecessary diffs due to different orders in + # `scenarios`. + serialized_scenarios = sorted( + [dump_test_parameters(scenario) for scenario in scenarios]) + + parameters['scenarios'] = ",\n".join(serialized_scenarios).replace( + "\n", "\n" + " " * 10) + + test_directory = os.path.dirname(test_filename) + + parameters['helper_js'] = "" + for test_helper_filename in test_helper_filenames: + parameters['helper_js'] += ' \n' % ( + os.path.relpath(test_helper_filename, test_directory)) + parameters['sanity_checker_js'] = os.path.relpath( + os.path.join(spec_directory, 'generic', 'sanity-checker.js'), + test_directory) + parameters['spec_json_js'] = os.path.relpath( + os.path.join(spec_directory, 'generic', 'spec_json.js'), + test_directory) + + test_headers_filename = test_filename + ".headers" + + test_html_template = util.get_template(test_html_template_basename) + disclaimer_template = util.get_template('disclaimer.template') + + html_template_filename = os.path.join(util.template_directory, + test_html_template_basename) + generated_disclaimer = disclaimer_template \ + % {'generating_script_filename': os.path.relpath(sys.argv[0], + util.test_root_directory), + 'spec_directory': os.path.relpath(spec_directory, + util.test_root_directory)} + + # Adjust the template for the test invoking JS. Indent it to look nice. + parameters['generated_disclaimer'] = generated_disclaimer.rstrip() + + # Directory for the test files. + try: + os.makedirs(test_directory) + except: + pass + + delivery = handle_deliveries(top_source_context.policy_deliveries) + + if len(delivery['headers']) > 0: + with open(test_headers_filename, "w") as f: + for header in delivery['headers']: + f.write('%s: %s\n' % (header, delivery['headers'][header])) + + parameters['meta_delivery_method'] = delivery['meta'] + # Obey the lint and pretty format. + if len(parameters['meta_delivery_method']) > 0: + parameters['meta_delivery_method'] = "\n " + \ + parameters['meta_delivery_method'] + + # Write out the generated HTML file. + util.write_file(test_filename, test_html_template % parameters) + + +def generate_test_source_files(spec_directory, test_helper_filenames, + spec_json, target): + test_expansion_schema = spec_json['test_expansion_schema'] + specification = spec_json['specification'] + + if target == "debug": + spec_json_js_template = util.get_template('spec_json.js.template') + util.write_file( + os.path.join(spec_directory, "generic", "spec_json.js"), + spec_json_js_template % {'spec_json': json.dumps(spec_json)}) + util.write_file( + os.path.join(spec_directory, "generic", + "debug-output.spec.src.json"), + json.dumps(spec_json, indent=2, separators=(',', ': '))) + + # Choose a debug/release template depending on the target. + html_template = "test.%s.html.template" % target + + artifact_order = list(test_expansion_schema.keys()) + artifact_order.remove('expansion') + + excluded_selection_pattern = '' + for key in artifact_order: + excluded_selection_pattern += '%(' + key + ')s/' + + # Create list of excluded tests. + exclusion_dict = set() + for excluded_pattern in spec_json['excluded_tests']: + excluded_expansion = \ + expand_pattern(excluded_pattern, test_expansion_schema) + for excluded_selection in permute_expansion(excluded_expansion, + artifact_order): + excluded_selection['delivery_key'] = spec_json['delivery_key'] + exclusion_dict.add(excluded_selection_pattern % excluded_selection) + + # `scenarios[filename]` represents the list of scenario objects to be + # generated into `filename`. + scenarios = {} + + for spec in specification: + # Used to make entries with expansion="override" override preceding + # entries with the same |selection_path|. + output_dict = {} + + for expansion_pattern in spec['test_expansion']: + expansion = expand_pattern(expansion_pattern, + test_expansion_schema) + for selection in permute_expansion(expansion, artifact_order): + selection['delivery_key'] = spec_json['delivery_key'] + selection_path = spec_json['selection_pattern'] % selection + if selection_path in output_dict: + if expansion_pattern['expansion'] != 'override': + print("Error: expansion is default in:") + print(dump_test_parameters(selection)) + print("but overrides:") + print(dump_test_parameters( + output_dict[selection_path])) + sys.exit(1) + output_dict[selection_path] = copy.deepcopy(selection) + + for selection_path in output_dict: + selection = output_dict[selection_path] + if (excluded_selection_pattern % selection) in exclusion_dict: + print('Excluding selection:', selection_path) + continue + try: + test_filename = get_test_filename(spec_directory, spec_json, + selection) + scenario = generate_selection(spec_json, selection) + scenarios[test_filename] = scenarios.get(test_filename, + []) + [scenario] + except util.ShouldSkip: + continue + + for filename in scenarios: + generate_test_file(spec_directory, test_helper_filenames, + html_template, filename, scenarios[filename]) + + +def merge_json(base, child): + for key in child: + if key not in base: + base[key] = child[key] + continue + # `base[key]` and `child[key]` both exists. + if isinstance(base[key], list) and isinstance(child[key], list): + base[key].extend(child[key]) + elif isinstance(base[key], dict) and isinstance(child[key], dict): + merge_json(base[key], child[key]) + else: + base[key] = child[key] + + +def main(): + parser = argparse.ArgumentParser( + description='Test suite generator utility') + parser.add_argument( + '-t', + '--target', + type=str, + choices=("release", "debug"), + default="release", + help='Sets the appropriate template for generating tests') + parser.add_argument( + '-s', + '--spec', + type=str, + default=os.getcwd(), + help='Specify a file used for describing and generating the tests') + # TODO(kristijanburnik): Add option for the spec_json file. + args = parser.parse_args() + + spec_directory = os.path.abspath(args.spec) + + # Read `spec.src.json` files, starting from `spec_directory`, and + # continuing to parent directories as long as `spec.src.json` exists. + spec_filenames = [] + test_helper_filenames = [] + spec_src_directory = spec_directory + while len(spec_src_directory) >= len(util.test_root_directory): + spec_filename = os.path.join(spec_src_directory, "spec.src.json") + if not os.path.exists(spec_filename): + break + spec_filenames.append(spec_filename) + test_filename = os.path.join(spec_src_directory, 'generic', + 'test-case.sub.js') + assert (os.path.exists(test_filename)) + test_helper_filenames.append(test_filename) + spec_src_directory = os.path.abspath( + os.path.join(spec_src_directory, "..")) + + spec_filenames = list(reversed(spec_filenames)) + test_helper_filenames = list(reversed(test_helper_filenames)) + + if len(spec_filenames) == 0: + print('Error: No spec.src.json is found at %s.' % spec_directory) + return + + # Load the default spec JSON file, ... + default_spec_filename = os.path.join(util.script_directory, + 'spec.src.json') + spec_json = collections.OrderedDict() + if os.path.exists(default_spec_filename): + spec_json = util.load_spec_json(default_spec_filename) + + # ... and then make spec JSON files in subdirectories override the default. + for spec_filename in spec_filenames: + child_spec_json = util.load_spec_json(spec_filename) + merge_json(spec_json, child_spec_json) + + spec_validator.assert_valid_spec_json(spec_json) + generate_test_source_files(spec_directory, test_helper_filenames, + spec_json, args.target) + + +if __name__ == '__main__': + main() diff --git a/test/fixtures/wpt/common/security-features/tools/spec.src.json b/test/fixtures/wpt/common/security-features/tools/spec.src.json new file mode 100644 index 0000000..4a84493 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/spec.src.json @@ -0,0 +1,533 @@ +{ + "selection_pattern": "%(source_context_list)s.%(delivery_type)s/%(delivery_value)s/%(subresource)s/%(origin)s.%(redirection)s.%(source_scheme)s", + "test_file_path_pattern": "gen/%(source_context_list)s.%(delivery_type)s/%(delivery_value)s/%(subresource)s.%(source_scheme)s.html", + "excluded_tests": [ + { + // Workers are same-origin only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "worker-classic", + "worker-module", + "sharedworker-classic", + "sharedworker-module" + ], + "origin": [ + "cross-https", + "cross-http", + "cross-http-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "expectation": "*" + }, + { + // Workers are same-origin only (redirects) + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": [ + "swap-origin", + "swap-scheme" + ], + "subresource": [ + "worker-classic", + "worker-module", + "sharedworker-classic", + "sharedworker-module" + ], + "origin": "*", + "expectation": "*" + }, + { + // Websockets are ws/wss-only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": "websocket", + "origin": [ + "same-https", + "same-http", + "same-http-downgrade", + "cross-https", + "cross-http", + "cross-http-downgrade" + ], + "expectation": "*" + }, + { + // Redirects are intentionally forbidden in browsers: + // https://fetch.spec.whatwg.org/#concept-websocket-establish + // Websockets are no-redirect only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": [ + "keep-origin", + "swap-origin", + "keep-scheme", + "swap-scheme", + "downgrade" + ], + "subresource": "websocket", + "origin": "*", + "expectation": "*" + }, + { + // ws/wss are websocket-only + "expansion": "*", + "source_scheme": "*", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "a-tag", + "area-tag", + "audio-tag", + "beacon", + "fetch", + "iframe-tag", + "img-tag", + "link-css-tag", + "link-prefetch-tag", + "object-tag", + "picture-tag", + "script-tag", + "script-tag-dynamic-import", + "sharedworker-classic", + "sharedworker-import", + "sharedworker-import-data", + "sharedworker-module", + "video-tag", + "worker-classic", + "worker-import", + "worker-import-data", + "worker-module", + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data", + "xhr" + ], + "origin": [ + "same-wss", + "same-ws", + "same-ws-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "expectation": "*" + }, + { + // Worklets are HTTPS contexts only + "expansion": "*", + "source_scheme": "http", + "source_context_list": "*", + "delivery_type": "*", + "delivery_value": "*", + "redirection": "*", + "subresource": [ + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data" + ], + "origin": "*", + "expectation": "*" + } + ], + "source_context_schema": { + "supported_subresource": { + "top": "*", + "iframe": "*", + "iframe-blank": "*", + "srcdoc": "*", + "worker-classic": [ + "xhr", + "fetch", + "websocket", + "worker-classic", + "worker-module" + ], + "worker-module": [ + "xhr", + "fetch", + "websocket", + "worker-classic", + "worker-module" + ], + "worker-classic-data": [ + "xhr", + "fetch", + "websocket" + ], + "worker-module-data": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-classic": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-module": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-classic-data": [ + "xhr", + "fetch", + "websocket" + ], + "sharedworker-module-data": [ + "xhr", + "fetch", + "websocket" + ] + } + }, + "source_context_list_schema": { + // Warning: Currently, some nested patterns of contexts have different + // inheritance rules for different kinds of policies. + // The generated tests will be used to test/investigate the policy + // inheritance rules, and eventually the policy inheritance rules will + // be unified (https://github.com/w3ctag/design-principles/issues/111). + "top": { + "description": "Policy set by the top-level Document", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "req": { + "description": "Subresource request's policy should override Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [ + "nonNullPolicy" + ] + }, + "srcdoc-inherit": { + "description": "srcdoc iframe without its own policy should inherit parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "srcdoc" + } + ], + "subresourcePolicyDeliveries": [] + }, + "srcdoc": { + "description": "srcdoc iframe's policy should override parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "srcdoc", + "policyDeliveries": [ + "nonNullPolicy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "iframe": { + "description": "external iframe's policy should override parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "iframe", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "iframe-blank-inherit": { + "description": "blank iframe should inherit parent Document's policy", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "iframe-blank" + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic": { + // This is applicable to referrer-policy tests. + // Use "worker-classic-inherit" for CSP (mixed-content, etc.). + "description": "dedicated workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "worker-classic", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-classic-data": { + "description": "data: dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-classic-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module": { + // This is applicable to referrer-policy tests. + "description": "dedicated workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "worker-module", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "worker-module-data": { + "description": "data: dedicated workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "worker-module-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-classic": { + "description": "shared workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "sharedworker-classic", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-classic-data": { + "description": "data: shared workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "sharedworker-classic-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-module": { + "description": "shared workers shouldn't inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "anotherPolicy" + ] + }, + { + "sourceContextType": "sharedworker-module", + "policyDeliveries": [ + "policy" + ] + } + ], + "subresourcePolicyDeliveries": [] + }, + "sharedworker-module-data": { + "description": "data: shared workers should inherit its parent's policy.", + "sourceContextList": [ + { + "sourceContextType": "top", + "policyDeliveries": [ + "policy" + ] + }, + { + "sourceContextType": "sharedworker-module-data", + "policyDeliveries": [] + } + ], + "subresourcePolicyDeliveries": [] + } + }, + "test_expansion_schema": { + "expansion": [ + "default", + "override" + ], + "source_scheme": [ + "http", + "https" + ], + "source_context_list": [ + "top", + "req", + "srcdoc-inherit", + "srcdoc", + "iframe", + "iframe-blank-inherit", + "worker-classic", + "worker-classic-data", + "worker-module", + "worker-module-data", + "sharedworker-classic", + "sharedworker-classic-data", + "sharedworker-module", + "sharedworker-module-data" + ], + "redirection": [ + "no-redirect", + "keep-origin", + "swap-origin", + "keep-scheme", + "swap-scheme", + "downgrade" + ], + "origin": [ + "same-https", + "same-http", + "same-http-downgrade", + "cross-https", + "cross-http", + "cross-http-downgrade", + "same-wss", + "same-ws", + "same-ws-downgrade", + "cross-wss", + "cross-ws", + "cross-ws-downgrade" + ], + "subresource": [ + "a-tag", + "area-tag", + "audio-tag", + "beacon", + "fetch", + "iframe-tag", + "img-tag", + "link-css-tag", + "link-prefetch-tag", + "object-tag", + "picture-tag", + "script-tag", + "script-tag-dynamic-import", + "sharedworker-classic", + "sharedworker-import", + "sharedworker-import-data", + "sharedworker-module", + "video-tag", + "websocket", + "worker-classic", + "worker-import", + "worker-import-data", + "worker-module", + "worklet-animation", + "worklet-animation-import-data", + "worklet-audio", + "worklet-audio-import-data", + "worklet-layout", + "worklet-layout-import-data", + "worklet-paint", + "worklet-paint-import-data", + "xhr" + ] + } +} diff --git a/test/fixtures/wpt/common/security-features/tools/spec_validator.py b/test/fixtures/wpt/common/security-features/tools/spec_validator.py new file mode 100755 index 0000000..e4c9e14 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/spec_validator.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 + +import json, sys + + +def assert_non_empty_string(obj, field): + assert field in obj, 'Missing field "%s"' % field + assert isinstance(obj[field], str), \ + 'Field "%s" must be a string' % field + assert len(obj[field]) > 0, 'Field "%s" must not be empty' % field + + +def assert_non_empty_list(obj, field): + assert isinstance(obj[field], list), \ + '%s must be a list' % field + assert len(obj[field]) > 0, \ + '%s list must not be empty' % field + + +def assert_non_empty_dict(obj, field): + assert isinstance(obj[field], dict), \ + '%s must be a dict' % field + assert len(obj[field]) > 0, \ + '%s dict must not be empty' % field + + +def assert_contains(obj, field): + assert field in obj, 'Must contain field "%s"' % field + + +def assert_value_from(obj, field, items): + assert obj[field] in items, \ + 'Field "%s" must be from: %s' % (field, str(items)) + + +def assert_atom_or_list_items_from(obj, field, items): + if isinstance(obj[field], str) or isinstance( + obj[field], int) or obj[field] is None: + assert_value_from(obj, field, items) + return + + assert isinstance(obj[field], list), '%s must be a list' % field + for allowed_value in obj[field]: + assert allowed_value != '*', "Wildcard is not supported for lists!" + assert allowed_value in items, \ + 'Field "%s" must be from: %s' % (field, str(items)) + + +def assert_contains_only_fields(obj, expected_fields): + for expected_field in expected_fields: + assert_contains(obj, expected_field) + + for actual_field in obj: + assert actual_field in expected_fields, \ + 'Unexpected field "%s".' % actual_field + + +def leaf_values(schema): + if isinstance(schema, list): + return schema + ret = [] + for _, sub_schema in schema.iteritems(): + ret += leaf_values(sub_schema) + return ret + + +def assert_value_unique_in(value, used_values): + assert value not in used_values, 'Duplicate value "%s"!' % str(value) + used_values[value] = True + + +def assert_valid_artifact(exp_pattern, artifact_key, schema): + if isinstance(schema, list): + assert_atom_or_list_items_from(exp_pattern, artifact_key, + ["*"] + schema) + return + + for sub_artifact_key, sub_schema in schema.iteritems(): + assert_valid_artifact(exp_pattern[artifact_key], sub_artifact_key, + sub_schema) + + +def validate(spec_json, details): + """ Validates the json specification for generating tests. """ + + details['object'] = spec_json + assert_contains_only_fields(spec_json, [ + "selection_pattern", "test_file_path_pattern", + "test_description_template", "test_page_title_template", + "specification", "delivery_key", "subresource_schema", + "source_context_schema", "source_context_list_schema", + "test_expansion_schema", "excluded_tests" + ]) + assert_non_empty_list(spec_json, "specification") + assert_non_empty_dict(spec_json, "test_expansion_schema") + assert_non_empty_list(spec_json, "excluded_tests") + + specification = spec_json['specification'] + test_expansion_schema = spec_json['test_expansion_schema'] + excluded_tests = spec_json['excluded_tests'] + + valid_test_expansion_fields = test_expansion_schema.keys() + + # Should be consistent with `sourceContextMap` in + # `/common/security-features/resources/common.sub.js`. + valid_source_context_names = [ + "top", "iframe", "iframe-blank", "srcdoc", "worker-classic", + "worker-module", "worker-classic-data", "worker-module-data", + "sharedworker-classic", "sharedworker-module", + "sharedworker-classic-data", "sharedworker-module-data" + ] + + valid_subresource_names = [ + "a-tag", "area-tag", "audio-tag", "form-tag", "iframe-tag", "img-tag", + "link-css-tag", "link-prefetch-tag", "object-tag", "picture-tag", + "script-tag", "script-tag-dynamic-import", "video-tag" + ] + ["beacon", "fetch", "xhr", "websocket"] + [ + "worker-classic", "worker-module", "worker-import", + "worker-import-data", "sharedworker-classic", "sharedworker-module", + "sharedworker-import", "sharedworker-import-data", + "serviceworker-classic", "serviceworker-module", + "serviceworker-import", "serviceworker-import-data" + ] + [ + "worklet-animation", "worklet-audio", "worklet-layout", + "worklet-paint", "worklet-animation-import", "worklet-audio-import", + "worklet-layout-import", "worklet-paint-import", + "worklet-animation-import-data", "worklet-audio-import-data", + "worklet-layout-import-data", "worklet-paint-import-data" + ] + + # Validate each single spec. + for spec in specification: + details['object'] = spec + + # Validate required fields for a single spec. + assert_contains_only_fields(spec, [ + 'title', 'description', 'specification_url', 'test_expansion' + ]) + assert_non_empty_string(spec, 'title') + assert_non_empty_string(spec, 'description') + assert_non_empty_string(spec, 'specification_url') + assert_non_empty_list(spec, 'test_expansion') + + for spec_exp in spec['test_expansion']: + details['object'] = spec_exp + assert_contains_only_fields(spec_exp, valid_test_expansion_fields) + + for artifact in test_expansion_schema: + details['test_expansion_field'] = artifact + assert_valid_artifact(spec_exp, artifact, + test_expansion_schema[artifact]) + del details['test_expansion_field'] + + # Validate source_context_schema. + details['object'] = spec_json['source_context_schema'] + assert_contains_only_fields( + spec_json['source_context_schema'], + ['supported_delivery_type', 'supported_subresource']) + assert_contains_only_fields( + spec_json['source_context_schema']['supported_delivery_type'], + valid_source_context_names) + for source_context in spec_json['source_context_schema'][ + 'supported_delivery_type']: + assert_valid_artifact( + spec_json['source_context_schema']['supported_delivery_type'], + source_context, test_expansion_schema['delivery_type']) + assert_contains_only_fields( + spec_json['source_context_schema']['supported_subresource'], + valid_source_context_names) + for source_context in spec_json['source_context_schema'][ + 'supported_subresource']: + assert_valid_artifact( + spec_json['source_context_schema']['supported_subresource'], + source_context, leaf_values(test_expansion_schema['subresource'])) + + # Validate subresource_schema. + details['object'] = spec_json['subresource_schema'] + assert_contains_only_fields(spec_json['subresource_schema'], + ['supported_delivery_type']) + assert_contains_only_fields( + spec_json['subresource_schema']['supported_delivery_type'], + leaf_values(test_expansion_schema['subresource'])) + for subresource in spec_json['subresource_schema'][ + 'supported_delivery_type']: + assert_valid_artifact( + spec_json['subresource_schema']['supported_delivery_type'], + subresource, test_expansion_schema['delivery_type']) + + # Validate the test_expansion schema members. + details['object'] = test_expansion_schema + assert_contains_only_fields(test_expansion_schema, [ + 'expansion', 'source_scheme', 'source_context_list', 'delivery_type', + 'delivery_value', 'redirection', 'subresource', 'origin', 'expectation' + ]) + assert_atom_or_list_items_from(test_expansion_schema, 'expansion', + ['default', 'override']) + assert_atom_or_list_items_from(test_expansion_schema, 'source_scheme', + ['http', 'https']) + assert_atom_or_list_items_from( + test_expansion_schema, 'source_context_list', + spec_json['source_context_list_schema'].keys()) + + # Should be consistent with `preprocess_redirection` in + # `/common/security-features/subresource/subresource.py`. + assert_atom_or_list_items_from(test_expansion_schema, 'redirection', [ + 'no-redirect', 'keep-origin', 'swap-origin', 'keep-scheme', + 'swap-scheme', 'downgrade' + ]) + for subresource in leaf_values(test_expansion_schema['subresource']): + assert subresource in valid_subresource_names, "Invalid subresource %s" % subresource + # Should be consistent with getSubresourceOrigin() in + # `/common/security-features/resources/common.sub.js`. + assert_atom_or_list_items_from(test_expansion_schema, 'origin', [ + 'same-http', 'same-https', 'same-ws', 'same-wss', 'cross-http', + 'cross-https', 'cross-ws', 'cross-wss', 'same-http-downgrade', + 'cross-http-downgrade', 'same-ws-downgrade', 'cross-ws-downgrade' + ]) + + # Validate excluded tests. + details['object'] = excluded_tests + for excluded_test_expansion in excluded_tests: + assert_contains_only_fields(excluded_test_expansion, + valid_test_expansion_fields) + details['object'] = excluded_test_expansion + for artifact in test_expansion_schema: + details['test_expansion_field'] = artifact + assert_valid_artifact(excluded_test_expansion, artifact, + test_expansion_schema[artifact]) + del details['test_expansion_field'] + + del details['object'] + + +def assert_valid_spec_json(spec_json): + error_details = {} + try: + validate(spec_json, error_details) + except AssertionError as err: + print('ERROR:', err) + print(json.dumps(error_details, indent=4)) + sys.exit(1) + + +def main(): + spec_json = load_spec_json() + assert_valid_spec_json(spec_json) + print("Spec JSON is valid.") + + +if __name__ == '__main__': + main() diff --git a/test/fixtures/wpt/common/security-features/tools/template/disclaimer.template b/test/fixtures/wpt/common/security-features/tools/template/disclaimer.template new file mode 100644 index 0000000..ba9458c --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/disclaimer.template @@ -0,0 +1 @@ + diff --git a/test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template b/test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template new file mode 100644 index 0000000..e4cbd03 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/spec_json.js.template @@ -0,0 +1 @@ +var SPEC_JSON = %(spec_json)s; diff --git a/test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template b/test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template new file mode 100644 index 0000000..b6be088 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/test.debug.html.template @@ -0,0 +1,26 @@ + +%(generated_disclaimer)s + + + + %(meta_delivery_method)s + + + + + + + +%(helper_js)s + + +
+ + diff --git a/test/fixtures/wpt/common/security-features/tools/template/test.release.html.template b/test/fixtures/wpt/common/security-features/tools/template/test.release.html.template new file mode 100644 index 0000000..bac2d5b --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/template/test.release.html.template @@ -0,0 +1,22 @@ + +%(generated_disclaimer)s + + + + %(meta_delivery_method)s + + + +%(helper_js)s + + +
+ + diff --git a/test/fixtures/wpt/common/security-features/tools/util.py b/test/fixtures/wpt/common/security-features/tools/util.py new file mode 100644 index 0000000..5da06f9 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/tools/util.py @@ -0,0 +1,228 @@ +import os, sys, json, json5, re +import collections + +script_directory = os.path.dirname(os.path.abspath(__file__)) +template_directory = os.path.abspath( + os.path.join(script_directory, 'template')) +test_root_directory = os.path.abspath( + os.path.join(script_directory, '..', '..', '..')) + + +def get_template(basename): + with open(os.path.join(template_directory, basename), "r") as f: + return f.read() + + +def write_file(filename, contents): + with open(filename, "w") as f: + f.write(contents) + + +def read_nth_line(fp, line_number): + fp.seek(0) + for i, line in enumerate(fp): + if (i + 1) == line_number: + return line + + +def load_spec_json(path_to_spec): + re_error_location = re.compile('line ([0-9]+) column ([0-9]+)') + with open(path_to_spec, "r") as f: + try: + return json5.load(f, object_pairs_hook=collections.OrderedDict) + except ValueError as ex: + print(ex.message) + match = re_error_location.search(ex.message) + if match: + line_number, column = int(match.group(1)), int(match.group(2)) + print(read_nth_line(f, line_number).rstrip()) + print(" " * (column - 1) + "^") + sys.exit(1) + + +class ShouldSkip(Exception): + ''' + Raised when the given combination of subresource type, source context type, + delivery type etc. are not supported and we should skip that configuration. + ShouldSkip is expected in normal generator execution (and thus subsequent + generation continues), as we first enumerate a broad range of configurations + first, and later raise ShouldSkip to filter out unsupported combinations. + + ShouldSkip is distinguished from other general errors that cause immediate + termination of the generator and require fix. + ''' + def __init__(self): + pass + + +class PolicyDelivery(object): + ''' + See `@typedef PolicyDelivery` comments in + `common/security-features/resources/common.sub.js`. + ''' + + def __init__(self, delivery_type, key, value): + self.delivery_type = delivery_type + self.key = key + self.value = value + + def __eq__(self, other): + return type(self) is type(other) and self.__dict__ == other.__dict__ + + @classmethod + def list_from_json(cls, list, target_policy_delivery, + supported_delivery_types): + # type: (dict, PolicyDelivery, typing.List[str]) -> typing.List[PolicyDelivery] + ''' + Parses a JSON object `list` that represents a list of `PolicyDelivery` + and returns a list of `PolicyDelivery`, plus supporting placeholders + (see `from_json()` comments below or + `common/security-features/README.md`). + + Can raise `ShouldSkip`. + ''' + if list is None: + return [] + + out = [] + for obj in list: + policy_delivery = PolicyDelivery.from_json( + obj, target_policy_delivery, supported_delivery_types) + # Drop entries with null values. + if policy_delivery.value is None: + continue + out.append(policy_delivery) + return out + + @classmethod + def from_json(cls, obj, target_policy_delivery, supported_delivery_types): + # type: (dict, PolicyDelivery, typing.List[str]) -> PolicyDelivery + ''' + Parses a JSON object `obj` and returns a `PolicyDelivery` object. + In addition to dicts (in the same format as to_json() outputs), + this method accepts the following placeholders: + "policy": + `target_policy_delivery` + "policyIfNonNull": + `target_policy_delivery` if its value is not None. + "anotherPolicy": + A PolicyDelivery that has the same key as + `target_policy_delivery` but a different value. + The delivery type is selected from `supported_delivery_types`. + + Can raise `ShouldSkip`. + ''' + + if obj == "policy": + policy_delivery = target_policy_delivery + elif obj == "nonNullPolicy": + if target_policy_delivery.value is None: + raise ShouldSkip() + policy_delivery = target_policy_delivery + elif obj == "anotherPolicy": + if len(supported_delivery_types) == 0: + raise ShouldSkip() + policy_delivery = target_policy_delivery.get_another_policy( + supported_delivery_types[0]) + elif isinstance(obj, dict): + policy_delivery = PolicyDelivery(obj['deliveryType'], obj['key'], + obj['value']) + else: + raise Exception('policy delivery is invalid: ' + obj) + + # Omit unsupported combinations of source contexts and delivery type. + if policy_delivery.delivery_type not in supported_delivery_types: + raise ShouldSkip() + + return policy_delivery + + def to_json(self): + # type: () -> dict + return { + "deliveryType": self.delivery_type, + "key": self.key, + "value": self.value + } + + def get_another_policy(self, delivery_type): + # type: (str) -> PolicyDelivery + if self.key == 'referrerPolicy': + # Return 'unsafe-url' (i.e. more unsafe policy than `self.value`) + # as long as possible, to make sure the tests to fail if the + # returned policy is used unexpectedly instead of `self.value`. + # Using safer policy wouldn't be distinguishable from acceptable + # arbitrary policy enforcement by user agents, as specified at + # Step 7 of + # https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer: + # "The user agent MAY alter referrerURL or referrerOrigin at this + # point to enforce arbitrary policy considerations in the + # interests of minimizing data leakage." + # See also the comments at `referrerUrlResolver` in + # `wpt/referrer-policy/generic/test-case.sub.js`. + if self.value != 'unsafe-url': + return PolicyDelivery(delivery_type, self.key, 'unsafe-url') + else: + return PolicyDelivery(delivery_type, self.key, 'no-referrer') + elif self.key == 'mixedContent': + if self.value == 'opt-in': + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'opt-in') + elif self.key == 'contentSecurityPolicy': + if self.value is not None: + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'worker-src-none') + elif self.key == 'upgradeInsecureRequests': + if self.value == 'upgrade': + return PolicyDelivery(delivery_type, self.key, None) + else: + return PolicyDelivery(delivery_type, self.key, 'upgrade') + else: + raise Exception('delivery key is invalid: ' + self.key) + + +class SourceContext(object): + def __init__(self, source_context_type, policy_deliveries): + # type: (unicode, typing.List[PolicyDelivery]) -> None + self.source_context_type = source_context_type + self.policy_deliveries = policy_deliveries + + def __eq__(self, other): + return type(self) is type(other) and self.__dict__ == other.__dict__ + + @classmethod + def from_json(cls, obj, target_policy_delivery, source_context_schema): + ''' + Parses a JSON object `obj` and returns a `SourceContext` object. + + `target_policy_delivery` and `source_context_schema` are used for + policy delivery placeholders and filtering out unsupported + delivery types. + + Can raise `ShouldSkip`. + ''' + source_context_type = obj.get('sourceContextType') + policy_deliveries = PolicyDelivery.list_from_json( + obj.get('policyDeliveries'), target_policy_delivery, + source_context_schema['supported_delivery_type'] + [source_context_type]) + return SourceContext(source_context_type, policy_deliveries) + + def to_json(self): + return { + "sourceContextType": self.source_context_type, + "policyDeliveries": [x.to_json() for x in self.policy_deliveries] + } + + +class CustomEncoder(json.JSONEncoder): + ''' + Used to dump dicts containing `SourceContext`/`PolicyDelivery` into JSON. + ''' + def default(self, obj): + if isinstance(obj, SourceContext): + return obj.to_json() + if isinstance(obj, PolicyDelivery): + return obj.to_json() + return json.JSONEncoder.default(self, obj) diff --git a/test/fixtures/wpt/common/security-features/types.md b/test/fixtures/wpt/common/security-features/types.md new file mode 100644 index 0000000..1707991 --- /dev/null +++ b/test/fixtures/wpt/common/security-features/types.md @@ -0,0 +1,62 @@ +# Types around the generator and generated tests + +This document describes types and concepts used across JavaScript and Python parts of this test framework. +Please refer to the JSDoc in `common.sub.js` or docstrings in Python scripts (if any). + +## Scenario + +### Properties + +- All keys of `test_expansion_schema` in `spec.src.json`, except for `expansion`, `delivery_type`, `delivery_value`, and `source_context_list`. Their values are **string**s specified in `test_expansion_schema`. +- `source_context_list` +- `subresource_policy_deliveries` + +### Types + +- Generator (`spec.src.json`): JSON object +- Generator (Python): `dict` +- Runtime (JS): JSON object +- Runtime (Python): N/A + +## `PolicyDelivery` + +### Types + +- Generator (`spec.src.json`): JSON object +- Generator (Python): `util.PolicyDelivery` +- Runtime (JS): JSON object (`@typedef PolicyDelivery` in `common.sub.js`) +- Runtime (Python): N/A + +## `SourceContext` + +Subresource requests can be possibly sent from various kinds of fetch client's environment settings objects. For example: + +- top-level windows, +- ` + + diff --git a/test/fixtures/wpt/eventsource/eventsource-cross-origin.window.js b/test/fixtures/wpt/eventsource/eventsource-cross-origin.window.js new file mode 100644 index 0000000..23bd27a --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-cross-origin.window.js @@ -0,0 +1,51 @@ +// META: title=EventSource: cross-origin + + const crossdomain = location.href.replace('://', '://élève.').replace(/\/[^\/]*$/, '/'), + origin = location.origin.replace('://', '://xn--lve-6lad.'); + + + function doCORS(url, title) { + async_test(document.title + " " + title).step(function() { + var source = new EventSource(url, { withCredentials: true }) + source.onmessage = this.step_func_done(e => { + assert_equals(e.data, "data"); + assert_equals(e.origin, origin); + source.close(); + }) + }) + } + + doCORS(crossdomain + "resources/cors.py?run=message", + "basic use") + doCORS(crossdomain + "resources/cors.py?run=redirect&location=/eventsource/resources/cors.py?run=message", + "redirect use") + doCORS(crossdomain + "resources/cors.py?run=status-reconnect&status=200", + "redirect use recon") + + function failCORS(url, title) { + async_test(document.title + " " + title).step(function() { + var source = new EventSource(url) + source.onerror = this.step_func(function(e) { + assert_equals(source.readyState, source.CLOSED, 'readyState') + assert_false(e.hasOwnProperty('data')) + source.close() + this.done() + }) + + /* Shouldn't happen */ + source.onmessage = this.step_func(function(e) { + assert_unreached("shouldn't fire message event") + }) + source.onopen = this.step_func(function(e) { + assert_unreached("shouldn't fire open event") + }) + }) + } + + failCORS(crossdomain + "resources/cors.py?run=message&origin=http://example.org", + "allow-origin: http://example.org should fail") + failCORS(crossdomain + "resources/cors.py?run=message&origin=", + "allow-origin:'' should fail") + failCORS(crossdomain + "resources/message.py", + "No allow-origin should fail") + diff --git a/test/fixtures/wpt/eventsource/eventsource-eventtarget.any.js b/test/fixtures/wpt/eventsource/eventsource-eventtarget.any.js new file mode 100644 index 0000000..b0d0017 --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-eventtarget.any.js @@ -0,0 +1,16 @@ +// META: title=EventSource: addEventListener() + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py") + source.addEventListener("message", listener, false) + }) + function listener(e) { + test.step(function() { + assert_equals("data", e.data) + this.close() + }, this) + test.done() + } + + diff --git a/test/fixtures/wpt/eventsource/eventsource-onmessage-realm.htm b/test/fixtures/wpt/eventsource/eventsource-onmessage-realm.htm new file mode 100644 index 0000000..db2218b --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-onmessage-realm.htm @@ -0,0 +1,25 @@ + + +EventSource: message event Realm + + + + + + + diff --git a/test/fixtures/wpt/eventsource/eventsource-onmessage-trusted.any.js b/test/fixtures/wpt/eventsource/eventsource-onmessage-trusted.any.js new file mode 100644 index 0000000..d0be4d0 --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-onmessage-trusted.any.js @@ -0,0 +1,12 @@ +// META: title=EventSource message events are trusted + +"use strict"; + +async_test(t => { + const source = new EventSource("resources/message.py"); + + source.onmessage = t.step_func_done(e => { + source.close(); + assert_equals(e.isTrusted, true); + }); +}, "EventSource message events are trusted"); diff --git a/test/fixtures/wpt/eventsource/eventsource-onmessage.any.js b/test/fixtures/wpt/eventsource/eventsource-onmessage.any.js new file mode 100644 index 0000000..391fa4b --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-onmessage.any.js @@ -0,0 +1,14 @@ +// META: title=EventSource: onmessage + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py") + source.onmessage = function(e) { + test.step(function() { + assert_equals("data", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/eventsource-onopen.any.js b/test/fixtures/wpt/eventsource/eventsource-onopen.any.js new file mode 100644 index 0000000..3977cb1 --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-onopen.any.js @@ -0,0 +1,17 @@ +// META: title=EventSource: onopen (announcing the connection) + + var test = async_test() + test.step(function() { + source = new EventSource("resources/message.py") + source.onopen = function(e) { + test.step(function() { + assert_equals(source.readyState, source.OPEN) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + this.close() + }, this) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/eventsource-prototype.any.js b/test/fixtures/wpt/eventsource/eventsource-prototype.any.js new file mode 100644 index 0000000..b7aefb3 --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-prototype.any.js @@ -0,0 +1,10 @@ +// META: title=EventSource: prototype et al + + test(function() { + EventSource.prototype.ReturnTrue = function() { return true } + var source = new EventSource("resources/message.py") + assert_true(source.ReturnTrue()) + assert_own_property(self, "EventSource") + source.close() + }) + diff --git a/test/fixtures/wpt/eventsource/eventsource-reconnect.window.js b/test/fixtures/wpt/eventsource/eventsource-reconnect.window.js new file mode 100644 index 0000000..551fbdc --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-reconnect.window.js @@ -0,0 +1,47 @@ +// META: title=EventSource: reconnection + + function doReconn(url, title) { + var test = async_test(document.title + " " + title) + test.step(function() { + var source = new EventSource(url) + source.onmessage = test.step_func(function(e) { + assert_equals(e.data, "data") + source.close() + test.done() + }) + }) + } + + doReconn("resources/status-reconnect.py?status=200", + "200") + + + var t = async_test(document.title + ", test reconnection events"); + t.step(function() { + var opened = false, reconnected = false, + source = new EventSource("resources/status-reconnect.py?status=200&ok_first&id=2"); + + source.onerror = t.step_func(function(e) { + assert_equals(e.type, 'error'); + assert_equals(source.readyState, source.CONNECTING, "readyState"); + assert_true(opened, "connection is opened earlier"); + + reconnected = true; + }); + + source.onmessage = t.step_func(function(e) { + if (!opened) { + opened = true; + assert_false(reconnected, "have reconnected before first message"); + assert_equals(e.data, "ok"); + } + else { + assert_true(reconnected, "Got reconnection event"); + assert_equals(e.data, "data"); + source.close() + t.done() + } + }); + }); + + diff --git a/test/fixtures/wpt/eventsource/eventsource-request-cancellation.window.js b/test/fixtures/wpt/eventsource/eventsource-request-cancellation.window.js new file mode 100644 index 0000000..1cee9b7 --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-request-cancellation.window.js @@ -0,0 +1,21 @@ +// META: title=EventSource: request cancellation + + var t = async_test(); + onload = t.step_func(function() { + var url = "resources/message.py?sleep=1000&message=" + encodeURIComponent("retry:1000\ndata:abc\n\n"); + var es = new EventSource(url); + es.onerror = t.step_func(function() { + assert_equals(es.readyState, EventSource.CLOSED) + t.step_timeout(function () { + assert_equals(es.readyState, EventSource.CLOSED, + "After stopping the eventsource readyState should be CLOSED") + t.done(); + }, 1000); + }); + + t.step_timeout(function() { + window.stop() + es.onopen = t.unreached_func("Got open event"); + es.onmessage = t.unreached_func("Got message after closing source"); + }, 0); + }); diff --git a/test/fixtures/wpt/eventsource/eventsource-url.any.js b/test/fixtures/wpt/eventsource/eventsource-url.any.js new file mode 100644 index 0000000..92207ea --- /dev/null +++ b/test/fixtures/wpt/eventsource/eventsource-url.any.js @@ -0,0 +1,8 @@ +// META: title=EventSource: url + + test(function() { + var url = "resources/message.py", + source = new EventSource(url) + assert_equals(source.url.substr(-(url.length)), url) + source.close() + }) diff --git a/test/fixtures/wpt/eventsource/format-bom-2.any.js b/test/fixtures/wpt/eventsource/format-bom-2.any.js new file mode 100644 index 0000000..8b7be84 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-bom-2.any.js @@ -0,0 +1,24 @@ +// META: title=EventSource: Double BOM + + var test = async_test(), + hasbeenone = false, + hasbeentwo = false + test.step(function() { + var source = new EventSource("resources/message.py?message=%EF%BB%BF%EF%BB%BFdata%3A1%0A%0Adata%3A2%0A%0Adata%3A3") + source.addEventListener("message", listener, false) + }) + function listener(e) { + test.step(function() { + if(e.data == "1") + hasbeenone = true + if(e.data == "2") + hasbeentwo = true + if(e.data == "3") { + assert_false(hasbeenone) + assert_true(hasbeentwo) + this.close() + test.done() + } + }, this) + } + diff --git a/test/fixtures/wpt/eventsource/format-bom.any.js b/test/fixtures/wpt/eventsource/format-bom.any.js new file mode 100644 index 0000000..05d1abd --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-bom.any.js @@ -0,0 +1,24 @@ +// META: title=EventSource: BOM + + var test = async_test(), + hasbeenone = false, + hasbeentwo = false + test.step(function() { + var source = new EventSource("resources/message.py?message=%EF%BB%BFdata%3A1%0A%0A%EF%BB%BFdata%3A2%0A%0Adata%3A3") + source.addEventListener("message", listener, false) + }) + function listener(e) { + test.step(function() { + if(e.data == "1") + hasbeenone = true + if(e.data == "2") + hasbeentwo = true + if(e.data == "3") { + assert_true(hasbeenone) + assert_false(hasbeentwo) + this.close() + test.done() + } + }, this) + } + diff --git a/test/fixtures/wpt/eventsource/format-comments.any.js b/test/fixtures/wpt/eventsource/format-comments.any.js new file mode 100644 index 0000000..186e471 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-comments.any.js @@ -0,0 +1,16 @@ +// META: title=EventSource: comment fest + + var test = async_test() + test.step(function() { + var longstring = (new Array(2*1024+1)).join("x"), // cannot make the string too long; causes timeout + message = encodeURI("data:1\r:\0\n:\r\ndata:2\n:" + longstring + "\rdata:3\n:data:fail\r:" + longstring + "\ndata:4\n"), + source = new EventSource("resources/message.py?message=" + message + "&newline=none") + source.onmessage = function(e) { + test.step(function() { + assert_equals("1\n2\n3\n4", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-data-before-final-empty-line.any.js b/test/fixtures/wpt/eventsource/format-data-before-final-empty-line.any.js new file mode 100644 index 0000000..5a4d84d --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-data-before-final-empty-line.any.js @@ -0,0 +1,17 @@ +// META: title=EventSource: a data before final empty line + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?newline=none&message=" + encodeURIComponent("retry:1000\ndata:test1\n\nid:test\ndata:test2")) + var count = 0; + source.onmessage = function(e) { + if (++count === 2) { + test.step(function() { + assert_equals(e.lastEventId, "", "lastEventId") + assert_equals(e.data, "test1", "data") + source.close() + }) + test.done() + } + } + }) diff --git a/test/fixtures/wpt/eventsource/format-field-data.any.js b/test/fixtures/wpt/eventsource/format-field-data.any.js new file mode 100644 index 0000000..bea9be1 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-data.any.js @@ -0,0 +1,23 @@ +// META: title=EventSource: data field parsing + + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3A%0A%0Adata%0Adata%0A%0Adata%3Atest"), + counter = 0 + source.onmessage = function(e) { + test.step(function() { + if(counter == 0) { + assert_equals("", e.data) + } else if(counter == 1) { + assert_equals("\n", e.data) + } else if(counter == 2) { + assert_equals("test", e.data) + source.close() + test.done() + } else { + assert_unreached() + } + counter++ + }) + } + }) diff --git a/test/fixtures/wpt/eventsource/format-field-event-empty.any.js b/test/fixtures/wpt/eventsource/format-field-event-empty.any.js new file mode 100644 index 0000000..ada8e57 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-event-empty.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: empty "event" field + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=event%3A%20%0Adata%3Adata") + source.onmessage = function(e) { + test.step(function() { + assert_equals("data", e.data) + this.close() + }, this) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-field-event.any.js b/test/fixtures/wpt/eventsource/format-field-event.any.js new file mode 100644 index 0000000..0c7d1fc --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-event.any.js @@ -0,0 +1,15 @@ +// META: title=EventSource: custom event name + var test = async_test(), + dispatchedtest = false + test.step(function() { + var source = new EventSource("resources/message.py?message=event%3Atest%0Adata%3Ax%0A%0Adata%3Ax") + source.addEventListener("test", function() { test.step(function() { dispatchedtest = true }) }, false) + source.onmessage = function() { + test.step(function() { + assert_true(dispatchedtest) + this.close() + }, this) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-field-id-2.any.js b/test/fixtures/wpt/eventsource/format-field-id-2.any.js new file mode 100644 index 0000000..9933f46 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-id-2.any.js @@ -0,0 +1,25 @@ +// META: title=EventSource: Last-Event-ID (2) + var test = async_test() + test.step(function() { + var source = new EventSource("resources/last-event-id.py"), + counter = 0 + source.onmessage = function(e) { + test.step(function() { + if(e.data == "hello" && counter == 0) { + counter++ + assert_equals(e.lastEventId, "…") + } else if(counter == 1) { + counter++ + assert_equals("…", e.data) + assert_equals("…", e.lastEventId) + } else if(counter == 2) { + counter++ + assert_equals("…", e.data) + assert_equals("…", e.lastEventId) + source.close() + test.done() + } else + assert_unreached() + }) + } + }) diff --git a/test/fixtures/wpt/eventsource/format-field-id-3.window.js b/test/fixtures/wpt/eventsource/format-field-id-3.window.js new file mode 100644 index 0000000..3766fbf --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-id-3.window.js @@ -0,0 +1,56 @@ +const ID_PERSISTS = 1, +ID_RESETS_1 = 2, +ID_RESETS_2 = 3; + +async_test(testPersist, "EventSource: lastEventId persists"); +async_test(testReset(ID_RESETS_1), "EventSource: lastEventId resets"); +async_test(testReset(ID_RESETS_2), "EventSource: lastEventId resets (id without colon)"); + +function testPersist(t) { + const source = new EventSource("resources/last-event-id2.py?type=" + ID_PERSISTS); + let counter = 0; + t.add_cleanup(() => source.close()); + source.onmessage = t.step_func(e => { + counter++; + if (counter === 1) { + assert_equals(e.lastEventId, "1"); + assert_equals(e.data, "1"); + } else if (counter === 2) { + assert_equals(e.lastEventId, "1"); + assert_equals(e.data, "2"); + } else if (counter === 3) { + assert_equals(e.lastEventId, "2"); + assert_equals(e.data, "3"); + } else if (counter === 4) { + assert_equals(e.lastEventId, "2"); + assert_equals(e.data, "4"); + t.done(); + } else { + assert_unreached(); + } + }); +} + +function testReset(type) { + return function (t) { + const source = new EventSource("resources/last-event-id2.py?type=" + type); + let counter = 0; + t.add_cleanup(() => source.close()); + source.onmessage = t.step_func(e => { + counter++; + if (counter === 1) { + assert_equals(e.lastEventId, "1"); + assert_equals(e.data, "1"); + } else if (counter === 2) { + assert_equals(e.lastEventId, ""); + assert_equals(e.data, "2"); + } else if (counter === 3) { + assert_equals(e.lastEventId, ""); + assert_equals(e.data, "3"); + t.done(); + } else { + assert_unreached(); + } + }); + } +} diff --git a/test/fixtures/wpt/eventsource/format-field-id-null.window.js b/test/fixtures/wpt/eventsource/format-field-id-null.window.js new file mode 100644 index 0000000..6d564dd --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-id-null.window.js @@ -0,0 +1,25 @@ +[ + "\u0000\u0000", + "x\u0000", + "\u0000x", + "x\u0000x", + " \u0000" +].forEach(idValue => { + const encodedIdValue = encodeURIComponent(idValue); + async_test(t => { + const source = new EventSource("resources/last-event-id.py?idvalue=" + encodedIdValue); + t.add_cleanup(() => source.close()); + let seenhello = false; + source.onmessage = t.step_func(e => { + if (e.data == "hello" && !seenhello) { + seenhello = true; + assert_equals(e.lastEventId, ""); + } else if(seenhello) { + assert_equals(e.data, "hello"); + assert_equals(e.lastEventId, ""); + t.done(); + } else + assert_unreached(); + }); + }, "EventSource: id field set to " + encodedIdValue); +}); diff --git a/test/fixtures/wpt/eventsource/format-field-id.any.js b/test/fixtures/wpt/eventsource/format-field-id.any.js new file mode 100644 index 0000000..26f1aea --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-id.any.js @@ -0,0 +1,21 @@ +// META: title=EventSource: Last-Event-ID + var test = async_test() + test.step(function() { + var source = new EventSource("resources/last-event-id.py"), + seenhello = false + source.onmessage = function(e) { + test.step(function() { + if(e.data == "hello" && !seenhello) { + seenhello = true + assert_equals(e.lastEventId, "…") + } else if(seenhello) { + assert_equals("…", e.data) + assert_equals("…", e.lastEventId) + source.close() + test.done() + } else + assert_unreached() + }) + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-field-parsing.any.js b/test/fixtures/wpt/eventsource/format-field-parsing.any.js new file mode 100644 index 0000000..9b05187 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-parsing.any.js @@ -0,0 +1,14 @@ +// META: title=EventSource: field parsing + var test = async_test() + test.step(function() { + var message = encodeURI("data:\0\ndata: 2\rData:1\ndata\0:2\ndata:1\r\0data:4\nda-ta:3\rdata_5\ndata:3\rdata:\r\n data:32\ndata:4\n"), + source = new EventSource("resources/message.py?message=" + message + "&newline=none") + source.onmessage = function(e) { + test.step(function() { + assert_equals(e.data, "\0\n 2\n1\n3\n\n4") + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-field-retry-bogus.any.js b/test/fixtures/wpt/eventsource/format-field-retry-bogus.any.js new file mode 100644 index 0000000..86d9b9e --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-retry-bogus.any.js @@ -0,0 +1,19 @@ +// META: title=EventSource: "retry" field (bogus) + var test = async_test() + test.step(function() { + var timeoutms = 3000, + source = new EventSource("resources/message.py?message=retry%3A3000%0Aretry%3A1000x%0Adata%3Ax"), + opened = 0 + source.onopen = function() { + test.step(function() { + if(opened == 0) + opened = new Date().getTime() + else { + var diff = (new Date().getTime()) - opened + assert_true(Math.abs(1 - diff / timeoutms) < 0.25) // allow 25% difference + this.close(); + test.done() + } + }, this) + } + }) diff --git a/test/fixtures/wpt/eventsource/format-field-retry-empty.any.js b/test/fixtures/wpt/eventsource/format-field-retry-empty.any.js new file mode 100644 index 0000000..e7d5e76 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-retry-empty.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: empty retry field + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=retry%0Adata%3Atest") + source.onmessage = function(e) { + test.step(function() { + assert_equals("test", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-field-retry.any.js b/test/fixtures/wpt/eventsource/format-field-retry.any.js new file mode 100644 index 0000000..819241d --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-retry.any.js @@ -0,0 +1,21 @@ +// META: title=EventSource: "retry" field + var test = async_test(); + test.step(function() { + var timeoutms = 3000, + timeoutstr = "03000", // 1536 in octal, but should be 3000 + source = new EventSource("resources/message.py?message=retry%3A" + timeoutstr + "%0Adata%3Ax"), + opened = 0 + source.onopen = function() { + test.step(function() { + if(opened == 0) + opened = new Date().getTime() + else { + var diff = (new Date().getTime()) - opened + assert_true(Math.abs(1 - diff / timeoutms) < 0.25) // allow 25% difference + this.close(); + test.done() + } + }, this) + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-field-unknown.any.js b/test/fixtures/wpt/eventsource/format-field-unknown.any.js new file mode 100644 index 0000000..f702ed8 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-field-unknown.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: unknown fields and parsing fun + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3Atest%0A%20data%0Adata%0Afoobar%3Axxx%0Ajustsometext%0A%3Athisisacommentyay%0Adata%3Atest") + source.onmessage = function(e) { + test.step(function() { + assert_equals("test\n\ntest", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-leading-space.any.js b/test/fixtures/wpt/eventsource/format-leading-space.any.js new file mode 100644 index 0000000..0ddfd9b --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-leading-space.any.js @@ -0,0 +1,14 @@ +// META: title=EventSource: leading space + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3A%09test%0Ddata%3A%20%0Adata%3Atest") + source.onmessage = function(e) { + test.step(function() { + assert_equals("\ttest\n\ntest", e.data) + source.close() + }) + test.done() + } + }) + // also used a CR as newline once + diff --git a/test/fixtures/wpt/eventsource/format-mime-bogus.any.js b/test/fixtures/wpt/eventsource/format-mime-bogus.any.js new file mode 100644 index 0000000..18c7c7d --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-mime-bogus.any.js @@ -0,0 +1,25 @@ +// META: title=EventSource: bogus MIME type + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?mime=x%20bogus") + source.onmessage = function() { + test.step(function() { + assert_unreached() + source.close() + }) + test.done() + } + source.onerror = function(e) { + test.step(function() { + assert_equals(this.readyState, this.CLOSED) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + this.close() + }, this) + test.done() + } + }) + // This tests "fails the connection" as well as making sure a simple + // event is dispatched and not a MessageEvent + diff --git a/test/fixtures/wpt/eventsource/format-mime-trailing-semicolon.any.js b/test/fixtures/wpt/eventsource/format-mime-trailing-semicolon.any.js new file mode 100644 index 0000000..55a314b --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-mime-trailing-semicolon.any.js @@ -0,0 +1,20 @@ +// META: title=EventSource: MIME type with trailing ; + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?mime=text/event-stream%3B") + source.onopen = function() { + test.step(function() { + assert_equals(source.readyState, source.OPEN) + source.close() + }) + test.done() + } + source.onerror = function() { + test.step(function() { + assert_unreached() + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-mime-valid-bogus.any.js b/test/fixtures/wpt/eventsource/format-mime-valid-bogus.any.js new file mode 100644 index 0000000..355ba6c --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-mime-valid-bogus.any.js @@ -0,0 +1,24 @@ +// META: title=EventSource: incorrect valid MIME type + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?mime=text/x-bogus") + source.onmessage = function() { + test.step(function() { + assert_unreached() + source.close() + }) + test.done() + } + source.onerror = function(e) { + test.step(function() { + assert_equals(source.readyState, source.CLOSED) + assert_false(e.hasOwnProperty('data')) + assert_false(e.bubbles) + assert_false(e.cancelable) + }) + test.done() + } + }) + // This tests "fails the connection" as well as making sure a simple + // event is dispatched and not a MessageEvent + diff --git a/test/fixtures/wpt/eventsource/format-newlines.any.js b/test/fixtures/wpt/eventsource/format-newlines.any.js new file mode 100644 index 0000000..0768171 --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-newlines.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: newline fest + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3Atest%0D%0Adata%0Adata%3Atest%0D%0A%0D&newline=none") + source.onmessage = function(e) { + test.step(function() { + assert_equals("test\n\ntest", e.data) + source.close() + }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-null-character.any.js b/test/fixtures/wpt/eventsource/format-null-character.any.js new file mode 100644 index 0000000..943628d --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-null-character.any.js @@ -0,0 +1,17 @@ +// META: title=EventSource: null character in response + var test = async_test() + test.step(function() { + var source = new EventSource("resources/message.py?message=data%3A%00%0A%0A") + source.onmessage = function(e) { + test.step(function() { + assert_equals("\x00", e.data) + source.close() + }, this) + test.done() + } + source.onerror = function() { + test.step(function() { assert_unreached() }) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/format-utf-8.any.js b/test/fixtures/wpt/eventsource/format-utf-8.any.js new file mode 100644 index 0000000..7976abf --- /dev/null +++ b/test/fixtures/wpt/eventsource/format-utf-8.any.js @@ -0,0 +1,12 @@ +// META: title=EventSource always UTF-8 +async_test().step(function() { + var source = new EventSource("resources/message.py?mime=text/event-stream%3bcharset=windows-1252&message=data%3Aok%E2%80%A6") + source.onmessage = this.step_func(function(e) { + assert_equals('ok…', e.data, 'decoded data') + source.close() + this.done() + }) + source.onerror = this.step_func(function() { + assert_unreached("Got error event") + }) +}) diff --git a/test/fixtures/wpt/eventsource/request-accept.any.js b/test/fixtures/wpt/eventsource/request-accept.any.js new file mode 100644 index 0000000..2e18173 --- /dev/null +++ b/test/fixtures/wpt/eventsource/request-accept.any.js @@ -0,0 +1,13 @@ +// META: title=EventSource: Accept header + var test = async_test() + test.step(function() { + var source = new EventSource("resources/accept.event_stream?pipe=sub") + source.onmessage = function(e) { + test.step(function() { + assert_equals(e.data, "text/event-stream") + this.close() + }, this) + test.done() + } + }) + diff --git a/test/fixtures/wpt/eventsource/request-cache-control.any.js b/test/fixtures/wpt/eventsource/request-cache-control.any.js new file mode 100644 index 0000000..95b71d7 --- /dev/null +++ b/test/fixtures/wpt/eventsource/request-cache-control.any.js @@ -0,0 +1,35 @@ +// META: title=EventSource: Cache-Control + var crossdomain = location.href + .replace('://', '://www2.') + .replace(/\/[^\/]*$/, '/') + + // running it twice to check whether it stays consistent + function cacheTest(url) { + var test = async_test(url + "1") + // Recursive test. This avoids test that timeout + var test2 = async_test(url + "2") + test.step(function() { + var source = new EventSource(url) + source.onmessage = function(e) { + test.step(function() { + assert_equals(e.data, "no-cache") + this.close() + test2.step(function() { + var source2 = new EventSource(url) + source2.onmessage = function(e) { + test2.step(function() { + assert_equals(e.data, "no-cache") + this.close() + }, this) + test2.done() + } + }) + }, this) + test.done() + } + }) + } + + cacheTest("resources/cache-control.event_stream?pipe=sub") + cacheTest(crossdomain + "resources/cors.py?run=cache-control") + diff --git a/test/fixtures/wpt/eventsource/request-credentials.window.js b/test/fixtures/wpt/eventsource/request-credentials.window.js new file mode 100644 index 0000000..d7c554a --- /dev/null +++ b/test/fixtures/wpt/eventsource/request-credentials.window.js @@ -0,0 +1,37 @@ +// META: title=EventSource: credentials + var crossdomain = location.href + .replace('://', '://www2.') + .replace(/\/[^\/]*$/, '/') + + function testCookie(desc, success, props, id) { + var test = async_test(document.title + ': credentials ' + desc) + test.step(function() { + var source = new EventSource(crossdomain + "resources/cors-cookie.py?ident=" + id, props) + + source.onmessage = test.step_func(function(e) { + if(e.data.indexOf("first") == 0) { + assert_equals(e.data, "first NO_COOKIE", "cookie status") + } + else if(e.data.indexOf("second") == 0) { + if (success) + assert_equals(e.data, "second COOKIE", "cookie status") + else + assert_equals(e.data, "second NO_COOKIE", "cookie status") + + source.close() + test.done() + } + else { + assert_unreached("unrecognized data returned: " + e.data) + source.close() + test.done() + } + }) + }) + } + + testCookie('enabled', true, { withCredentials: true }, '1_' + new Date().getTime()) + testCookie('disabled', false, { withCredentials: false }, '2_' + new Date().getTime()) + testCookie('default', false, { }, '3_' + new Date().getTime()) + + diff --git a/test/fixtures/wpt/eventsource/request-redirect.window.js b/test/fixtures/wpt/eventsource/request-redirect.window.js new file mode 100644 index 0000000..3788dd8 --- /dev/null +++ b/test/fixtures/wpt/eventsource/request-redirect.window.js @@ -0,0 +1,24 @@ +// META: title=EventSource: redirect + function redirectTest(status) { + var test = async_test(document.title + " (" + status +")") + test.step(function() { + var source = new EventSource("/common/redirect.py?location=/eventsource/resources/message.py&status=" + status) + source.onopen = function() { + test.step(function() { + assert_equals(this.readyState, this.OPEN) + this.close() + }, this) + test.done() + } + source.onerror = function() { + test.step(function() { assert_unreached() }) + test.done() + } + }) + } + + redirectTest("301") + redirectTest("302") + redirectTest("303") + redirectTest("307") + diff --git a/test/fixtures/wpt/eventsource/request-status-error.window.js b/test/fixtures/wpt/eventsource/request-status-error.window.js new file mode 100644 index 0000000..8632d8e --- /dev/null +++ b/test/fixtures/wpt/eventsource/request-status-error.window.js @@ -0,0 +1,27 @@ +// META: title=EventSource: incorrect HTTP status code + function statusTest(status) { + var test = async_test(document.title + " (" + status +")") + test.step(function() { + var source = new EventSource("resources/status-error.py?status=" + status) + source.onmessage = function() { + test.step(function() { + assert_unreached() + }) + test.done() + } + source.onerror = function() { + test.step(function() { + assert_equals(this.readyState, this.CLOSED) + }, this) + test.done() + } + }) + } + statusTest("204") + statusTest("205") + statusTest("210") + statusTest("299") + statusTest("404") + statusTest("410") + statusTest("503") + diff --git a/test/fixtures/wpt/eventsource/resources/accept.event_stream b/test/fixtures/wpt/eventsource/resources/accept.event_stream new file mode 100644 index 0000000..24da548 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/accept.event_stream @@ -0,0 +1,2 @@ +data: {{headers[accept]}} + diff --git a/test/fixtures/wpt/eventsource/resources/cache-control.event_stream b/test/fixtures/wpt/eventsource/resources/cache-control.event_stream new file mode 100644 index 0000000..aa9f2d6 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/cache-control.event_stream @@ -0,0 +1,2 @@ +data: {{headers[cache-control]}} + diff --git a/test/fixtures/wpt/eventsource/resources/cors-cookie.py b/test/fixtures/wpt/eventsource/resources/cors-cookie.py new file mode 100644 index 0000000..9eaab9b --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/cors-cookie.py @@ -0,0 +1,31 @@ +from datetime import datetime + +def main(request, response): + last_event_id = request.headers.get(b"Last-Event-Id", b"") + ident = request.GET.first(b'ident', b"test") + cookie = b"COOKIE" if ident in request.cookies else b"NO_COOKIE" + origin = request.GET.first(b'origin', request.headers[b"origin"]) + credentials = request.GET.first(b'credentials', b'true') + + headers = [] + + if origin != b'none': + headers.append((b"Access-Control-Allow-Origin", origin)); + + if credentials != b'none': + headers.append((b"Access-Control-Allow-Credentials", credentials)); + + if last_event_id == b'': + headers.append((b"Content-Type", b"text/event-stream")) + response.set_cookie(ident, b"COOKIE") + data = b"id: 1\nretry: 200\ndata: first %s\n\n" % cookie + elif last_event_id == b'1': + headers.append((b"Content-Type", b"text/event-stream")) + long_long_time_ago = datetime.now().replace(year=2001, month=7, day=27) + response.set_cookie(ident, b"COOKIE", expires=long_long_time_ago) + data = b"id: 2\ndata: second %s\n\n" % cookie + else: + headers.append((b"Content-Type", b"stop")) + data = b"data: " + last_event_id + cookie + b"\n\n"; + + return headers, data diff --git a/test/fixtures/wpt/eventsource/resources/cors.py b/test/fixtures/wpt/eventsource/resources/cors.py new file mode 100644 index 0000000..6ed31f2 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/cors.py @@ -0,0 +1,36 @@ +import os +from wptserve import pipes + +from wptserve.utils import isomorphic_decode + +def run_other(request, response, path): + #This is a terrible hack + environ = {u"__file__": path} + exec(compile(open(path, u"r").read(), path, u'exec'), environ, environ) + rv = environ[u"main"](request, response) + return rv + +def main(request, response): + origin = request.GET.first(b"origin", request.headers[b"origin"]) + credentials = request.GET.first(b"credentials", b"true") + + response.headers.update([(b"Access-Control-Allow-Origin", origin), + (b"Access-Control-Allow-Credentials", credentials)]) + + handler = request.GET.first(b'run') + if handler in [b"status-reconnect", + b"message", + b"redirect", + b"cache-control"]: + if handler == b"cache-control": + response.headers.set(b"Content-Type", b"text/event-stream") + rv = open(os.path.join(request.doc_root, u"eventsource", u"resources", u"cache-control.event_stream"), u"r").read() + response.content = rv + pipes.sub(request, response) + return + elif handler == b"redirect": + return run_other(request, response, os.path.join(request.doc_root, u"common", u"redirect.py")) + else: + return run_other(request, response, os.path.join(os.path.dirname(isomorphic_decode(__file__)), isomorphic_decode(handler) + u".py")) + else: + return diff --git a/test/fixtures/wpt/eventsource/resources/eventsource-onmessage-realm.htm b/test/fixtures/wpt/eventsource/resources/eventsource-onmessage-realm.htm new file mode 100644 index 0000000..63e6d01 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/eventsource-onmessage-realm.htm @@ -0,0 +1,2 @@ + +This page is just used to grab an EventSource constructor diff --git a/test/fixtures/wpt/eventsource/resources/init.htm b/test/fixtures/wpt/eventsource/resources/init.htm new file mode 100644 index 0000000..7c56d88 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/init.htm @@ -0,0 +1,9 @@ + + + + support init file + + + + + diff --git a/test/fixtures/wpt/eventsource/resources/last-event-id.py b/test/fixtures/wpt/eventsource/resources/last-event-id.py new file mode 100644 index 0000000..a2cb726 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/last-event-id.py @@ -0,0 +1,9 @@ +def main(request, response): + response.headers.set(b"Content-Type", b"text/event-stream") + + last_event_id = request.headers.get(b"Last-Event-ID", b"") + if last_event_id: + return b"data: " + last_event_id + b"\n\n" + else: + idvalue = request.GET.first(b"idvalue", u"\u2026".encode("utf-8")) + return b"id: " + idvalue + b"\nretry: 200\ndata: hello\n\n" diff --git a/test/fixtures/wpt/eventsource/resources/last-event-id2.py b/test/fixtures/wpt/eventsource/resources/last-event-id2.py new file mode 100644 index 0000000..4f133d7 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/last-event-id2.py @@ -0,0 +1,23 @@ +ID_PERSISTS = 1 +ID_RESETS_1 = 2 +ID_RESETS_2 = 3 + +def main(request, response): + response.headers.set(b"Content-Type", b"text/event-stream") + try: + test_type = int(request.GET.first(b"type", ID_PERSISTS)) + except: + test_type = ID_PERSISTS + + if test_type == ID_PERSISTS: + return b"id: 1\ndata: 1\n\ndata: 2\n\nid: 2\ndata:3\n\ndata:4\n\n" + + elif test_type == ID_RESETS_1: + return b"id: 1\ndata: 1\n\nid:\ndata:2\n\ndata:3\n\n" + + # empty id field without colon character (:) should also reset + elif test_type == ID_RESETS_2: + return b"id: 1\ndata: 1\n\nid\ndata:2\n\ndata:3\n\n" + + else: + return b"data: invalid_test\n\n" diff --git a/test/fixtures/wpt/eventsource/resources/message.py b/test/fixtures/wpt/eventsource/resources/message.py new file mode 100644 index 0000000..468564f --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/message.py @@ -0,0 +1,14 @@ +import time + +def main(request, response): + mime = request.GET.first(b"mime", b"text/event-stream") + message = request.GET.first(b"message", b"data: data"); + newline = b"" if request.GET.first(b"newline", None) == b"none" else b"\n\n"; + sleep = int(request.GET.first(b"sleep", b"0")) + + headers = [(b"Content-Type", mime)] + body = message + newline + b"\n" + if sleep != 0: + time.sleep(sleep/1000) + + return headers, body diff --git a/test/fixtures/wpt/eventsource/resources/message2.py b/test/fixtures/wpt/eventsource/resources/message2.py new file mode 100644 index 0000000..8515e7b --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/message2.py @@ -0,0 +1,33 @@ +import time + +def main(request, response): + response.headers.set(b'Content-Type', b'text/event-stream') + response.headers.set(b'Cache-Control', b'no-cache') + + response.write_status_headers() + + while True: + response.writer.write(u"data:msg") + response.writer.write(u"\n") + response.writer.write(u"data: msg") + response.writer.write(u"\n\n") + + response.writer.write(u":") + response.writer.write(u"\n") + + response.writer.write(u"falsefield:msg") + response.writer.write(u"\n\n") + + response.writer.write(u"falsefield:msg") + response.writer.write(u"\n") + + response.writer.write(u"Data:data") + response.writer.write(u"\n\n") + + response.writer.write(u"data") + response.writer.write(u"\n\n") + + response.writer.write(u"data:end") + response.writer.write(u"\n\n") + + time.sleep(2) diff --git a/test/fixtures/wpt/eventsource/resources/reconnect-fail.py b/test/fixtures/wpt/eventsource/resources/reconnect-fail.py new file mode 100644 index 0000000..12b0770 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/reconnect-fail.py @@ -0,0 +1,24 @@ +def main(request, response): + name = b"recon_fail_" + request.GET.first(b"id") + + headers = [(b"Content-Type", b"text/event-stream")] + cookie = request.cookies.first(name, None) + state = cookie.value if cookie is not None else None + + if state == b'opened': + status = (200, b"RECONNECT") + response.set_cookie(name, b"reconnected"); + body = b"data: reconnected\n\n"; + + elif state == b'reconnected': + status = (204, b"NO CONTENT (CLOSE)") + response.delete_cookie(name); + body = b"data: closed\n\n" # Will never get through + + else: + status = (200, b"OPEN"); + response.set_cookie(name, b"opened"); + body = b"retry: 2\ndata: opened\n\n"; + + return status, headers, body + diff --git a/test/fixtures/wpt/eventsource/resources/status-error.py b/test/fixtures/wpt/eventsource/resources/status-error.py new file mode 100644 index 0000000..ed5687b --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/status-error.py @@ -0,0 +1,15 @@ +def main(request, response): + status = (request.GET.first(b"status", b"404"), b"HAHAHAHA") + headers = [(b"Content-Type", b"text/event-stream")] + + # According to RFC7231, HTTP responses bearing status code 204 or 205 must + # not specify a body. The expected browser behavior for this condition is not + # currently defined--see the following for further discussion: + # + # https://github.com/web-platform-tests/wpt/pull/5227 + if status[0] in [b"204", b"205"]: + body = b"" + else: + body = b"data: data\n\n" + + return status, headers, body diff --git a/test/fixtures/wpt/eventsource/resources/status-reconnect.py b/test/fixtures/wpt/eventsource/resources/status-reconnect.py new file mode 100644 index 0000000..a59f751 --- /dev/null +++ b/test/fixtures/wpt/eventsource/resources/status-reconnect.py @@ -0,0 +1,21 @@ +def main(request, response): + status_code = request.GET.first(b"status", b"204") + name = request.GET.first(b"id", status_code) + + headers = [(b"Content-Type", b"text/event-stream")] + + cookie_name = b"request" + name + + if request.cookies.first(cookie_name, b"") == status_code: + status = 200 + response.delete_cookie(cookie_name) + body = b"data: data\n\n" + else: + response.set_cookie(cookie_name, status_code); + status = (int(status_code), b"TEST") + body = b"retry: 2\n" + if b"ok_first" in request.GET: + body += b"data: ok\n\n" + + return status, headers, body + diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-close.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-close.htm new file mode 100644 index 0000000..30fbc30 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-close.htm @@ -0,0 +1,24 @@ + + + + shared worker - EventSource: close() + + + + +
+ + + \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-close.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-close.js new file mode 100644 index 0000000..8d160b6 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-close.js @@ -0,0 +1,12 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.onopen = function(e) { + this.close() + port.postMessage([true, this.readyState]) + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm new file mode 100644 index 0000000..690cde3 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.htm @@ -0,0 +1,34 @@ + + + + shared worker - EventSource: constructor (act as if there is a network error) + + + + + +
+ + + + diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.js new file mode 100644 index 0000000..a68dc5b --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-non-same-origin.js @@ -0,0 +1,13 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var url = decodeURIComponent(location.hash.substr(1)) + var source = new EventSource(url) + source.onerror = function(e) { + port.postMessage([true, this.readyState, 'data' in e]) + this.close(); + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-url-bogus.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-url-bogus.js new file mode 100644 index 0000000..8084735 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-constructor-url-bogus.js @@ -0,0 +1,10 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("http://this is invalid/") + port.postMessage([false, 'no exception thrown']) + source.close() +} catch(e) { + port.postMessage([true, e.code]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.htm new file mode 100644 index 0000000..f25509d --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.htm @@ -0,0 +1,24 @@ + + + + shared worker - EventSource: addEventListener() + + + + +
+ + + \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.js new file mode 100644 index 0000000..7611651 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-eventtarget.js @@ -0,0 +1,13 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.addEventListener("message", listener, false) + function listener(e) { + port.postMessage([true, e.data]) + this.close() + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-onmesage.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onmesage.js new file mode 100644 index 0000000..f5e2c89 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onmesage.js @@ -0,0 +1,12 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.onmessage = function(e) { + port.postMessage([true, e.data]) + this.close() + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-onmessage.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onmessage.htm new file mode 100644 index 0000000..bcd6093 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onmessage.htm @@ -0,0 +1,24 @@ + + + + shared worker - EventSource: onmessage + + + + +
+ + + \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.htm new file mode 100644 index 0000000..752a6e4 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.htm @@ -0,0 +1,27 @@ + + + + shared worker - EventSource: onopen (announcing the connection) + + + + +
+ + + \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.js new file mode 100644 index 0000000..6dc9424 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-onopen.js @@ -0,0 +1,12 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + source.onopen = function(e) { + port.postMessage([true, source.readyState, 'data' in e, e.bubbles, e.cancelable]) + this.close() + } +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.htm new file mode 100644 index 0000000..16c932a --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.htm @@ -0,0 +1,25 @@ + + + + shared worker - EventSource: prototype et al + + + + +
+ + + \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.js new file mode 100644 index 0000000..f4c809a --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-prototype.js @@ -0,0 +1,11 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + EventSource.prototype.ReturnTrue = function() { return true } + var source = new EventSource("../resources/message.py") + port.postMessage([true, source.ReturnTrue(), 'EventSource' in self]) + source.close() +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-url.htm b/test/fixtures/wpt/eventsource/shared-worker/eventsource-url.htm new file mode 100644 index 0000000..a1c9ca8 --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-url.htm @@ -0,0 +1,25 @@ + + + + shared worker - EventSource: url + + + + +
+ + + \ No newline at end of file diff --git a/test/fixtures/wpt/eventsource/shared-worker/eventsource-url.js b/test/fixtures/wpt/eventsource/shared-worker/eventsource-url.js new file mode 100644 index 0000000..491dbac --- /dev/null +++ b/test/fixtures/wpt/eventsource/shared-worker/eventsource-url.js @@ -0,0 +1,10 @@ +onconnect = function(e) { +try { + var port = e.ports[0] + var source = new EventSource("../resources/message.py") + port.postMessage([true, source.url]) + source.close() +} catch(e) { + port.postMessage([false, String(e)]) +} +} \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/META.yml b/test/fixtures/wpt/fetch/META.yml new file mode 100644 index 0000000..81432ff --- /dev/null +++ b/test/fixtures/wpt/fetch/META.yml @@ -0,0 +1,7 @@ +spec: https://fetch.spec.whatwg.org/ +suggested_reviewers: + - jdm + - youennf + - annevk + - mnot + - yutakahirano diff --git a/test/fixtures/wpt/fetch/README.md b/test/fixtures/wpt/fetch/README.md new file mode 100644 index 0000000..dcaad02 --- /dev/null +++ b/test/fixtures/wpt/fetch/README.md @@ -0,0 +1,6 @@ +Tests for the [Fetch Standard](https://fetch.spec.whatwg.org/). + +More Fetch tests can be found in + +* /cors +* /xhr diff --git a/test/fixtures/wpt/fetch/api/abort/cache.https.any.js b/test/fixtures/wpt/fetch/api/abort/cache.https.any.js new file mode 100644 index 0000000..bdaf0e6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/abort/cache.https.any.js @@ -0,0 +1,47 @@ +// META: title=Request signals & the cache API +// META: global=window,worker + +promise_test(async () => { + await caches.delete('test'); + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request('../resources/data.json', { signal }); + + const cache = await caches.open('test'); + await cache.put(request, new Response('')); + + const requests = await cache.keys(); + + assert_equals(requests.length, 1, 'Ensuring cleanup worked'); + + const [cachedRequest] = requests; + + controller.abort(); + + assert_false(cachedRequest.signal.aborted, "Request from cache shouldn't be aborted"); + + const data = await fetch(cachedRequest).then(r => r.json()); + assert_equals(data.key, 'value', 'Fetch fully completes'); +}, "Signals are not stored in the cache API"); + +promise_test(async () => { + await caches.delete('test'); + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request('../resources/data.json', { signal }); + controller.abort(); + + const cache = await caches.open('test'); + await cache.put(request, new Response('')); + + const requests = await cache.keys(); + + assert_equals(requests.length, 1, 'Ensuring cleanup worked'); + + const [cachedRequest] = requests; + + assert_false(cachedRequest.signal.aborted, "Request from cache shouldn't be aborted"); + + const data = await fetch(cachedRequest).then(r => r.json()); + assert_equals(data.key, 'value', 'Fetch fully completes'); +}, "Signals are not stored in the cache API, even if they're already aborted"); diff --git a/test/fixtures/wpt/fetch/api/abort/destroyed-context.html b/test/fixtures/wpt/fetch/api/abort/destroyed-context.html new file mode 100644 index 0000000..161d39b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/abort/destroyed-context.html @@ -0,0 +1,27 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/api/abort/general.any.js b/test/fixtures/wpt/fetch/api/abort/general.any.js new file mode 100644 index 0000000..139f089 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/abort/general.any.js @@ -0,0 +1,572 @@ +// META: timeout=long +// META: global=window,worker +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../request/request-error.js + +const BODY_METHODS = ['arrayBuffer', 'blob', 'bytes', 'formData', 'json', 'text']; + +const error1 = new Error('error1'); +error1.name = 'error1'; + +// This is used to close connections that weren't correctly closed during the tests, +// otherwise you can end up running out of HTTP connections. +let requestAbortKeys = []; + +function abortRequests() { + const keys = requestAbortKeys; + requestAbortKeys = []; + return Promise.all( + keys.map(key => fetch(`../resources/stash-put.py?key=${key}&value=close`)) + ); +} + +const hostInfo = get_host_info(); +const urlHostname = hostInfo.REMOTE_HOST; + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const fetchPromise = fetch('../resources/data.json', { signal }); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Aborting rejects with AbortError"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(error1); + + const fetchPromise = fetch('../resources/data.json', { signal }); + + await promise_rejects_exactly(t, error1, fetchPromise, 'fetch() should reject with abort reason'); +}, "Aborting rejects with abort reason"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const url = new URL('../resources/data.json', location); + url.hostname = urlHostname; + + const fetchPromise = fetch(url, { + signal, + mode: 'no-cors' + }); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Aborting rejects with AbortError - no-cors"); + +// Test that errors thrown from the request constructor take priority over abort errors. +// badRequestArgTests is from response-error.js +for (const { args, testName } of badRequestArgTests) { + promise_test(async t => { + try { + // If this doesn't throw, we'll effectively skip the test. + // It'll fail properly in ../request/request-error.html + new Request(...args); + } + catch (err) { + const controller = new AbortController(); + controller.abort(); + + // Add signal to 2nd arg + args[1] = args[1] || {}; + args[1].signal = controller.signal; + await promise_rejects_js(t, TypeError, fetch(...args)); + } + }, `TypeError from request constructor takes priority - ${testName}`); +} + +test(() => { + const request = new Request(''); + assert_true(Boolean(request.signal), "Signal member is present & truthy"); + assert_equals(request.signal.constructor, AbortSignal); +}, "Request objects have a signal property"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + assert_true(Boolean(request.signal), "Signal member is present & truthy"); + assert_equals(request.signal.constructor, AbortSignal); + assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference'); + assert_true(request.signal.aborted, `Request's signal has aborted`); + + const fetchPromise = fetch(request); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(error1); + + const request = new Request('../resources/data.json', { signal }); + + assert_not_equals(request.signal, signal, 'Request has a new signal, not a reference'); + assert_true(request.signal.aborted, `Request's signal has aborted`); + assert_equals(request.signal.reason, error1, `Request's signal's abort reason is error1`); + + const fetchPromise = fetch(request); + + await promise_rejects_exactly(t, error1, fetchPromise, "fetch() should reject with abort reason"); +}, "Signal on request object should also have abort reason"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + const requestFromRequest = new Request(request); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json'); + const requestFromRequest = new Request(request, { signal }); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object, with signal on second request"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal: new AbortController().signal }); + const requestFromRequest = new Request(request, { signal }); + + const fetchPromise = fetch(requestFromRequest); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal on request object created from request object, with signal on second request overriding another"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + const fetchPromise = fetch(request, {method: 'POST'}); + + await promise_rejects_dom(t, "AbortError", fetchPromise); +}, "Signal retained after unrelated properties are overridden by fetch"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { signal }); + + const data = await fetch(request, { signal: null }).then(r => r.json()); + assert_equals(data.key, 'value', 'Fetch fully completes'); +}, "Signal removed by setting to null"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const log = []; + + await Promise.all([ + fetch('../resources/data.json', { signal }).then( + () => assert_unreached("Fetch must not resolve"), + () => log.push('fetch-reject') + ), + Promise.resolve().then(() => log.push('next-microtask')) + ]); + + assert_array_equals(log, ['fetch-reject', 'next-microtask']); +}, "Already aborted signal rejects immediately"); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('../resources/data.json', { + signal, + method: 'POST', + body: 'foo', + headers: { 'Content-Type': 'text/plain' } + }); + + await fetch(request).catch(() => {}); + + assert_true(request.bodyUsed, "Body has been used"); +}, "Request is still 'used' if signal is aborted before fetching"); + +for (const bodyMethod of BODY_METHODS) { + promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + + const log = []; + const response = await fetch('../resources/data.json', { signal }); + + controller.abort(); + + const bodyPromise = response[bodyMethod](); + + await Promise.all([ + bodyPromise.catch(() => log.push(`${bodyMethod}-reject`)), + Promise.resolve().then(() => log.push('next-microtask')) + ]); + + await promise_rejects_dom(t, "AbortError", bodyPromise); + + assert_array_equals(log, [`${bodyMethod}-reject`, 'next-microtask']); + }, `response.${bodyMethod}() rejects if already aborted`); +} + +promise_test(async (t) => { + const controller = new AbortController(); + const signal = controller.signal; + + const res = await fetch('../resources/data.json', { signal }); + controller.abort(); + + await promise_rejects_dom(t, 'AbortError', res.text()); + await promise_rejects_dom(t, 'AbortError', res.text()); +}, 'Call text() twice on aborted response'); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + controller.abort(); + + await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }).catch(() => {}); + + // I'm hoping this will give the browser enough time to (incorrectly) make the request + // above, if it intends to. + await fetch('../resources/data.json').then(r => r.json()); + + const response = await fetch(`../resources/stash-take.py?key=${stateKey}`); + const data = await response.json(); + + assert_equals(data, null, "Request hasn't been made to the server"); +}, "Already aborted signal does not make request"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const fetches = []; + + for (let i = 0; i < 3; i++) { + const abortKey = token(); + requestAbortKeys.push(abortKey); + + fetches.push( + fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal }) + ); + } + + for (const fetchPromise of fetches) { + await promise_rejects_dom(t, "AbortError", fetchPromise); + } +}, "Already aborted signal can be used for many fetches"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + + await fetch('../resources/data.json', { signal }).then(r => r.json()); + + controller.abort(); + + const fetches = []; + + for (let i = 0; i < 3; i++) { + const abortKey = token(); + requestAbortKeys.push(abortKey); + + fetches.push( + fetch(`../resources/infinite-slow-response.py?${i}&abortKey=${abortKey}`, { signal }) + ); + } + + for (const fetchPromise of fetches) { + await promise_rejects_dom(t, "AbortError", fetchPromise); + } +}, "Signal can be used to abort other fetches, even if another fetch succeeded before aborting"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + + const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + controller.abort(); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Underlying connection is closed when aborting after receiving response"); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const url = new URL(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, location); + url.hostname = urlHostname; + + await fetch(url, { + signal, + mode: 'no-cors' + }); + + const stashTakeURL = new URL(`../resources/stash-take.py?key=${stateKey}`, location); + stashTakeURL.hostname = urlHostname; + + const beforeAbortResult = await fetch(stashTakeURL).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + controller.abort(); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(stashTakeURL).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Underlying connection is closed when aborting after receiving response - no-cors"); + +for (const bodyMethod of BODY_METHODS) { + promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + + const beforeAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + assert_equals(beforeAbortResult, "open", "Connection is open"); + + const bodyPromise = response[bodyMethod](); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", bodyPromise); + + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } + }, `Fetch aborted & connection closed when aborted after calling response.${bodyMethod}()`); +} + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + const reader = response.body.getReader(); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", reader.read()); + await promise_rejects_dom(t, "AbortError", reader.closed); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Stream errors once aborted. Underlying connection closed."); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + const stateKey = token(); + const abortKey = token(); + requestAbortKeys.push(abortKey); + + const response = await fetch(`../resources/infinite-slow-response.py?stateKey=${stateKey}&abortKey=${abortKey}`, { signal }); + const reader = response.body.getReader(); + + await reader.read(); + + controller.abort(); + + await promise_rejects_dom(t, "AbortError", reader.read()); + await promise_rejects_dom(t, "AbortError", reader.closed); + + // The connection won't close immediately, but it should close at some point: + const start = Date.now(); + + while (true) { + // Stop spinning if 10 seconds have passed + if (Date.now() - start > 10000) throw Error('Timed out'); + + const afterAbortResult = await fetch(`../resources/stash-take.py?key=${stateKey}`).then(r => r.json()); + if (afterAbortResult == 'closed') break; + } +}, "Stream errors once aborted, after reading. Underlying connection closed."); + +promise_test(async t => { + await abortRequests(); + + const controller = new AbortController(); + const signal = controller.signal; + + const response = await fetch(`../resources/empty.txt`, { signal }); + + // Read whole response to ensure close signal has sent. + await response.clone().text(); + + const reader = response.body.getReader(); + + controller.abort(); + + const item = await reader.read(); + + assert_true(item.done, "Stream is done"); +}, "Stream will not error if body is empty. It's closed with an empty queue before it errors."); + +promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + let cancelReason; + + const body = new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([42])); + }, + cancel(reason) { + cancelReason = reason; + } + }); + + const fetchPromise = fetch('../resources/empty.txt', { + body, signal, + method: 'POST', + duplex: 'half', + headers: { + 'Content-Type': 'text/plain' + } + }); + + assert_true(!!cancelReason, 'Cancel called sync'); + assert_equals(cancelReason.constructor, DOMException); + assert_equals(cancelReason.name, 'AbortError'); + + await promise_rejects_dom(t, "AbortError", fetchPromise); + + const fetchErr = await fetchPromise.catch(e => e); + + assert_equals(cancelReason, fetchErr, "Fetch rejects with same error instance"); +}, "Readable stream synchronously cancels with AbortError if aborted before reading"); + +test(() => { + const controller = new AbortController(); + const signal = controller.signal; + controller.abort(); + + const request = new Request('.', { signal }); + const requestSignal = request.signal; + + const clonedRequest = request.clone(); + + assert_equals(requestSignal, request.signal, "Original request signal the same after cloning"); + assert_true(request.signal.aborted, "Original request signal aborted"); + assert_not_equals(clonedRequest.signal, request.signal, "Cloned request has different signal"); + assert_true(clonedRequest.signal.aborted, "Cloned request signal aborted"); +}, "Signal state is cloned"); + +test(() => { + const controller = new AbortController(); + const signal = controller.signal; + + const request = new Request('.', { signal }); + const clonedRequest = request.clone(); + + const log = []; + + request.signal.addEventListener('abort', () => log.push('original-aborted')); + clonedRequest.signal.addEventListener('abort', () => log.push('clone-aborted')); + + controller.abort(); + + assert_array_equals(log, ['original-aborted', 'clone-aborted'], "Abort events fired in correct order"); + assert_true(request.signal.aborted, 'Signal aborted'); + assert_true(clonedRequest.signal.aborted, 'Signal aborted'); +}, "Clone aborts with original controller"); diff --git a/test/fixtures/wpt/fetch/api/abort/keepalive.html b/test/fixtures/wpt/fetch/api/abort/keepalive.html new file mode 100644 index 0000000..db12df0 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/abort/keepalive.html @@ -0,0 +1,85 @@ + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/abort/request.any.js b/test/fixtures/wpt/fetch/api/abort/request.any.js new file mode 100644 index 0000000..dcc7803 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/abort/request.any.js @@ -0,0 +1,85 @@ +// META: timeout=long +// META: global=window,worker + +const BODY_FUNCTION_AND_DATA = { + arrayBuffer: null, + blob: null, + formData: new FormData(), + json: new Blob(["{}"]), + text: null, +}; + +for (const [bodyFunction, body] of Object.entries(BODY_FUNCTION_AND_DATA)) { + promise_test(async () => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body, + }); + + controller.abort(); + await request[bodyFunction](); + assert_true( + true, + `An aborted request should still be able to run ${bodyFunction}()` + ); + }, `Calling ${bodyFunction}() on an aborted request`); + + promise_test(async () => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body, + }); + + const p = request[bodyFunction](); + controller.abort(); + await p; + assert_true( + true, + `An aborted request should still be able to run ${bodyFunction}()` + ); + }, `Aborting a request after calling ${bodyFunction}()`); + + if (!body) { + promise_test(async () => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body, + }); + + // consuming happens synchronously, so don't wait + fetch(request).catch(() => {}); + + controller.abort(); + await request[bodyFunction](); + assert_true( + true, + `An aborted consumed request should still be able to run ${bodyFunction}() when empty` + ); + }, `Calling ${bodyFunction}() on an aborted consumed empty request`); + } + + promise_test(async t => { + const controller = new AbortController(); + const signal = controller.signal; + const request = new Request("../resources/data.json", { + method: "post", + signal, + body: body || new Blob(["foo"]), + }); + + // consuming happens synchronously, so don't wait + fetch(request).catch(() => {}); + + controller.abort(); + await promise_rejects_js(t, TypeError, request[bodyFunction]()); + }, `Calling ${bodyFunction}() on an aborted consumed nonempty request`); +} diff --git a/test/fixtures/wpt/fetch/api/abort/serviceworker-intercepted.https.html b/test/fixtures/wpt/fetch/api/abort/serviceworker-intercepted.https.html new file mode 100644 index 0000000..1867e20 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/abort/serviceworker-intercepted.https.html @@ -0,0 +1,212 @@ + + + + + Aborting fetch when intercepted by a service worker + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/basic/accept-header.any.js b/test/fixtures/wpt/fetch/api/basic/accept-header.any.js new file mode 100644 index 0000000..cd54cf2 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/accept-header.any.js @@ -0,0 +1,34 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept").then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_equals(response.headers.get("x-request-accept"), "*/*", "Request has accept header with value '*/*'"); + }); +}, "Request through fetch should have 'accept' header with value '*/*'"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept", {"headers": [["Accept", "custom/*"]]}).then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_equals(response.headers.get("x-request-accept"), "custom/*", "Request has accept header with value 'custom/*'"); + }); +}, "Request through fetch should have 'accept' header with value 'custom/*'"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept-Language").then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_true(response.headers.has("x-request-accept-language")); + }); +}, "Request through fetch should have a 'accept-language' header"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=Accept-Language", {"headers": [["Accept-Language", "bzh"]]}).then(function(response) { + assert_equals(response.status, 200, "HTTP status is 200"); + assert_equals(response.type , "basic", "Response's type is basic"); + assert_equals(response.headers.get("x-request-accept-language"), "bzh", "Request has accept header with value 'bzh'"); + }); +}, "Request through fetch should have 'accept-language' header with value 'bzh'"); diff --git a/test/fixtures/wpt/fetch/api/basic/block-mime-as-script.html b/test/fixtures/wpt/fetch/api/basic/block-mime-as-script.html new file mode 100644 index 0000000..afc2bbb --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/block-mime-as-script.html @@ -0,0 +1,43 @@ + + +Block mime type as script + + +
+ diff --git a/test/fixtures/wpt/fetch/api/basic/conditional-get.any.js b/test/fixtures/wpt/fetch/api/basic/conditional-get.any.js new file mode 100644 index 0000000..2f9fa81 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/conditional-get.any.js @@ -0,0 +1,38 @@ +// META: title=Request ETag +// META: global=window,worker +// META: script=/common/utils.js + +promise_test(function() { + var cacheBuster = token(); // ensures first request is uncached + var url = "../resources/cache.py?v=" + cacheBuster; + var etag; + + // make the first request + return fetch(url).then(function(response) { + // ensure we're getting the regular, uncached response + assert_equals(response.status, 200); + assert_equals(response.headers.get("X-HTTP-STATUS"), null) + + return response.text(); // consuming the body, just to be safe + }).then(function(body) { + // make a second request + return fetch(url); + }).then(function(response) { + // while the server responds with 304 if our browser sent the correct + // If-None-Match request header, at the JavaScript level this surfaces + // as 200 + assert_equals(response.status, 200); + assert_equals(response.headers.get("X-HTTP-STATUS"), "304") + + etag = response.headers.get("ETag") + + return response.text(); // consuming the body, just to be safe + }).then(function(body) { + // make a third request, explicitly setting If-None-Match request header + var headers = { "If-None-Match": etag } + return fetch(url, { headers: headers }) + }).then(function(response) { + // 304 now surfaces thanks to the explicit If-None-Match request header + assert_equals(response.status, 304); + }); +}, "Testing conditional GET with ETags"); diff --git a/test/fixtures/wpt/fetch/api/basic/error-after-response.any.js b/test/fixtures/wpt/fetch/api/basic/error-after-response.any.js new file mode 100644 index 0000000..f711442 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/error-after-response.any.js @@ -0,0 +1,24 @@ +// META: title=Fetch: network timeout after receiving the HTTP response headers +// META: global=window,worker +// META: timeout=long +// META: script=../resources/utils.js + +function checkReader(test, reader, promiseToTest) +{ + return reader.read().then((value) => { + validateBufferFromString(value.value, "TEST_CHUNK", "Should receive first chunk"); + return promise_rejects_js(test, TypeError, promiseToTest(reader)); + }); +} + +promise_test((test) => { + return fetch("../resources/bad-chunk-encoding.py?count=1").then((response) => { + return checkReader(test, response.body.getReader(), reader => reader.read()); + }); +}, "Response reader read() promise should reject after a network error happening after resolving fetch promise"); + +promise_test((test) => { + return fetch("../resources/bad-chunk-encoding.py?count=1").then((response) => { + return checkReader(test, response.body.getReader(), reader => reader.closed); + }); +}, "Response reader closed promise should reject after a network error happening after resolving fetch promise"); diff --git a/test/fixtures/wpt/fetch/api/basic/header-value-combining.any.js b/test/fixtures/wpt/fetch/api/basic/header-value-combining.any.js new file mode 100644 index 0000000..bb70d87 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/header-value-combining.any.js @@ -0,0 +1,15 @@ +// META: global=window,worker + +[ + ["content-length", "0", "header-content-length"], + ["content-length", "0, 0", "header-content-length-twice"], + ["double-trouble", ", ", "headers-double-empty"], + ["foo-test", "1, 2, 3", "headers-basic"], + ["heya", ", \u000B\u000C, 1, , , 2", "headers-some-are-empty"], + ["www-authenticate", "1, 2, 3, 4", "headers-www-authenticate"], +].forEach(testValues => { + promise_test(async t => { + const response = await fetch("../../../xhr/resources/" + testValues[2] + ".asis"); + assert_equals(response.headers.get(testValues[0]), testValues[1]); + }, "response.headers.get('" + testValues[0] + "') expects " + testValues[1]); +}); diff --git a/test/fixtures/wpt/fetch/api/basic/header-value-null-byte.any.js b/test/fixtures/wpt/fetch/api/basic/header-value-null-byte.any.js new file mode 100644 index 0000000..741d83b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/header-value-null-byte.any.js @@ -0,0 +1,5 @@ +// META: global=window,worker + +promise_test(t => { + return promise_rejects_js(t, TypeError, fetch("../../../xhr/resources/parse-headers.py?my-custom-header="+encodeURIComponent("x\0x"))); +}, "Ensure fetch() rejects null bytes in headers"); diff --git a/test/fixtures/wpt/fetch/api/basic/historical.any.js b/test/fixtures/wpt/fetch/api/basic/historical.any.js new file mode 100644 index 0000000..c808126 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/historical.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker + +test(() => { + assert_false("getAll" in new Headers()); + assert_false("getAll" in Headers.prototype); +}, "Headers object no longer has a getAll() method"); + +test(() => { + assert_false("type" in new Request("about:blank")); + assert_false("type" in Request.prototype); +}, "'type' getter should not exist on Request objects"); + +// See https://github.com/whatwg/fetch/pull/979 for the removal +test(() => { + assert_false("trailer" in new Response()); + assert_false("trailer" in Response.prototype); +}, "Response object no longer has a trailer getter"); diff --git a/test/fixtures/wpt/fetch/api/basic/http-response-code.any.js b/test/fixtures/wpt/fetch/api/basic/http-response-code.any.js new file mode 100644 index 0000000..1fd312a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/http-response-code.any.js @@ -0,0 +1,14 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +promise_test(async (test) => { + const resp = await fetch( + "/fetch/connection-pool/resources/network-partition-key.py?" + + `status=425&uuid=${token()}&partition_id=${get_host_info().ORIGIN}` + + `&dispatch=check_partition&addcounter=true`); + assert_equals(resp.status, 425); + const text = await resp.text(); + assert_equals(text, "ok. Request was sent 1 times. 1 connections were created."); +}, "Fetch on 425 response should not be retried for non TLS early data."); diff --git a/test/fixtures/wpt/fetch/api/basic/integrity.sub.any.js b/test/fixtures/wpt/fetch/api/basic/integrity.sub.any.js new file mode 100644 index 0000000..e3cfd1b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/integrity.sub.any.js @@ -0,0 +1,87 @@ +// META: global=window,dedicatedworker,sharedworker +// META: script=../resources/utils.js + +function integrity(desc, url, integrity, initRequestMode, shouldPass) { + var fetchRequestInit = {'integrity': integrity} + if (!!initRequestMode && initRequestMode !== "") { + fetchRequestInit.mode = initRequestMode; + } + + if (shouldPass) { + promise_test(function(test) { + return fetch(url, fetchRequestInit).then(function(resp) { + if (initRequestMode !== "no-cors") { + assert_equals(resp.status, 200, "Response's status is 200"); + } else { + assert_equals(resp.status, 0, "Opaque response's status is 0"); + assert_equals(resp.type, "opaque"); + } + }); + }, desc); + } else { + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url, fetchRequestInit)); + }, desc); + } +} + +const topSha256 = "sha256-KHIDZcXnR2oBHk9DrAA+5fFiR6JjudYjqoXtMR1zvzk="; +const topSha384 = "sha384-MgZYnnAzPM/MjhqfOIMfQK5qcFvGZsGLzx4Phd7/A8fHTqqLqXqKo8cNzY3xEPTL"; +const topSha512 = "sha512-D6yns0qxG0E7+TwkevZ4Jt5t7Iy3ugmAajG/dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg=="; +const topSha512wrongpadding = "sha512-D6yns0qxG0E7+TwkevZ4Jt5t7Iy3ugmAajG/dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg"; +const topSha512base64url = "sha512-D6yns0qxG0E7-TwkevZ4Jt5t7Iy3ugmAajG_dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg=="; +const topSha512base64url_nopadding = "sha512-D6yns0qxG0E7-TwkevZ4Jt5t7Iy3ugmAajG_dlf6Pado1JqTyneKXICDiqFIkLMRExgtvg8PlxbKTkYfRejSOg"; +const invalidSha256 = "sha256-dKUcPOn/AlUjWIwcHeHNqYXPlvyGiq+2dWOdFcE+24I="; +const invalidSha512 = "sha512-oUceBRNxPxnY60g/VtPCj2syT4wo4EZh2CgYdWy9veW8+OsReTXoh7dizMGZafvx9+QhMS39L/gIkxnPIn41Zg=="; + +const path = dirname(location.pathname) + RESOURCES_DIR + "top.txt"; +const url = path; +const corsUrl = + `http://{{host}}:{{ports[http][1]}}${path}?pipe=header(Access-Control-Allow-Origin,*)`; +const corsUrl2 = `https://{{host}}:{{ports[https][0]}}${path}` + +integrity("Empty string integrity", url, "", /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-256 integrity", url, topSha256, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-384 integrity", url, topSha384, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-512 integrity", url, topSha512, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("SHA-512 integrity with missing padding", url, topSha512wrongpadding, + /* initRequestMode */ undefined, /* shouldPass */ true); +integrity("SHA-512 integrity base64url encoded", url, topSha512base64url, + /* initRequestMode */ undefined, /* shouldPass */ true); +integrity("SHA-512 integrity base64url encoded with missing padding", url, + topSha512base64url_nopadding, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Invalid integrity", url, invalidSha256, + /* initRequestMode */ undefined, /* shouldPass */ false); +integrity("Multiple integrities: valid stronger than invalid", url, + invalidSha256 + " " + topSha384, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Multiple integrities: invalid stronger than valid", + url, invalidSha512 + " " + topSha384, /* initRequestMode */ undefined, + /* shouldPass */ false); +integrity("Multiple integrities: invalid as strong as valid", url, + invalidSha512 + " " + topSha512, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Multiple integrities: both are valid", url, + topSha384 + " " + topSha512, /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("Multiple integrities: both are invalid", url, + invalidSha256 + " " + invalidSha512, /* initRequestMode */ undefined, + /* shouldPass */ false); +integrity("CORS empty integrity", corsUrl, "", /* initRequestMode */ undefined, + /* shouldPass */ true); +integrity("CORS SHA-512 integrity", corsUrl, topSha512, + /* initRequestMode */ undefined, /* shouldPass */ true); +integrity("CORS invalid integrity", corsUrl, invalidSha512, + /* initRequestMode */ undefined, /* shouldPass */ false); + +integrity("Empty string integrity for opaque response", corsUrl2, "", + /* initRequestMode */ "no-cors", /* shouldPass */ true); +integrity("SHA-* integrity for opaque response", corsUrl2, topSha512, + /* initRequestMode */ "no-cors", /* shouldPass */ false); + +done(); diff --git a/test/fixtures/wpt/fetch/api/basic/keepalive.any.js b/test/fixtures/wpt/fetch/api/basic/keepalive.any.js new file mode 100644 index 0000000..d4e831b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/keepalive.any.js @@ -0,0 +1,77 @@ +// META: global=window +// META: timeout=long +// META: title=Fetch API: keepalive handling +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +} = get_host_info(); + +/** + * In a different-site iframe, test to fetch a keepalive URL on the specified + * document event. + */ +function keepaliveSimpleRequestTest(method) { + for (const evt of ['load', 'unload', 'pagehide']) { + const desc = + `[keepalive] simple ${method} request on '${evt}' [no payload]`; + promise_test(async (test) => { + const token1 = token(); + const iframe = document.createElement('iframe'); + iframe.src = getKeepAliveIframeUrl(token1, method, {sendOn: evt}); + document.body.appendChild(iframe); + await iframeLoaded(iframe); + if (evt != 'load') { + iframe.remove(); + } + + assertStashedTokenAsync(desc, token1); + }, `${desc}; setting up`); + } +} + +for (const method of ['GET', 'POST']) { + keepaliveSimpleRequestTest(method); +} + +// verifies fetch keepalive requests from a worker +function keepaliveSimpleWorkerTest() { + const desc = + `simple keepalive test for web workers`; + promise_test(async (test) => { + const TOKEN = token(); + const FRAME_ORIGIN = new URL(location.href).origin; + const TEST_URL = get_host_info().HTTP_ORIGIN + `/fetch/api/resources/stash-put.py?key=${TOKEN}&value=on` + + `&frame_origin=${FRAME_ORIGIN}`; + // start a worker which sends keepalive request and immediately terminates + const worker = new Worker(`/fetch/api/resources/keepalive-worker.js?param=${TEST_URL}`); + + const keepAliveWorkerPromise = new Promise((resolve, reject) => { + worker.onmessage = (event) => { + if (event.data === 'started') { + resolve(); + } else { + reject(new Error("Unexpected message received from worker")); + } + }; + worker.onerror = (error) => { + reject(error); + }; + }); + + // wait until the worker has been initialized (indicated by the "started" message) + await keepAliveWorkerPromise; + // verifies if the token sent in fetch request has been updated in the server + assertStashedTokenAsync(desc, TOKEN); + + }, `${desc};`); + +} + +keepaliveSimpleWorkerTest(); diff --git a/test/fixtures/wpt/fetch/api/basic/mediasource.window.js b/test/fixtures/wpt/fetch/api/basic/mediasource.window.js new file mode 100644 index 0000000..1f89595 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/mediasource.window.js @@ -0,0 +1,5 @@ +promise_test(t => { + const mediaSource = new MediaSource(), + mediaSourceURL = URL.createObjectURL(mediaSource); + return promise_rejects_js(t, TypeError, fetch(mediaSourceURL)); +}, "Cannot fetch blob: URL from a MediaSource"); diff --git a/test/fixtures/wpt/fetch/api/basic/mode-no-cors.sub.any.js b/test/fixtures/wpt/fetch/api/basic/mode-no-cors.sub.any.js new file mode 100644 index 0000000..a4abcac --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/mode-no-cors.sub.any.js @@ -0,0 +1,29 @@ +// META: script=../resources/utils.js + +function fetchNoCors(url, isOpaqueFiltered) { + var urlQuery = "?pipe=header(x-is-filtered,value)" + promise_test(function(test) { + if (isOpaqueFiltered) + return fetch(url + urlQuery, {"mode": "no-cors"}).then(function(resp) { + assert_equals(resp.status, 0, "Opaque filter: status is 0"); + assert_equals(resp.statusText, "", "Opaque filter: statusText is \"\""); + assert_equals(resp.url, "", "Opaque filter: url is \"\""); + assert_equals(resp.type , "opaque", "Opaque filter: response's type is opaque"); + assert_equals(resp.headers.get("x-is-filtered"), null, "Header x-is-filtered is filtered"); + }); + else + return fetch(url + urlQuery, {"mode": "no-cors"}).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-is-filtered"), "value", "Header x-is-filtered is not filtered"); + }); + }, "Fetch "+ url + " with no-cors mode"); +} + +fetchNoCors(RESOURCES_DIR + "top.txt", false); +fetchNoCors("http://{{host}}:{{ports[http][0]}}/fetch/api/resources/top.txt", false); +fetchNoCors("https://{{host}}:{{ports[https][0]}}/fetch/api/resources/top.txt", true); +fetchNoCors("http://{{host}}:{{ports[http][1]}}/fetch/api/resources/top.txt", true); + +done(); + diff --git a/test/fixtures/wpt/fetch/api/basic/mode-same-origin.any.js b/test/fixtures/wpt/fetch/api/basic/mode-same-origin.any.js new file mode 100644 index 0000000..1457702 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/mode-same-origin.any.js @@ -0,0 +1,28 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function fetchSameOrigin(url, shouldPass) { + promise_test(function(test) { + if (shouldPass) + return fetch(url , {"mode": "same-origin"}).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + }); + else + return promise_rejects_js(test, TypeError, fetch(url, {mode: "same-origin"})); + }, "Fetch "+ url + " with same-origin mode"); +} + +var host_info = get_host_info(); + +fetchSameOrigin(RESOURCES_DIR + "top.txt", true); +fetchSameOrigin(host_info.HTTP_ORIGIN + "/fetch/api/resources/top.txt", true); +fetchSameOrigin(host_info.HTTPS_ORIGIN + "/fetch/api/resources/top.txt", false); +fetchSameOrigin(host_info.HTTP_REMOTE_ORIGIN + "/fetch/api/resources/top.txt", false); + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py?location="; + +fetchSameOrigin(redirPath + RESOURCES_DIR + "top.txt", true); +fetchSameOrigin(redirPath + host_info.HTTP_ORIGIN + "/fetch/api/resources/top.txt", true); +fetchSameOrigin(redirPath + host_info.HTTPS_ORIGIN + "/fetch/api/resources/top.txt", false); +fetchSameOrigin(redirPath + host_info.HTTP_REMOTE_ORIGIN + "/fetch/api/resources/top.txt", false); diff --git a/test/fixtures/wpt/fetch/api/basic/referrer.any.js b/test/fixtures/wpt/fetch/api/basic/referrer.any.js new file mode 100644 index 0000000..85745e6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/referrer.any.js @@ -0,0 +1,29 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function runTest(url, init, expectedReferrer, title) { + promise_test(function(test) { + url += (url.indexOf('?') !== -1 ? '&' : '?') + "headers=referer&cors"; + + return fetch(url , init).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.headers.get("x-request-referer"), expectedReferrer, "Request's referrer is correct"); + }); + }, title); +} + +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py"; +var corsFetchedUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py"; +var redirectUrl = RESOURCES_DIR + "redirect.py?location=" ; +var corsRedirectUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "redirect.py?location="; + +runTest(fetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, location.toString(), "origin-when-cross-origin policy on a same-origin URL"); +runTest(corsFetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, get_host_info().HTTP_ORIGIN + "/", "origin-when-cross-origin policy on a cross-origin URL"); +runTest(redirectUrl + corsFetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, get_host_info().HTTP_ORIGIN + "/", "origin-when-cross-origin policy on a cross-origin URL after same-origin redirection"); +runTest(corsRedirectUrl + fetchedUrl, { referrerPolicy: "origin-when-cross-origin"}, get_host_info().HTTP_ORIGIN + "/", "origin-when-cross-origin policy on a same-origin URL after cross-origin redirection"); + + +var referrerUrlWithCredentials = get_host_info().HTTP_ORIGIN.replace("http://", "http://username:password@"); +runTest(fetchedUrl, {referrer: referrerUrlWithCredentials}, get_host_info().HTTP_ORIGIN + "/", "Referrer with credentials should be stripped"); +var referrerUrlWithFragmentIdentifier = get_host_info().HTTP_ORIGIN + "#fragmentIdentifier"; +runTest(fetchedUrl, {referrer: referrerUrlWithFragmentIdentifier}, get_host_info().HTTP_ORIGIN + "/", "Referrer with fragment ID should be stripped"); diff --git a/test/fixtures/wpt/fetch/api/basic/request-forbidden-headers.any.js b/test/fixtures/wpt/fetch/api/basic/request-forbidden-headers.any.js new file mode 100644 index 0000000..d7560f0 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-forbidden-headers.any.js @@ -0,0 +1,82 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function requestValidOverrideHeaders(desc, validHeaders) { + var url = RESOURCES_DIR + "inspect-headers.py"; + var requestInit = {"headers": validHeaders} + var urlParameters = "?headers=" + Object.keys(validHeaders).join("|"); + + promise_test(function(test){ + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + for (var header in validHeaders) + assert_equals(resp.headers.get("x-request-" + header), validHeaders[header], header + "is not skipped for non-forbidden methods"); + }); + }, desc); +} + +requestForbiddenHeaders("Accept-Charset is a forbidden request header", {"Accept-Charset": "utf-8"}); +requestForbiddenHeaders("Accept-Encoding is a forbidden request header", {"Accept-Encoding": ""}); + +requestForbiddenHeaders("Access-Control-Request-Headers is a forbidden request header", {"Access-Control-Request-Headers": ""}); +requestForbiddenHeaders("Access-Control-Request-Method is a forbidden request header", {"Access-Control-Request-Method": ""}); +requestForbiddenHeaders("Connection is a forbidden request header", {"Connection": "close"}); +requestForbiddenHeaders("Content-Length is a forbidden request header", {"Content-Length": "42"}); +requestForbiddenHeaders("Cookie is a forbidden request header", {"Cookie": "cookie=none"}); +requestForbiddenHeaders("Cookie2 is a forbidden request header", {"Cookie2": "cookie2=none"}); +requestForbiddenHeaders("Date is a forbidden request header", {"Date": "Wed, 04 May 1988 22:22:22 GMT"}); +requestForbiddenHeaders("DNT is a forbidden request header", {"DNT": "4"}); +requestForbiddenHeaders("Expect is a forbidden request header", {"Expect": "100-continue"}); +requestForbiddenHeaders("Host is a forbidden request header", {"Host": "http://wrong-host.com"}); +requestForbiddenHeaders("Keep-Alive is a forbidden request header", {"Keep-Alive": "timeout=15"}); +requestForbiddenHeaders("Origin is a forbidden request header", {"Origin": "http://wrong-origin.com"}); +requestForbiddenHeaders("Referer is a forbidden request header", {"Referer": "http://wrong-referer.com"}); +requestForbiddenHeaders("TE is a forbidden request header", {"TE": "trailers"}); +requestForbiddenHeaders("Trailer is a forbidden request header", {"Trailer": "Accept"}); +requestForbiddenHeaders("Transfer-Encoding is a forbidden request header", {"Transfer-Encoding": "chunked"}); +requestForbiddenHeaders("Upgrade is a forbidden request header", {"Upgrade": "HTTP/2.0"}); +requestForbiddenHeaders("Via is a forbidden request header", {"Via": "1.1 nowhere.com"}); +requestForbiddenHeaders("Proxy- is a forbidden request header", {"Proxy-": "value"}); +requestForbiddenHeaders("Proxy-Test is a forbidden request header", {"Proxy-Test": "value"}); +requestForbiddenHeaders("Sec- is a forbidden request header", {"Sec-": "value"}); +requestForbiddenHeaders("Sec-Test is a forbidden request header", {"Sec-Test": "value"}); + +let forbiddenMethods = [ + "TRACE", + "TRACK", + "CONNECT", + "trace", + "track", + "connect", + "trace,", + "GET,track ", + " connect", +]; + +let overrideHeaders = [ + "x-http-method-override", + "x-http-method", + "x-method-override", + "X-HTTP-METHOD-OVERRIDE", + "X-HTTP-METHOD", + "X-METHOD-OVERRIDE", +]; + +for (forbiddenMethod of forbiddenMethods) { + for (overrideHeader of overrideHeaders) { + requestForbiddenHeaders(`header ${overrideHeader} is forbidden to use value ${forbiddenMethod}`, {[overrideHeader]: forbiddenMethod}); + } +} + +let permittedValues = [ + "GETTRACE", + "GET", + "\",TRACE\",", +]; + +for (permittedValue of permittedValues) { + for (overrideHeader of overrideHeaders) { + requestValidOverrideHeaders(`header ${overrideHeader} is allowed to use value ${permittedValue}`, {[overrideHeader]: permittedValue}); + } +} diff --git a/test/fixtures/wpt/fetch/api/basic/request-head.any.js b/test/fixtures/wpt/fetch/api/basic/request-head.any.js new file mode 100644 index 0000000..e0b6afa --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-head.any.js @@ -0,0 +1,6 @@ +// META: global=window,worker + +promise_test(function(test) { + var requestInit = {"method": "HEAD", "body": "test"}; + return promise_rejects_js(test, TypeError, fetch(".", requestInit)); +}, "Fetch with HEAD with body"); diff --git a/test/fixtures/wpt/fetch/api/basic/request-headers-case.any.js b/test/fixtures/wpt/fetch/api/basic/request-headers-case.any.js new file mode 100644 index 0000000..4c10e71 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-headers-case.any.js @@ -0,0 +1,13 @@ +// META: global=window,worker + +promise_test(() => { + return fetch("/xhr/resources/echo-headers.py", {headers: [["THIS-is-A-test", 1], ["THIS-IS-A-TEST", 2]] }).then(res => res.text()).then(body => { + assert_regexp_match(body, /THIS-is-A-test: 1, 2/) + }) +}, "Multiple headers with the same name, different case (THIS-is-A-test first)") + +promise_test(() => { + return fetch("/xhr/resources/echo-headers.py", {headers: [["THIS-IS-A-TEST", 1], ["THIS-is-A-test", 2]] }).then(res => res.text()).then(body => { + assert_regexp_match(body, /THIS-IS-A-TEST: 1, 2/) + }) +}, "Multiple headers with the same name, different case (THIS-IS-A-TEST first)") diff --git a/test/fixtures/wpt/fetch/api/basic/request-headers-nonascii.any.js b/test/fixtures/wpt/fetch/api/basic/request-headers-nonascii.any.js new file mode 100644 index 0000000..4a9a801 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-headers-nonascii.any.js @@ -0,0 +1,29 @@ +// META: global=window,worker + +// This tests characters that are not +// https://infra.spec.whatwg.org/#ascii-code-point +// but are still +// https://infra.spec.whatwg.org/#byte-value +// in request header values. +// Such request header values are valid and thus sent to servers. +// Characters outside the #byte-value range are tested e.g. in +// fetch/api/headers/headers-errors.html. + +promise_test(() => { + return fetch( + "../resources/inspect-headers.py?headers=accept|x-test", + {headers: { + "Accept": "before-æøå-after", + "X-Test": "before-ß-after" + }}) + .then(res => { + assert_equals( + res.headers.get("x-request-accept"), + "before-æøå-after", + "Accept Header"); + assert_equals( + res.headers.get("x-request-x-test"), + "before-ß-after", + "X-Test Header"); + }); +}, "Non-ascii bytes in request headers"); diff --git a/test/fixtures/wpt/fetch/api/basic/request-headers.any.js b/test/fixtures/wpt/fetch/api/basic/request-headers.any.js new file mode 100644 index 0000000..f6a7fe1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-headers.any.js @@ -0,0 +1,83 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkContentType(contentType, body) +{ + if (self.FormData && body instanceof self.FormData) { + assert_true(contentType.startsWith("multipart/form-data; boundary="), "Request should have header content-type starting with multipart/form-data; boundary=, but got " + contentType); + return; + } + + var expectedContentType = "text/plain;charset=UTF-8"; + if(body === null || body instanceof ArrayBuffer || body.buffer instanceof ArrayBuffer) + expectedContentType = null; + else if (body instanceof Blob) + expectedContentType = body.type ? body.type : null; + else if (body instanceof URLSearchParams) + expectedContentType = "application/x-www-form-urlencoded;charset=UTF-8"; + + assert_equals(contentType , expectedContentType, "Request should have header content-type: " + expectedContentType); +} + +function requestHeaders(desc, url, method, body, expectedOrigin, expectedContentLength) { + var urlParameters = "?headers=origin|user-agent|accept-charset|content-length|content-type"; + var requestInit = {"method": method} + promise_test(function(test){ + if (typeof body === "function") + body = body(); + if (body) + requestInit["body"] = body; + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_true(resp.headers.has("x-request-user-agent"), "Request has header user-agent"); + assert_false(resp.headers.has("accept-charset"), "Request has header accept-charset"); + assert_equals(resp.headers.get("x-request-origin") , expectedOrigin, "Request should have header origin: " + expectedOrigin); + if (expectedContentLength !== undefined) + assert_equals(resp.headers.get("x-request-content-length") , expectedContentLength, "Request should have header content-length: " + expectedContentLength); + checkContentType(resp.headers.get("x-request-content-type"), body); + }); + }, desc); +} + +var url = RESOURCES_DIR + "inspect-headers.py" + +requestHeaders("Fetch with GET", url, "GET", null, null, null); +requestHeaders("Fetch with HEAD", url, "HEAD", null, null, null); +requestHeaders("Fetch with PUT without body", url, "POST", null, location.origin, "0"); +requestHeaders("Fetch with PUT with body", url, "PUT", "Request's body", location.origin, "14"); +requestHeaders("Fetch with POST without body", url, "POST", null, location.origin, "0"); +requestHeaders("Fetch with POST with text body", url, "POST", "Request's body", location.origin, "14"); +requestHeaders("Fetch with POST with FormData body", url, "POST", function() { return new FormData(); }, location.origin); +requestHeaders("Fetch with POST with URLSearchParams body", url, "POST", function() { return new URLSearchParams("name=value"); }, location.origin, "10"); +requestHeaders("Fetch with POST with Blob body", url, "POST", new Blob(["Test"]), location.origin, "4"); +requestHeaders("Fetch with POST with ArrayBuffer body", url, "POST", new ArrayBuffer(4), location.origin, "4"); +requestHeaders("Fetch with POST with Uint8Array body", url, "POST", new Uint8Array(4), location.origin, "4"); +requestHeaders("Fetch with POST with Int8Array body", url, "POST", new Int8Array(4), location.origin, "4"); +requestHeaders("Fetch with POST with Float16Array body", url, "POST", () => new Float16Array(1), location.origin, "2"); +requestHeaders("Fetch with POST with Float32Array body", url, "POST", new Float32Array(1), location.origin, "4"); +requestHeaders("Fetch with POST with Float64Array body", url, "POST", new Float64Array(1), location.origin, "8"); +requestHeaders("Fetch with POST with DataView body", url, "POST", new DataView(new ArrayBuffer(8), 0, 4), location.origin, "4"); +requestHeaders("Fetch with POST with Blob body with mime type", url, "POST", new Blob(["Test"], { type: "text/maybe" }), location.origin, "4"); +requestHeaders("Fetch with Chicken", url, "Chicken", null, location.origin, null); +requestHeaders("Fetch with Chicken with body", url, "Chicken", "Request's body", location.origin, "14"); + +function requestOriginHeader(method, mode, needsOrigin) { + promise_test(function(test){ + return fetch(url + "?headers=origin", {method:method, mode:mode}).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + if(needsOrigin) + assert_equals(resp.headers.get("x-request-origin") , location.origin, "Request should have an Origin header with origin: " + location.origin); + else + assert_equals(resp.headers.get("x-request-origin"), null, "Request should not have an Origin header") + }); + }, "Fetch with " + method + " and mode \"" + mode + "\" " + (needsOrigin ? "needs" : "does not need") + " an Origin header"); +} + +requestOriginHeader("GET", "cors", false); +requestOriginHeader("POST", "same-origin", true); +requestOriginHeader("POST", "no-cors", true); +requestOriginHeader("PUT", "same-origin", true); +requestOriginHeader("TacO", "same-origin", true); +requestOriginHeader("TacO", "cors", true); diff --git a/test/fixtures/wpt/fetch/api/basic/request-private-network-headers.tentative.any.js b/test/fixtures/wpt/fetch/api/basic/request-private-network-headers.tentative.any.js new file mode 100644 index 0000000..9662a91 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-private-network-headers.tentative.any.js @@ -0,0 +1,18 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +requestForbiddenHeaders( + 'Access-Control-Request-Private-Network is a forbidden request header', + {'Access-Control-Request-Private-Network': ''}); + +var invalidRequestHeaders = [ + ["Access-Control-Request-Private-Network", "KO"], +]; + +invalidRequestHeaders.forEach(function(header) { + test(function() { + var request = new Request(""); + request.headers.set(header[0], header[1]); + assert_equals(request.headers.get(header[0]), null); + }, "Adding invalid request header \"" + header[0] + ": " + header[1] + "\""); +}); diff --git a/test/fixtures/wpt/fetch/api/basic/request-referrer-redirected-worker.html b/test/fixtures/wpt/fetch/api/basic/request-referrer-redirected-worker.html new file mode 100644 index 0000000..bdea1e1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-referrer-redirected-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer header + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/basic/request-referrer.any.js b/test/fixtures/wpt/fetch/api/basic/request-referrer.any.js new file mode 100644 index 0000000..0c33576 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-referrer.any.js @@ -0,0 +1,24 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function testReferrer(referrer, expected, desc) { + promise_test(function(test) { + var url = RESOURCES_DIR + "inspect-headers.py?headers=referer" + var req = new Request(url, { referrer: referrer }); + return fetch(req).then(function(resp) { + var actual = resp.headers.get("x-request-referer"); + if (expected) { + assert_equals(actual, expected, "request's referer should be: " + expected); + return; + } + if (actual) { + assert_equals(actual, "", "request's referer should be empty"); + } + }); + }, desc); +} + +testReferrer("about:client", self.location.href, 'about:client referrer'); + +var fooURL = new URL("./foo", self.location).href; +testReferrer(fooURL, fooURL, 'url referrer'); diff --git a/test/fixtures/wpt/fetch/api/basic/request-upload.any.js b/test/fixtures/wpt/fetch/api/basic/request-upload.any.js new file mode 100644 index 0000000..0c4813b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-upload.any.js @@ -0,0 +1,139 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +function testUpload(desc, url, method, createBody, expectedBody) { + const requestInit = {method}; + promise_test(function(test){ + const body = createBody(); + if (body) { + requestInit["body"] = body; + requestInit.duplex = "half"; + } + return fetch(url, requestInit).then(function(resp) { + return resp.text().then((text)=> { + assert_equals(text, expectedBody); + }); + }); + }, desc); +} + +function testUploadFailure(desc, url, method, createBody) { + const requestInit = {method}; + promise_test(t => { + const body = createBody(); + if (body) { + requestInit["body"] = body; + } + return promise_rejects_js(t, TypeError, fetch(url, requestInit)); + }, desc); +} + +const url = RESOURCES_DIR + "echo-content.py" + +testUpload("Fetch with PUT with body", url, + "PUT", + () => "Request's body", + "Request's body"); +testUpload("Fetch with POST with text body", url, + "POST", + () => "Request's body", + "Request's body"); +testUpload("Fetch with POST with URLSearchParams body", url, + "POST", + () => new URLSearchParams("name=value"), + "name=value"); +testUpload("Fetch with POST with Blob body", url, + "POST", + () => new Blob(["Test"]), + "Test"); +testUpload("Fetch with POST with ArrayBuffer body", url, + "POST", + () => new ArrayBuffer(4), + "\0\0\0\0"); +testUpload("Fetch with POST with Uint8Array body", url, + "POST", + () => new Uint8Array(4), + "\0\0\0\0"); +testUpload("Fetch with POST with Int8Array body", url, + "POST", + () => new Int8Array(4), + "\0\0\0\0"); +testUpload("Fetch with POST with Float16Array body", url, + "POST", + () => new Float16Array(2), + "\0\0\0\0"); +testUpload("Fetch with POST with Float32Array body", url, + "POST", + () => new Float32Array(1), + "\0\0\0\0"); +testUpload("Fetch with POST with Float64Array body", url, + "POST", + () => new Float64Array(1), + "\0\0\0\0\0\0\0\0"); +testUpload("Fetch with POST with DataView body", url, + "POST", + () => new DataView(new ArrayBuffer(8), 0, 4), + "\0\0\0\0"); +testUpload("Fetch with POST with Blob body with mime type", url, + "POST", + () => new Blob(["Test"], { type: "text/maybe" }), + "Test"); + +testUploadFailure("Fetch with POST with ReadableStream containing String", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue("Test"); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing null", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(null); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing number", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(99); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing ArrayBuffer", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(new ArrayBuffer()); + controller.close(); + }}) + }); +testUploadFailure("Fetch with POST with ReadableStream containing Blob", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.enqueue(new Blob()); + controller.close(); + }}) + }); + +promise_test(async (test) => { + const resp = await fetch( + "/fetch/connection-pool/resources/network-partition-key.py?" + + `status=421&uuid=${token()}&partition_id=${get_host_info().ORIGIN}` + + `&dispatch=check_partition&addcounter=true`, + {method: "POST", body: "foobar"}); + assert_equals(resp.status, 421); + const text = await resp.text(); + assert_equals(text, "ok. Request was sent 2 times. 2 connections were created."); +}, "Fetch with POST with text body on 421 response should be retried once on new connection."); + +promise_test(async (test) => { + const body = new ReadableStream({start: c => c.close()}); + await promise_rejects_js(test, TypeError, fetch('/', {method: 'POST', body})); +}, "Streaming upload shouldn't work on Http/1.1."); diff --git a/test/fixtures/wpt/fetch/api/basic/request-upload.h2.any.js b/test/fixtures/wpt/fetch/api/basic/request-upload.h2.any.js new file mode 100644 index 0000000..6812227 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/request-upload.h2.any.js @@ -0,0 +1,209 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +const duplex = "half"; + +async function assertUpload(url, method, createBody, expectedBody) { + const requestInit = {method}; + const body = createBody(); + if (body) { + requestInit["body"] = body; + requestInit.duplex = "half"; + } + const resp = await fetch(url, requestInit); + const text = await resp.text(); + assert_equals(text, expectedBody); +} + +function testUpload(desc, url, method, createBody, expectedBody) { + promise_test(async () => { + await assertUpload(url, method, createBody, expectedBody); + }, desc); +} + +function createStream(chunks) { + return new ReadableStream({ + start: (controller) => { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + } + }); +} + +const url = RESOURCES_DIR + "echo-content.h2.py" + +testUpload("Fetch with POST with empty ReadableStream", url, + "POST", + () => { + return new ReadableStream({start: controller => { + controller.close(); + }}) + }, + ""); + +testUpload("Fetch with POST with ReadableStream", url, + "POST", + () => { + return new ReadableStream({start: controller => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }}) + }, + "Test"); + +promise_test(async (test) => { + const body = new ReadableStream({start: controller => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }}); + const resp = await fetch( + "/fetch/connection-pool/resources/network-partition-key.py?" + + `status=421&uuid=${token()}&partition_id=${self.origin}` + + `&dispatch=check_partition&addcounter=true`, + {method: "POST", body: body, duplex}); + assert_equals(resp.status, 421); + const text = await resp.text(); + assert_equals(text, "ok. Request was sent 1 times. 1 connections were created."); +}, "Fetch with POST with ReadableStream on 421 response should return the response and not retry."); + +promise_test(async (test) => { + const request = new Request('', { + body: new ReadableStream(), + method: 'POST', + duplex, + }); + + assert_equals(request.headers.get('Content-Type'), null, `Request should not have a content-type set`); + + const response = await fetch('data:a/a;charset=utf-8,test', { + method: 'POST', + body: new ReadableStream(), + duplex, + }); + + assert_equals(await response.text(), 'test', `Response has correct body`); +}, "Feature detect for POST with ReadableStream"); + +promise_test(async (test) => { + const request = new Request('data:a/a;charset=utf-8,test', { + body: new ReadableStream(), + method: 'POST', + duplex, + }); + + assert_equals(request.headers.get('Content-Type'), null, `Request should not have a content-type set`); + const response = await fetch(request); + assert_equals(await response.text(), 'test', `Response has correct body`); +}, "Feature detect for POST with ReadableStream, using request object"); + +test(() => { + let duplexAccessed = false; + + const request = new Request("", { + body: new ReadableStream(), + method: "POST", + get duplex() { + duplexAccessed = true; + return "half"; + }, + }); + + assert_equals( + request.headers.get("Content-Type"), + null, + `Request should not have a content-type set` + ); + assert_true(duplexAccessed, `duplex dictionary property should be accessed`); +}, "Synchronous feature detect"); + +// The asserts the synchronousFeatureDetect isn't broken by a partial implementation. +// An earlier feature detect was broken by Safari implementing streaming bodies as part of Request, +// but it failed when passed to fetch(). +// This tests ensures that UAs must not implement RequestInit.duplex and streaming request bodies without also implementing the fetch() parts. +promise_test(async () => { + let duplexAccessed = false; + + const request = new Request("", { + body: new ReadableStream(), + method: "POST", + get duplex() { + duplexAccessed = true; + return "half"; + }, + }); + + const supported = + request.headers.get("Content-Type") === null && duplexAccessed; + + // If the feature detect fails, assume the browser is being truthful (other tests pick up broken cases here) + if (!supported) return false; + + await assertUpload( + url, + "POST", + () => + new ReadableStream({ + start: (controller) => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }, + }), + "Test" + ); +}, "Synchronous feature detect fails if feature unsupported"); + +promise_test(async (t) => { + const body = createStream(["hello"]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload with body containing a String"); + +promise_test(async (t) => { + const body = createStream([null]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload with body containing null"); + +promise_test(async (t) => { + const body = createStream([33]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload with body containing a number"); + +promise_test(async (t) => { + const url = "/fetch/api/resources/authentication.py?realm=test"; + const body = createStream([]); + const method = "POST"; + await promise_rejects_js(t, TypeError, fetch(url, { method, body, duplex })); +}, "Streaming upload should fail on a 401 response"); + +promise_test(async (t) => { + const abortMessage = 'foo abort'; + let streamCancelPromise = new Promise(async res => { + var stream = new ReadableStream({ + cancel: function(reason) { + res(reason); + } + }); + let abortController = new AbortController(); + let fetchPromise = promise_rejects_exactly(t, abortMessage, fetch('', { + method: 'POST', + body: stream, + duplex: 'half', + signal: abortController.signal + })); + abortController.abort(abortMessage); + await fetchPromise; + }); + + let cancelReason = await streamCancelPromise; + assert_equals( + cancelReason, abortMessage, 'ReadableStream.cancel should be called.'); +}, 'ReadbleStream should be closed on signal.abort'); diff --git a/test/fixtures/wpt/fetch/api/basic/response-null-body.any.js b/test/fixtures/wpt/fetch/api/basic/response-null-body.any.js new file mode 100644 index 0000000..bb05892 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/response-null-body.any.js @@ -0,0 +1,38 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +const nullBodyStatus = [204, 205, 304]; +const methods = ["GET", "POST", "OPTIONS"]; + +for (const status of nullBodyStatus) { + for (const method of methods) { + promise_test( + async () => { + const url = + `${RESOURCES_DIR}status.py?code=${status}&content=hello-world`; + const resp = await fetch(url, { method }); + assert_equals(resp.status, status); + assert_equals(resp.body, null, "the body should be null"); + const text = await resp.text(); + assert_equals(text, "", "null bodies result in empty text"); + }, + `Response.body is null for responses with status=${status} (method=${method})`, + ); + } +} + +promise_test(async () => { + const url = `${RESOURCES_DIR}status.py?code=200&content=hello-world`; + const resp = await fetch(url, { method: "HEAD" }); + assert_equals(resp.status, 200); + assert_equals(resp.body, null, "the body should be null"); + const text = await resp.text(); + assert_equals(text, "", "null bodies result in empty text"); +}, `Response.body is null for responses with method=HEAD`); + +promise_test(async (t) => { + const integrity = "sha384-UT6f7WCFp32YJnp1is4l/ZYnOeQKpE8xjmdkLOwZ3nIP+tmT2aMRFQGJomjVf5cE"; + const url = `${RESOURCES_DIR}status.py?code=204&content=hello-world`; + const promise = fetch(url, { method: "GET", integrity }); + promise_rejects_js(t, TypeError, promise); +}, "Null body status with subresource integrity should abort"); diff --git a/test/fixtures/wpt/fetch/api/basic/response-url.sub.any.js b/test/fixtures/wpt/fetch/api/basic/response-url.sub.any.js new file mode 100644 index 0000000..0d123c4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/response-url.sub.any.js @@ -0,0 +1,16 @@ +function checkResponseURL(fetchedURL, expectedURL) +{ + promise_test(function() { + return fetch(fetchedURL).then(function(response) { + assert_equals(response.url, expectedURL); + }); + }, "Testing response url getter with " +fetchedURL); +} + +var baseURL = "http://{{host}}:{{ports[http][0]}}"; +checkResponseURL(baseURL + "/ada", baseURL + "/ada"); +checkResponseURL(baseURL + "/#", baseURL + "/"); +checkResponseURL(baseURL + "/#ada", baseURL + "/"); +checkResponseURL(baseURL + "#ada", baseURL + "/"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/basic/scheme-about.any.js b/test/fixtures/wpt/fetch/api/basic/scheme-about.any.js new file mode 100644 index 0000000..9ef4418 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/scheme-about.any.js @@ -0,0 +1,26 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkNetworkError(url, method) { + method = method || "GET"; + const desc = "Fetching " + url.substring(0, 45) + " with method " + method + " is KO" + promise_test(function(test) { + var promise = fetch(url, { method: method }); + return promise_rejects_js(test, TypeError, promise); + }, desc); +} + +checkNetworkError("about:blank", "GET"); +checkNetworkError("about:blank", "PUT"); +checkNetworkError("about:blank", "POST"); +checkNetworkError("about:invalid.com"); +checkNetworkError("about:config"); +checkNetworkError("about:unicorn"); + +promise_test(function(test) { + var promise = fetch("about:blank", { + "method": "GET", + "Range": "bytes=1-10" + }); + return promise_rejects_js(test, TypeError, promise); +}, "Fetching about:blank with range header does not affect behavior"); diff --git a/test/fixtures/wpt/fetch/api/basic/scheme-blob.sub.any.js b/test/fixtures/wpt/fetch/api/basic/scheme-blob.sub.any.js new file mode 100644 index 0000000..8afdc03 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/scheme-blob.sub.any.js @@ -0,0 +1,125 @@ +// META: script=../resources/utils.js + +function checkFetchResponse(url, data, mime, size, desc) { + promise_test(function(test) { + size = size.toString(); + return fetch(url).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), mime, "Content-Type is " + resp.headers.get("Content-Type")); + assert_equals(resp.headers.get("Content-Length"), size, "Content-Length is " + resp.headers.get("Content-Length")); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, data, "Response's body is " + data); + }); + }, desc); +} + +var blob = new Blob(["Blob's data"], { "type" : "text/plain" }); +checkFetchResponse(URL.createObjectURL(blob), "Blob's data", "text/plain", blob.size, + "Fetching [GET] URL.createObjectURL(blob) is OK"); + +function checkKoUrl(url, method, desc) { + promise_test(function(test) { + var promise = fetch(url, {"method": method}); + return promise_rejects_js(test, TypeError, promise); + }, desc); +} + +var blob2 = new Blob(["Blob's data"], { "type" : "text/plain" }); +checkKoUrl("blob:http://{{domains[www]}}:{{ports[http][0]}}/", "GET", + "Fetching [GET] blob:http://{{domains[www]}}:{{ports[http][0]}}/ is KO"); + +var invalidRequestMethods = [ + "POST", + "OPTIONS", + "HEAD", + "PUT", + "DELETE", + "INVALID", +]; +invalidRequestMethods.forEach(function(method) { + checkKoUrl(URL.createObjectURL(blob2), method, "Fetching [" + method + "] URL.createObjectURL(blob) is KO"); +}); + +checkKoUrl("blob:not-backed-by-a-blob/", "GET", + "Fetching [GET] blob:not-backed-by-a-blob/ is KO"); + +let empty_blob = new Blob([]); +checkFetchResponse(URL.createObjectURL(empty_blob), "", "", 0, + "Fetching URL.createObjectURL(empty_blob) is OK"); + +let empty_type_blob = new Blob([], {type: ""}); +checkFetchResponse(URL.createObjectURL(empty_type_blob), "", "", 0, + "Fetching URL.createObjectURL(empty_type_blob) is OK"); + +let empty_data_blob = new Blob([], {type: "text/plain"}); +checkFetchResponse(URL.createObjectURL(empty_data_blob), "", "text/plain", 0, + "Fetching URL.createObjectURL(empty_data_blob) is OK"); + +let invalid_type_blob = new Blob([], {type: "invalid"}); +checkFetchResponse(URL.createObjectURL(invalid_type_blob), "", "", 0, + "Fetching URL.createObjectURL(invalid_type_blob) is OK"); + +promise_test(function(test) { + return fetch("/images/blue.png").then(function(resp) { + return resp.arrayBuffer(); + }).then(function(image_buffer) { + let blob = new Blob([image_buffer]); + return fetch(URL.createObjectURL(blob)).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), "", "Content-Type is " + resp.headers.get("Content-Type")); + }) + }); +}, "Blob content is not sniffed for a content type [image/png]"); + +let simple_xml_string = ''; +let xml_blob_no_type = new Blob([simple_xml_string]); +checkFetchResponse(URL.createObjectURL(xml_blob_no_type), simple_xml_string, "", 45, + "Blob content is not sniffed for a content type [text/xml]"); + +let simple_text_string = 'Hello, World!'; +promise_test(function(test) { + let blob = new Blob([simple_text_string], {"type": "text/plain"}); + let slice = blob.slice(7, simple_text_string.length, "\0"); + return fetch(URL.createObjectURL(slice)).then(function (resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), ""); + assert_equals(resp.headers.get("Content-Length"), "6"); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, "World!"); + }); +}, "Set content type to the empty string for slice with invalid content type"); + +promise_test(function(test) { + let blob = new Blob([simple_text_string], {"type": "text/plain"}); + let slice = blob.slice(7, simple_text_string.length, "\0"); + return fetch(URL.createObjectURL(slice)).then(function (resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), ""); + assert_equals(resp.headers.get("Content-Length"), "6"); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, "World!"); + }); +}, "Set content type to the empty string for slice with no content type "); + +promise_test(function(test) { + let blob = new Blob([simple_xml_string]); + let slice = blob.slice(0, 38); + return fetch(URL.createObjectURL(slice)).then(function (resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), ""); + assert_equals(resp.headers.get("Content-Length"), "38"); + return resp.text(); + }).then(function(bodyAsText) { + assert_equals(bodyAsText, ''); + }); +}, "Blob.slice should not sniff the content for a content type"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/basic/scheme-data.any.js b/test/fixtures/wpt/fetch/api/basic/scheme-data.any.js new file mode 100644 index 0000000..55df43b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/scheme-data.any.js @@ -0,0 +1,43 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkFetchResponse(url, data, mime, fetchMode, method) { + var cut = (url.length >= 40) ? "[...]" : ""; + var desc = "Fetching " + (method ? "[" + method + "] " : "") + url.substring(0, 40) + cut + " is OK"; + var init = {"method": method || "GET"}; + if (fetchMode) { + init.mode = fetchMode; + desc += " (" + fetchMode + ")"; + } + promise_test(function(test) { + return fetch(url, init).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.statusText, "OK", "HTTP statusText is OK"); + assert_equals(resp.type, "basic", "response type is basic"); + assert_equals(resp.headers.get("Content-Type"), mime, "Content-Type is " + resp.headers.get("Content-Type")); + return resp.text(); + }).then(function(body) { + assert_equals(body, data, "Response's body is correct"); + }); + }, desc); +} + +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII"); +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII", "same-origin"); +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII", "cors"); +checkFetchResponse("data:text/plain;base64,cmVzcG9uc2UncyBib2R5", "response's body", "text/plain"); +checkFetchResponse("data:image/png;base64,cmVzcG9uc2UncyBib2R5", + "response's body", + "image/png"); +checkFetchResponse("data:,response%27s%20body", "response's body", "text/plain;charset=US-ASCII", null, "POST"); +checkFetchResponse("data:,response%27s%20body", "", "text/plain;charset=US-ASCII", null, "HEAD"); + +function checkKoUrl(url, method, desc) { + var cut = (url.length >= 40) ? "[...]" : ""; + desc = "Fetching [" + method + "] " + url.substring(0, 45) + cut + " is KO" + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url, {"method": method})); + }, desc); +} + +checkKoUrl("data:notAdataUrl.com", "GET"); diff --git a/test/fixtures/wpt/fetch/api/basic/scheme-others.sub.any.js b/test/fixtures/wpt/fetch/api/basic/scheme-others.sub.any.js new file mode 100644 index 0000000..550f69c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/scheme-others.sub.any.js @@ -0,0 +1,31 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function checkKoUrl(url, desc) { + if (!desc) + desc = "Fetching " + url.substring(0, 45) + " is KO" + promise_test(function(test) { + var promise = fetch(url); + return promise_rejects_js(test, TypeError, promise); + }, desc); +} + +var urlWithoutScheme = "://{{host}}:{{ports[http][0]}}/"; +checkKoUrl("aaa" + urlWithoutScheme); +checkKoUrl("cap" + urlWithoutScheme); +checkKoUrl("cid" + urlWithoutScheme); +checkKoUrl("dav" + urlWithoutScheme); +checkKoUrl("dict" + urlWithoutScheme); +checkKoUrl("dns" + urlWithoutScheme); +checkKoUrl("geo" + urlWithoutScheme); +checkKoUrl("im" + urlWithoutScheme); +checkKoUrl("imap" + urlWithoutScheme); +checkKoUrl("ipp" + urlWithoutScheme); +checkKoUrl("ldap" + urlWithoutScheme); +checkKoUrl("mailto" + urlWithoutScheme); +checkKoUrl("nfs" + urlWithoutScheme); +checkKoUrl("pop" + urlWithoutScheme); +checkKoUrl("rtsp" + urlWithoutScheme); +checkKoUrl("snmp" + urlWithoutScheme); + +done(); diff --git a/test/fixtures/wpt/fetch/api/basic/status.h2.any.js b/test/fixtures/wpt/fetch/api/basic/status.h2.any.js new file mode 100644 index 0000000..99fec88 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/status.h2.any.js @@ -0,0 +1,17 @@ +// See also /xhr/status.h2.window.js + +[ + 200, + 210, + 400, + 404, + 410, + 500, + 502 +].forEach(status => { + promise_test(async t => { + const response = await fetch("/xhr/resources/status.py?code=" + status); + assert_equals(response.status, status, "status should be " + status); + assert_equals(response.statusText, "", "statusText should be the empty string"); + }, "statusText over H2 for status " + status + " should be the empty string"); +}); diff --git a/test/fixtures/wpt/fetch/api/basic/stream-response.any.js b/test/fixtures/wpt/fetch/api/basic/stream-response.any.js new file mode 100644 index 0000000..d964dda --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/stream-response.any.js @@ -0,0 +1,40 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function streamBody(reader, test, count = 0) { + return reader.read().then(function(data) { + if (!data.done && count < 2) { + count += 1; + return streamBody(reader, test, count); + } else { + test.step(function() { + assert_true(count >= 2, "Retrieve body progressively"); + }); + } + }); +} + +//simulate streaming: +//count is large enough to let the UA deliver the body before it is completely retrieved +promise_test(function(test) { + return fetch(RESOURCES_DIR + "trickle.py?ms=30&count=100").then(function(resp) { + if (resp.body) + return streamBody(resp.body.getReader(), test); + else + test.step(function() { + assert_unreached( "Body does not exist in response"); + }); + }); +}, "Stream response's body when content-type is present"); + +// This test makes sure that the response body is not buffered if no content type is provided. +promise_test(function(test) { + return fetch(RESOURCES_DIR + "trickle.py?ms=300&count=10¬ype=true").then(function(resp) { + if (resp.body) + return streamBody(resp.body.getReader(), test); + else + test.step(function() { + assert_unreached( "Body does not exist in response"); + }); + }); +}, "Stream response's body when content-type is not present"); diff --git a/test/fixtures/wpt/fetch/api/basic/stream-safe-creation.any.js b/test/fixtures/wpt/fetch/api/basic/stream-safe-creation.any.js new file mode 100644 index 0000000..382efc1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/stream-safe-creation.any.js @@ -0,0 +1,54 @@ +// META: global=window,worker + +// These tests verify that stream creation is not affected by changes to +// Object.prototype. + +const creationCases = { + fetch: async () => fetch(location.href), + request: () => new Request(location.href, {method: 'POST', body: 'hi'}), + response: () => new Response('bye'), + consumeEmptyResponse: () => new Response().text(), + consumeNonEmptyResponse: () => new Response(new Uint8Array([64])).text(), + consumeEmptyRequest: () => new Request(location.href).text(), + consumeNonEmptyRequest: () => new Request(location.href, + {method: 'POST', body: 'yes'}).arrayBuffer(), +}; + +for (const creationCase of Object.keys(creationCases)) { + for (const accessorName of ['start', 'type', 'size', 'highWaterMark']) { + promise_test(async t => { + Object.defineProperty(Object.prototype, accessorName, { + get() { throw Error(`Object.prototype.${accessorName} was accessed`); }, + configurable: true + }); + t.add_cleanup(() => { + delete Object.prototype[accessorName]; + return Promise.resolve(); + }); + await creationCases[creationCase](); + }, `throwing Object.prototype.${accessorName} accessor should not affect ` + + `stream creation by '${creationCase}'`); + + promise_test(async t => { + // -1 is a convenient value which is invalid, and should cause the + // constructor to throw, for all four fields. + Object.prototype[accessorName] = -1; + t.add_cleanup(() => { + delete Object.prototype[accessorName]; + return Promise.resolve(); + }); + await creationCases[creationCase](); + }, `Object.prototype.${accessorName} accessor returning invalid value ` + + `should not affect stream creation by '${creationCase}'`); + } + + promise_test(async t => { + Object.prototype.start = controller => controller.error(new Error('start')); + t.add_cleanup(() => { + delete Object.prototype.start; + return Promise.resolve(); + }); + await creationCases[creationCase](); + }, `Object.prototype.start function which errors the stream should not ` + + `affect stream creation by '${creationCase}'`); +} diff --git a/test/fixtures/wpt/fetch/api/basic/text-utf8.any.js b/test/fixtures/wpt/fetch/api/basic/text-utf8.any.js new file mode 100644 index 0000000..05c8c88 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/text-utf8.any.js @@ -0,0 +1,74 @@ +// META: title=Fetch: Request and Response text() should decode as UTF-8 +// META: global=window,worker +// META: script=../resources/utils.js + +function testTextDecoding(body, expectedText, urlParameter, title) +{ + var arrayBuffer = stringToArray(body); + + promise_test(function(test) { + var request = new Request("", {method: "POST", body: arrayBuffer}); + return request.text().then(function(value) { + assert_equals(value, expectedText, "Request.text() should decode data as UTF-8"); + }); + }, title + " with Request.text()"); + + promise_test(function(test) { + var response = new Response(arrayBuffer); + return response.text().then(function(value) { + assert_equals(value, expectedText, "Response.text() should decode data as UTF-8"); + }); + }, title + " with Response.text()"); + + promise_test(function(test) { + return fetch("../resources/status.py?code=200&type=text%2Fplain%3Bcharset%3DUTF-8&content=" + urlParameter).then(function(response) { + return response.text().then(function(value) { + assert_equals(value, expectedText, "Fetched Response.text() should decode data as UTF-8"); + }); + }); + }, title + " with fetched data (UTF-8 charset)"); + + promise_test(function(test) { + return fetch("../resources/status.py?code=200&type=text%2Fplain%3Bcharset%3DUTF-16&content=" + urlParameter).then(function(response) { + return response.text().then(function(value) { + assert_equals(value, expectedText, "Fetched Response.text() should decode data as UTF-8"); + }); + }); + }, title + " with fetched data (UTF-16 charset)"); + + promise_test(function(test) { + return new Response(body).arrayBuffer().then(function(buffer) { + assert_array_equals(new Uint8Array(buffer), encode_utf8(body), "Response.arrayBuffer() should contain data encoded as UTF-8"); + }); + }, title + " (Response object)"); + + promise_test(function(test) { + return new Request("", {method: "POST", body: body}).arrayBuffer().then(function(buffer) { + assert_array_equals(new Uint8Array(buffer), encode_utf8(body), "Request.arrayBuffer() should contain data encoded as UTF-8"); + }); + }, title + " (Request object)"); + +} + +var utf8WithBOM = "\xef\xbb\xbf\xe4\xb8\x89\xe6\x9d\x91\xe3\x81\x8b\xe3\x81\xaa\xe5\xad\x90"; +var utf8WithBOMAsURLParameter = "%EF%BB%BF%E4%B8%89%E6%9D%91%E3%81%8B%E3%81%AA%E5%AD%90"; +var utf8WithoutBOM = "\xe4\xb8\x89\xe6\x9d\x91\xe3\x81\x8b\xe3\x81\xaa\xe5\xad\x90"; +var utf8WithoutBOMAsURLParameter = "%E4%B8%89%E6%9D%91%E3%81%8B%E3%81%AA%E5%AD%90"; +var utf8Decoded = "三村かな子"; +testTextDecoding(utf8WithBOM, utf8Decoded, utf8WithBOMAsURLParameter, "UTF-8 with BOM"); +testTextDecoding(utf8WithoutBOM, utf8Decoded, utf8WithoutBOMAsURLParameter, "UTF-8 without BOM"); + +var utf16BEWithBOM = "\xfe\xff\x4e\x09\x67\x51\x30\x4b\x30\x6a\x5b\x50"; +var utf16BEWithBOMAsURLParameter = "%fe%ff%4e%09%67%51%30%4b%30%6a%5b%50"; +var utf16BEWithBOMDecodedAsUTF8 = "��N\tgQ0K0j[P"; +testTextDecoding(utf16BEWithBOM, utf16BEWithBOMDecodedAsUTF8, utf16BEWithBOMAsURLParameter, "UTF-16BE with BOM decoded as UTF-8"); + +var utf16LEWithBOM = "\xff\xfe\x09\x4e\x51\x67\x4b\x30\x6a\x30\x50\x5b"; +var utf16LEWithBOMAsURLParameter = "%ff%fe%09%4e%51%67%4b%30%6a%30%50%5b"; +var utf16LEWithBOMDecodedAsUTF8 = "��\tNQgK0j0P["; +testTextDecoding(utf16LEWithBOM, utf16LEWithBOMDecodedAsUTF8, utf16LEWithBOMAsURLParameter, "UTF-16LE with BOM decoded as UTF-8"); + +var utf16WithoutBOM = "\xe6\x00\xf8\x00\xe5\x00\x0a\x00\xc6\x30\xb9\x30\xc8\x30\x0a\x00"; +var utf16WithoutBOMAsURLParameter = "%E6%00%F8%00%E5%00%0A%00%C6%30%B9%30%C8%30%0A%00"; +var utf16WithoutBOMDecoded = "\ufffd\u0000\ufffd\u0000\ufffd\u0000\u000a\u0000\ufffd\u0030\ufffd\u0030\ufffd\u0030\u000a\u0000"; +testTextDecoding(utf16WithoutBOM, utf16WithoutBOMDecoded, utf16WithoutBOMAsURLParameter, "UTF-16 without BOM decoded as UTF-8"); diff --git a/test/fixtures/wpt/fetch/api/basic/url-parsing.sub.html b/test/fixtures/wpt/fetch/api/basic/url-parsing.sub.html new file mode 100644 index 0000000..fa47b29 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/basic/url-parsing.sub.html @@ -0,0 +1,33 @@ + + + + + + + + +
+ diff --git a/test/fixtures/wpt/fetch/api/body/cloned-any.js b/test/fixtures/wpt/fetch/api/body/cloned-any.js new file mode 100644 index 0000000..2bca96c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/body/cloned-any.js @@ -0,0 +1,50 @@ +// Changing the body after it have been passed to Response/Request +// should not change the outcome of the consumed body + +const url = 'http://a'; +const method = 'post'; + +promise_test(async t => { + const body = new FormData(); + body.set('a', '1'); + const res = new Response(body); + const req = new Request(url, { method, body }); + body.set('a', '2'); + assert_true((await res.formData()).get('a') === '1'); + assert_true((await req.formData()).get('a') === '1'); +}, 'FormData is cloned'); + +promise_test(async t => { + const body = new URLSearchParams({a: '1'}); + const res = new Response(body); + const req = new Request(url, { method, body }); + body.set('a', '2'); + assert_true((await res.formData()).get('a') === '1'); + assert_true((await req.formData()).get('a') === '1'); +}, 'URLSearchParams is cloned'); + +promise_test(async t => { + const body = new Uint8Array([97]); // a + const res = new Response(body); + const req = new Request(url, { method, body }); + body[0] = 98; // b + assert_true(await res.text() === 'a'); + assert_true(await req.text() === 'a'); +}, 'TypedArray is cloned'); + +promise_test(async t => { + const body = new Uint8Array([97]); // a + const res = new Response(body.buffer); + const req = new Request(url, { method, body: body.buffer }); + body[0] = 98; // b + assert_true(await res.text() === 'a'); + assert_true(await req.text() === 'a'); +}, 'ArrayBuffer is cloned'); + +promise_test(async t => { + const body = new Blob(['a']); + const res = new Response(body); + const req = new Request(url, { method, body }); + assert_true(await res.blob() !== body); + assert_true(await req.blob() !== body); +}, 'Blob is cloned'); diff --git a/test/fixtures/wpt/fetch/api/body/formdata.any.js b/test/fixtures/wpt/fetch/api/body/formdata.any.js new file mode 100644 index 0000000..6733fa0 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/body/formdata.any.js @@ -0,0 +1,25 @@ +promise_test(async t => { + const res = new Response(new FormData()); + const fd = await res.formData(); + assert_true(fd instanceof FormData); +}, 'Consume empty response.formData() as FormData'); + +promise_test(async t => { + const req = new Request('about:blank', { + method: 'POST', + body: new FormData() + }); + const fd = await req.formData(); + assert_true(fd instanceof FormData); +}, 'Consume empty request.formData() as FormData'); + +promise_test(async t => { + let formdata = new FormData(); + formdata.append('foo', new Blob([JSON.stringify({ bar: "baz", })], { type: "application/json" })); + let blob = await new Response(formdata).blob(); + let body = await blob.text(); + blob = new Blob([body.toLowerCase()], { type: blob.type.toLowerCase() }); + let formdataWithLowercaseBody = await new Response(blob).formData(); + assert_true(formdataWithLowercaseBody.has("foo")); + assert_equals(formdataWithLowercaseBody.get("foo").type, "application/json"); +}, 'Consume multipart/form-data headers case-insensitively'); diff --git a/test/fixtures/wpt/fetch/api/body/mime-type.any.js b/test/fixtures/wpt/fetch/api/body/mime-type.any.js new file mode 100644 index 0000000..67c9af7 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/body/mime-type.any.js @@ -0,0 +1,127 @@ +[ + () => new Request("about:blank", { headers: { "Content-Type": "text/plain" } }), + () => new Response("", { headers: { "Content-Type": "text/plain" } }) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "text/plain"); + const newMIMEType = "test/test"; + bodyContainer.headers.set("Content-Type", newMIMEType); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, newMIMEType); + }, `${bodyContainer.constructor.name}: overriding explicit Content-Type`); +}); + +[ + () => new Request("about:blank", { body: new URLSearchParams(), method: "POST" }), + () => new Response(new URLSearchParams()), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "application/x-www-form-urlencoded;charset=UTF-8"); + bodyContainer.headers.delete("Content-Type"); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, ""); + }, `${bodyContainer.constructor.name}: removing implicit Content-Type`); +}); + +[ + () => new Request("about:blank", { body: new ArrayBuffer(), method: "POST" }), + () => new Response(new ArrayBuffer()), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), null); + const newMIMEType = "test/test"; + bodyContainer.headers.set("Content-Type", newMIMEType); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, newMIMEType); + }, `${bodyContainer.constructor.name}: setting missing Content-Type`); +}); + +[ + () => new Request("about:blank", { method: "POST" }), + () => new Response(), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, ""); + }, `${bodyContainer.constructor.name}: MIME type for Blob from empty body`); +}); + +[ + () => new Request("about:blank", { method: "POST", headers: [["Content-Type", "Mytext/Plain"]] }), + () => new Response("", { headers: [["Content-Type", "Mytext/Plain"]] }) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, 'mytext/plain'); + }, `${bodyContainer.constructor.name}: MIME type for Blob from empty body with Content-Type`); +}); + +[ + () => new Request("about:blank", { body: new Blob([""]), method: "POST" }), + () => new Response(new Blob([""])) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, ""); + assert_equals(bodyContainer.headers.get("Content-Type"), null); + }, `${bodyContainer.constructor.name}: MIME type for Blob`); +}); + +[ + () => new Request("about:blank", { body: new Blob([""], { type: "Text/Plain" }), method: "POST" }), + () => new Response(new Blob([""], { type: "Text/Plain" })) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + const blob = await bodyContainer.blob(); + assert_equals(blob.type, "text/plain"); + assert_equals(bodyContainer.headers.get("Content-Type"), "text/plain"); + }, `${bodyContainer.constructor.name}: MIME type for Blob with non-empty type`); +}); + +[ + () => new Request("about:blank", { method: "POST", body: new Blob([""], { type: "Text/Plain" }), headers: [["Content-Type", "Text/Html"]] }), + () => new Response(new Blob([""], { type: "Text/Plain" }, { headers: [["Content-Type", "Text/Html"]] })) +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + const cloned = bodyContainer.clone(); + promise_test(async t => { + const blobs = [await bodyContainer.blob(), await cloned.blob()]; + assert_equals(blobs[0].type, "text/html"); + assert_equals(blobs[1].type, "text/html"); + assert_equals(bodyContainer.headers.get("Content-Type"), "Text/Html"); + assert_equals(cloned.headers.get("Content-Type"), "Text/Html"); + }, `${bodyContainer.constructor.name}: Extract a MIME type with clone`); +}); + +[ + () => new Request("about:blank", { body: new Blob([], { type: "text/plain" }), method: "POST", headers: [["Content-Type", "text/html"]] }), + () => new Response(new Blob([], { type: "text/plain" }), { headers: [["Content-Type", "text/html"]] }), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "text/html"); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, "text/html"); + }, `${bodyContainer.constructor.name}: Content-Type in headers wins Blob"s type`); +}); + +[ + () => new Request("about:blank", { body: new Blob([], { type: "text/plain" }), method: "POST" }), + () => new Response(new Blob([], { type: "text/plain" })), +].forEach(bodyContainerCreator => { + const bodyContainer = bodyContainerCreator(); + promise_test(async t => { + assert_equals(bodyContainer.headers.get("Content-Type"), "text/plain"); + const newMIMEType = "text/html"; + bodyContainer.headers.set("Content-Type", newMIMEType); + const blob = await bodyContainer.blob(); + assert_equals(blob.type, newMIMEType); + }, `${bodyContainer.constructor.name}: setting missing Content-Type in headers and it wins Blob"s type`); +}); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-basic.any.js b/test/fixtures/wpt/fetch/api/cors/cors-basic.any.js new file mode 100644 index 0000000..95de0af --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-basic.any.js @@ -0,0 +1,43 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +const { + HTTPS_ORIGIN, + HTTP_ORIGIN_WITH_DIFFERENT_PORT, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + HTTPS_REMOTE_ORIGIN, +} = get_host_info(); + +function cors(desc, origin) { + const url = `${origin}${dirname(location.pathname)}${RESOURCES_DIR}top.txt`; + const urlAllowCors = `${url}?pipe=header(Access-Control-Allow-Origin,*)`; + + promise_test((test) => { + return fetch(urlAllowCors, {'mode': 'no-cors'}).then((resp) => { + assert_equals(resp.status, 0, "Opaque filter: status is 0"); + assert_equals(resp.statusText, "", "Opaque filter: statusText is \"\""); + assert_equals(resp.type , "opaque", "Opaque filter: response's type is opaque"); + return resp.text().then((value) => { + assert_equals(value, "", "Opaque response should have an empty body"); + }); + }); + }, `${desc} [no-cors mode]`); + + promise_test((test) => { + return promise_rejects_js(test, TypeError, fetch(url, {'mode': 'cors'})); + }, `${desc} [server forbid CORS]`); + + promise_test((test) => { + return fetch(urlAllowCors, {'mode': 'cors'}).then((resp) => { + assert_equals(resp.status, 200, "Fetch's response's status is 200"); + assert_equals(resp.type , "cors", "CORS response's type is cors"); + }); + }, `${desc} [cors mode]`); +} + +cors('Same domain different port', HTTP_ORIGIN_WITH_DIFFERENT_PORT); +cors('Same domain different protocol different port', HTTPS_ORIGIN); +cors('Cross domain basic usage', HTTP_REMOTE_ORIGIN); +cors('Cross domain different port', HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT); +cors('Cross domain different protocol', HTTPS_REMOTE_ORIGIN); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-cookies-redirect.any.js b/test/fixtures/wpt/fetch/api/cors/cors-cookies-redirect.any.js new file mode 100644 index 0000000..f5217b4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-cookies-redirect.any.js @@ -0,0 +1,49 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +var redirectUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var urlSetCookies1 = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; +var urlSetCookies2 = get_host_info().HTTP_ORIGIN_WITH_DIFFERENT_PORT + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; +var urlCheckCookies = get_host_info().HTTP_ORIGIN_WITH_DIFFERENT_PORT + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=cookie"; + +var urlSetCookiesParameters = "?pipe=header(Access-Control-Allow-Origin," + location.origin + ")"; +urlSetCookiesParameters += "|header(Access-Control-Allow-Credentials,true)"; + +urlSetCookiesParameters1 = urlSetCookiesParameters + "|header(Set-Cookie,a=1)"; +urlSetCookiesParameters2 = urlSetCookiesParameters + "|header(Set-Cookie,a=2)"; + +urlClearCookiesParameters1 = urlSetCookiesParameters + "|header(Set-Cookie,a=1%3B%20max-age=0)"; +urlClearCookiesParameters2 = urlSetCookiesParameters + "|header(Set-Cookie,a=2%3B%20max-age=0)"; + +promise_test(async (test) => { + await fetch(urlSetCookies1 + urlSetCookiesParameters1, {"credentials": "include", "mode": "cors"}); + await fetch(urlSetCookies2 + urlSetCookiesParameters2, {"credentials": "include", "mode": "cors"}); +}, "Set cookies"); + +function doTest(usePreflight) { + promise_test(async (test) => { + var url = redirectUrl; + var uuid_token = token(); + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&redirect_status=301"; + urlParameters += "&location=" + encodeURIComponent(urlCheckCookies); + urlParameters += "&allow_headers=a&headers=Cookie"; + headers = []; + if (usePreflight) + headers.push(["a", "b"]); + + var requestInit = {"credentials": "include", "mode": "cors", "headers": headers}; + var response = await fetch(url + urlParameters, requestInit); + + assert_equals(response.headers.get("x-request-cookie") , "a=2", "Request includes cookie(s)"); + }, "Testing credentials after cross-origin redirection with CORS and " + (usePreflight ? "" : "no ") + "preflight"); +} + +doTest(false); +doTest(true); + +promise_test(async (test) => { + await fetch(urlSetCookies1 + urlClearCookiesParameters1, {"credentials": "include", "mode": "cors"}); + await fetch(urlSetCookies2 + urlClearCookiesParameters2, {"credentials": "include", "mode": "cors"}); +}, "Clean cookies"); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-cookies.any.js b/test/fixtures/wpt/fetch/api/cors/cors-cookies.any.js new file mode 100644 index 0000000..8c666e4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-cookies.any.js @@ -0,0 +1,56 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsCookies(desc, baseURL1, baseURL2, credentialsMode, cookies) { + var urlSetCookie = baseURL1 + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; + var urlCheckCookies = baseURL2 + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=cookie"; + //enable cors with credentials + var urlParameters = "?pipe=header(Access-Control-Allow-Origin," + location.origin + ")"; + urlParameters += "|header(Access-Control-Allow-Credentials,true)"; + + var urlCleanParameters = "?pipe=header(Access-Control-Allow-Origin," + location.origin + ")"; + urlCleanParameters += "|header(Access-Control-Allow-Credentials,true)"; + if (cookies) { + urlParameters += "|header(Set-Cookie,"; + urlParameters += cookies.join(",True)|header(Set-Cookie,") + ",True)"; + urlCleanParameters += "|header(Set-Cookie,"; + urlCleanParameters += cookies.join("%3B%20max-age=0,True)|header(Set-Cookie,") + "%3B%20max-age=0,True)"; + } + + var requestInit = {"credentials": credentialsMode, "mode": "cors"}; + + promise_test(function(test){ + return fetch(urlSetCookie + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + //check cookies sent + return fetch(urlCheckCookies, requestInit); + }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_false(resp.headers.has("Cookie") , "Cookie header is not exposed in response"); + if (credentialsMode === "include" && baseURL1 === baseURL2) { + assert_equals(resp.headers.get("x-request-cookie") , cookies.join("; "), "Request includes cookie(s)"); + } + else { + assert_false(resp.headers.has("x-request-cookie") , "Request should have no cookie"); + } + //clean cookies + return fetch(urlSetCookie + urlCleanParameters, {"credentials": "include"}); + }).catch(function(e) { + return fetch(urlSetCookie + urlCleanParameters, {"credentials": "include"}).then(function(resp) { + throw e; + }) + }); + }, desc); +} + +var local = get_host_info().HTTP_ORIGIN; +var remote = get_host_info().HTTP_REMOTE_ORIGIN; +// FIXME: otherRemote might not be accessible on some test environments. +var otherRemote = local.replace("http://", "http://www."); + +corsCookies("Omit mode: no cookie sent", local, local, "omit", ["g=7"]); +corsCookies("Include mode: 1 cookie", remote, remote, "include", ["a=1"]); +corsCookies("Include mode: local cookies are not sent with remote request", local, remote, "include", ["c=3"]); +corsCookies("Include mode: remote cookies are not sent with local request", remote, local, "include", ["d=4"]); +corsCookies("Same-origin mode: cookies are discarded in cors request", remote, remote, "same-origin", ["f=6"]); +corsCookies("Include mode: remote cookies are not sent with other remote request", remote, otherRemote, "include", ["e=5"]); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-expose-star.sub.any.js b/test/fixtures/wpt/fetch/api/cors/cors-expose-star.sub.any.js new file mode 100644 index 0000000..340e99a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-expose-star.sub.any.js @@ -0,0 +1,41 @@ +// META: script=../resources/utils.js + +const url = "http://{{host}}:{{ports[http][1]}}" + dirname(location.pathname) + RESOURCES_DIR + "top.txt", + sharedHeaders = "?pipe=header(Access-Control-Expose-Headers,*)|header(Test,X)|header(Set-Cookie,X)|header(*,whoa)|" + +promise_test(() => { + const headers = "header(Access-Control-Allow-Origin,*)" + return fetch(url + sharedHeaders + headers).then(resp => { + assert_equals(resp.status, 200) + assert_equals(resp.type , "cors") + assert_equals(resp.headers.get("test"), "X") + assert_equals(resp.headers.get("set-cookie"), null) + assert_equals(resp.headers.get("*"), "whoa") + }) +}, "Basic Access-Control-Expose-Headers: * support") + +promise_test(() => { + const origin = location.origin, // assuming an ASCII origin + headers = "header(Access-Control-Allow-Origin," + origin + ")|header(Access-Control-Allow-Credentials,true)" + return fetch(url + sharedHeaders + headers, { credentials:"include" }).then(resp => { + assert_equals(resp.status, 200) + assert_equals(resp.type , "cors") + assert_equals(resp.headers.get("content-type"), "text/plain") // safelisted + assert_equals(resp.headers.get("test"), null) + assert_equals(resp.headers.get("set-cookie"), null) + assert_equals(resp.headers.get("*"), "whoa") + }) +}, "* for credentialed fetches only matches literally") + +promise_test(() => { + const headers = "header(Access-Control-Allow-Origin,*)|header(Access-Control-Expose-Headers,set-cookie\\,*)" + return fetch(url + sharedHeaders + headers).then(resp => { + assert_equals(resp.status, 200) + assert_equals(resp.type , "cors") + assert_equals(resp.headers.get("test"), "X") + assert_equals(resp.headers.get("set-cookie"), null) + assert_equals(resp.headers.get("*"), "whoa") + }) +}, "* can be one of several values") + +done(); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-filtering.sub.any.js b/test/fixtures/wpt/fetch/api/cors/cors-filtering.sub.any.js new file mode 100644 index 0000000..5f94924 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-filtering.sub.any.js @@ -0,0 +1,65 @@ +// META: script=../resources/utils.js + +function corsFilter(corsUrl, headerName, headerValue, isFiltered) { + var url = corsUrl + "?pipe=header(" + headerName + "," + encodeURIComponent(headerValue) +")|header(Access-Control-Allow-Origin,*)"; + promise_test(function(test) { + return fetch(url).then(function(resp) { + assert_equals(resp.status, 200, "Fetch success with code 200"); + assert_equals(resp.type , "cors", "CORS fetch's response has cors type"); + if (!isFiltered) { + assert_equals(resp.headers.get(headerName), headerValue, + headerName + " header should be included in response with value: " + headerValue); + } else { + assert_false(resp.headers.has(headerName), "UA should exclude " + headerName + " header from response"); + } + }); + }, "CORS filter on " + headerName + " header"); +} + +function corsExposeFilter(corsUrl, headerName, headerValue, isForbidden, withCredentials) { + var url = corsUrl + "?pipe=header(" + headerName + "," + encodeURIComponent(headerValue) +")|" + + "header(Access-Control-Allow-Origin, http://{{host}}:{{ports[http][0]}})" + + "header(Access-Control-Allow-Credentials, true)" + + "header(Access-Control-Expose-Headers," + headerName + ")"; + + var title = "CORS filter on " + headerName + " header, header is " + (isForbidden ? "forbidden" : "exposed"); + if (withCredentials) + title+= "(credentials = include)"; + promise_test(function(test) { + return fetch(new Request(url, { credentials: withCredentials ? "include" : "omit" })).then(function(resp) { + assert_equals(resp.status, 200, "Fetch success with code 200"); + assert_equals(resp.type , "cors", "CORS fetch's response has cors type"); + if (!isForbidden) { + assert_equals(resp.headers.get(headerName), headerValue, + headerName + " header should be included in response with value: " + headerValue); + } else { + assert_false(resp.headers.has(headerName), "UA should exclude " + headerName + " header from response"); + } + }); + }, title); +} + +var url = "http://{{host}}:{{ports[http][1]}}" + dirname(location.pathname) + RESOURCES_DIR + "top.txt"; + +corsFilter(url, "Cache-Control", "no-cache", false); +corsFilter(url, "Content-Language", "fr", false); +corsFilter(url, "Content-Type", "text/html", false); +corsFilter(url, "Expires","04 May 1988 22:22:22 GMT" , false); +corsFilter(url, "Last-Modified", "04 May 1988 22:22:22 GMT", false); +corsFilter(url, "Pragma", "no-cache", false); +corsFilter(url, "Content-Length", "3" , false); // top.txt contains "top" + +corsFilter(url, "Age", "27", true); +corsFilter(url, "Server", "wptServe" , true); +corsFilter(url, "Warning", "Mind the gap" , true); +corsFilter(url, "Set-Cookie", "name=value; max-age=0", true); +corsFilter(url, "Set-Cookie2", "name=value; max-age=0", true); + +corsExposeFilter(url, "Age", "27", false); +corsExposeFilter(url, "Server", "wptServe" , false); +corsExposeFilter(url, "Warning", "Mind the gap" , false); + +corsExposeFilter(url, "Set-Cookie", "name=value; max-age=0" , true); +corsExposeFilter(url, "Set-Cookie2", "name=value; max-age=0" , true); +corsExposeFilter(url, "Set-Cookie", "name=value; max-age=0" , true, true); +corsExposeFilter(url, "Set-Cookie2", "name=value; max-age=0" , true, true); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-keepalive.any.js b/test/fixtures/wpt/fetch/api/cors/cors-keepalive.any.js new file mode 100644 index 0000000..f54bf4f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-keepalive.any.js @@ -0,0 +1,116 @@ +// META: global=window +// META: timeout=long +// META: title=Fetch API: keepalive handling +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js +// META: script=../resources/utils.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTPS_ORIGIN, + HTTP_ORIGIN_WITH_DIFFERENT_PORT, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + HTTPS_REMOTE_ORIGIN, +} = get_host_info(); + +/** + * Tests to cover the basic behaviors of keepalive + cors/no-cors mode requests + * to different `origin` when the initiator document is still alive. They should + * behave the same as without setting keepalive. + */ +function keepaliveCorsBasicTest(desc, origin) { + const url = `${origin}${dirname(location.pathname)}${RESOURCES_DIR}top.txt`; + const urlAllowCors = `${url}?pipe=header(Access-Control-Allow-Origin,*)`; + + promise_test((test) => { + return fetch(urlAllowCors, {keepalive: true, 'mode': 'no-cors'}) + .then((resp) => { + assert_equals(resp.status, 0, 'Opaque filter: status is 0'); + assert_equals(resp.statusText, '', 'Opaque filter: statusText is ""'); + assert_equals( + resp.type, 'opaque', 'Opaque filter: response\'s type is opaque'); + return resp.text().then((value) => { + assert_equals( + value, '', 'Opaque response should have an empty body'); + }); + }); + }, `${desc} [no-cors mode]`); + + promise_test((test) => { + return promise_rejects_js( + test, TypeError, fetch(url, {keepalive: true, 'mode': 'cors'})); + }, `${desc} [cors mode, server forbid CORS]`); + + promise_test((test) => { + return fetch(urlAllowCors, {keepalive: true, 'mode': 'cors'}) + .then((resp) => { + assert_equals(resp.status, 200, 'Fetch\'s response\'s status is 200'); + assert_equals(resp.type, 'cors', 'CORS response\'s type is cors'); + }); + }, `${desc} [cors mode]`); +} + +keepaliveCorsBasicTest( + `[keepalive] Same domain different port`, HTTP_ORIGIN_WITH_DIFFERENT_PORT); +keepaliveCorsBasicTest( + `[keepalive] Same domain different protocol different port`, HTTPS_ORIGIN); +keepaliveCorsBasicTest( + `[keepalive] Cross domain basic usage`, HTTP_REMOTE_ORIGIN); +keepaliveCorsBasicTest( + `[keepalive] Cross domain different port`, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT); +keepaliveCorsBasicTest( + `[keepalive] Cross domain different protocol`, HTTPS_REMOTE_ORIGIN); + +/** + * In a same-site iframe, and in `unload` event handler, test to fetch + * a keepalive URL that involves in different cors modes. + */ +function keepaliveCorsInUnloadTest(description, origin, method) { + const evt = 'unload'; + for (const mode of ['no-cors', 'cors']) { + for (const disallowCrossOrigin of [false, true]) { + const desc = `${description} ${method} request in ${evt} [${mode} mode` + + (disallowCrossOrigin ? ']' : ', server forbid CORS]'); + const expectTokenExist = !disallowCrossOrigin || mode === 'no-cors'; + promise_test(async (test) => { + const token1 = token(); + const iframe = document.createElement('iframe'); + iframe.src = getKeepAliveIframeUrl(token1, method, { + frameOrigin: '', + requestOrigin: origin, + sendOn: evt, + mode: mode, + disallowCrossOrigin + }); + document.body.appendChild(iframe); + await iframeLoaded(iframe); + iframe.remove(); + assert_equals(await getTokenFromMessage(), token1); + + assertStashedTokenAsync(desc, token1, {expectTokenExist}); + }, `${desc}; setting up`); + } + } +} + +for (const method of ['GET', 'POST']) { + keepaliveCorsInUnloadTest( + '[keepalive] Same domain different port', HTTP_ORIGIN_WITH_DIFFERENT_PORT, + method); + keepaliveCorsInUnloadTest( + `[keepalive] Same domain different protocol different port`, HTTPS_ORIGIN, + method); + keepaliveCorsInUnloadTest( + `[keepalive] Cross domain basic usage`, HTTP_REMOTE_ORIGIN, method); + keepaliveCorsInUnloadTest( + `[keepalive] Cross domain different port`, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, method); + keepaliveCorsInUnloadTest( + `[keepalive] Cross domain different protocol`, HTTPS_REMOTE_ORIGIN, + method); +} diff --git a/test/fixtures/wpt/fetch/api/cors/cors-multiple-origins.sub.any.js b/test/fixtures/wpt/fetch/api/cors/cors-multiple-origins.sub.any.js new file mode 100644 index 0000000..b3abb92 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-multiple-origins.sub.any.js @@ -0,0 +1,22 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function corsMultipleOrigins(originList) { + var urlParameters = "?origin=" + encodeURIComponent(originList.join(", ")); + var url = "http://{{host}}:{{ports[http][1]}}" + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters)); + }, "Listing multiple origins is illegal: " + originList); +} +/* Actual origin */ +var origin = "http://{{host}}:{{ports[http][0]}}"; + +corsMultipleOrigins(["\"\"", "http://example.com", origin]); +corsMultipleOrigins(["\"\"", "http://example.com", "*"]); +corsMultipleOrigins(["\"\"", origin, origin]); +corsMultipleOrigins(["*", "http://example.com", "*"]); +corsMultipleOrigins(["*", "http://example.com", origin]); +corsMultipleOrigins(["", "http://example.com", "https://example2.com"]); + +done(); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-no-preflight.any.js b/test/fixtures/wpt/fetch/api/cors/cors-no-preflight.any.js new file mode 100644 index 0000000..7a0269a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-no-preflight.any.js @@ -0,0 +1,41 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsNoPreflight(desc, baseURL, method, headerName, headerValue) { + + var uuid_token = token(); + var url = baseURL + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + var requestInit = {"mode": "cors", "method": method, "headers":{}}; + if (headerName) + requestInit["headers"][headerName] = headerValue; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "0", "No preflight request has been made"); + }); + }); + }, desc); +} + +var host_info = get_host_info(); + +corsNoPreflight("Cross domain basic usage [GET]", host_info.HTTP_REMOTE_ORIGIN, "GET"); +corsNoPreflight("Same domain different port [GET]", host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT, "GET"); +corsNoPreflight("Cross domain different port [GET]", host_info.HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, "GET"); +corsNoPreflight("Cross domain different protocol [GET]", host_info.HTTPS_REMOTE_ORIGIN, "GET"); +corsNoPreflight("Same domain different protocol different port [GET]", host_info.HTTPS_ORIGIN, "GET"); +corsNoPreflight("Cross domain [POST]", host_info.HTTP_REMOTE_ORIGIN, "POST"); +corsNoPreflight("Cross domain [HEAD]", host_info.HTTP_REMOTE_ORIGIN, "HEAD"); +corsNoPreflight("Cross domain [GET] [Accept: */*]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Accept", "*/*"); +corsNoPreflight("Cross domain [GET] [Accept-Language: fr]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Accept-Language", "fr"); +corsNoPreflight("Cross domain [GET] [Content-Language: fr]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Language", "fr"); +corsNoPreflight("Cross domain [GET] [Content-Type: application/x-www-form-urlencoded]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "application/x-www-form-urlencoded"); +corsNoPreflight("Cross domain [GET] [Content-Type: multipart/form-data]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "multipart/form-data"); +corsNoPreflight("Cross domain [GET] [Content-Type: text/plain]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "text/plain"); +corsNoPreflight("Cross domain [GET] [Content-Type: text/plain;charset=utf-8]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "text/plain;charset=utf-8"); +corsNoPreflight("Cross domain [GET] [Content-Type: Text/Plain;charset=utf-8]", host_info.HTTP_REMOTE_ORIGIN, "GET" , "Content-Type", "Text/Plain;charset=utf-8"); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-origin.any.js b/test/fixtures/wpt/fetch/api/cors/cors-origin.any.js new file mode 100644 index 0000000..30a02d9 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-origin.any.js @@ -0,0 +1,51 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +/* If origin is undefined, it is set to fetched url's origin*/ +function corsOrigin(desc, baseURL, method, origin, shouldPass) { + if (!origin) + origin = baseURL; + + var uuid_token = token(); + var urlParameters = "?token=" + uuid_token + "&max_age=0&origin=" + encodeURIComponent(origin) + "&allow_methods=" + method; + var url = baseURL + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + var requestInit = {"mode": "cors", "method": method}; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + if (shouldPass) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + }); + } else { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + } + }); + }, desc); + +} + +var host_info = get_host_info(); + +/* Actual origin */ +var origin = host_info.HTTP_ORIGIN; + +corsOrigin("Cross domain different subdomain [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "GET", origin, true); +corsOrigin("Cross domain different subdomain [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "GET", undefined, false); +corsOrigin("Same domain different port [origin OK]", host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT, "GET", origin, true); +corsOrigin("Same domain different port [origin KO]", host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT, "GET", undefined, false); +corsOrigin("Cross domain different port [origin OK]", host_info.HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, "GET", origin, true); +corsOrigin("Cross domain different port [origin KO]", host_info.HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, "GET", undefined, false); +corsOrigin("Cross domain different protocol [origin OK]", host_info.HTTPS_REMOTE_ORIGIN, "GET", origin, true); +corsOrigin("Cross domain different protocol [origin KO]", host_info.HTTPS_REMOTE_ORIGIN, "GET", undefined, false); +corsOrigin("Same domain different protocol different port [origin OK]", host_info.HTTPS_ORIGIN, "GET", origin, true); +corsOrigin("Same domain different protocol different port [origin KO]", host_info.HTTPS_ORIGIN, "GET", undefined, false); +corsOrigin("Cross domain [POST] [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "POST", origin, true); +corsOrigin("Cross domain [POST] [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "POST", undefined, false); +corsOrigin("Cross domain [HEAD] [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "HEAD", origin, true); +corsOrigin("Cross domain [HEAD] [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "HEAD", undefined, false); +corsOrigin("CORS preflight [PUT] [origin OK]", host_info.HTTP_REMOTE_ORIGIN, "PUT", origin, true); +corsOrigin("CORS preflight [PUT] [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "PUT", undefined, false); +corsOrigin("Allowed origin: \"\" [origin KO]", host_info.HTTP_REMOTE_ORIGIN, "GET", "" , false); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-cache.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-cache.any.js new file mode 100644 index 0000000..ce6a169 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-cache.any.js @@ -0,0 +1,46 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +var cors_url = get_host_info().HTTP_REMOTE_ORIGIN + + dirname(location.pathname) + + RESOURCES_DIR + + "preflight.py"; + +promise_test((test) => { + var uuid_token = token(); + var request_url = + cors_url + "?token=" + uuid_token + "&max_age=12000&allow_methods=POST" + + "&allow_headers=x-test-header"; + return fetch(cors_url + "?token=" + uuid_token + "&clear-stash") + .then(() => { + return fetch( + new Request(request_url, + { + mode: "cors", + method: "POST", + headers: [["x-test-header", "test1"]] + })); + }) + .then((resp) => { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + return fetch(cors_url + "?token=" + uuid_token + "&clear-stash"); + }) + .then((res) => res.text()) + .then((txt) => { + assert_equals(txt, "1", "Server stash must be cleared."); + return fetch( + new Request(request_url, + { + mode: "cors", + method: "POST", + headers: [["x-test-header", "test2"]] + })); + }) + .then((resp) => { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "0", "Preflight request has not been made"); + return fetch(cors_url + "?token=" + uuid_token + "&clear-stash"); + }); +}); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js new file mode 100644 index 0000000..b2747cc --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-not-cors-safelisted.any.js @@ -0,0 +1,19 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=resources/corspreflight.js + +const corsURL = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +promise_test(() => fetch("resources/not-cors-safelisted.json").then(res => res.json().then(runTests)), "Loading data…"); + +function runTests(testArray) { + testArray.forEach(testItem => { + const [headerName, headerValue] = testItem; + corsPreflight("Need CORS-preflight for " + headerName + "/" + headerValue + " header", + corsURL, + "GET", + true, + [[headerName, headerValue]]); + }); +} diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-redirect.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-redirect.any.js new file mode 100644 index 0000000..15f7659 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-redirect.any.js @@ -0,0 +1,37 @@ +// META: global=window,worker +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsPreflightRedirect(desc, redirectUrl, redirectLocation, redirectStatus, redirectPreflight) { + var uuid_token = token(); + var url = redirectUrl; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + if (redirectPreflight) + urlParameters += "&redirect_preflight"; + var requestInit = {"mode": "cors", "redirect": "follow"}; + + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + urlParameters += "&allow_headers=x-force-preflight"; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + }); + }, desc); +} + +var redirectUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var locationUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +for (var code of [301, 302, 303, 307, 308]) { + /* preflight should not follow the redirection */ + corsPreflightRedirect("Redirection " + code + " on preflight failed", redirectUrl, locationUrl, code, true); + /* preflight is done before redirection: preflight force redirect to error */ + corsPreflightRedirect("Redirection " + code + " after preflight failed", redirectUrl, locationUrl, code, false); +} diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-referrer.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-referrer.any.js new file mode 100644 index 0000000..5df9fcf --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-referrer.any.js @@ -0,0 +1,51 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsPreflightReferrer(desc, corsUrl, referrerPolicy, referrer, expectedReferrer) { + var uuid_token = token(); + var url = corsUrl; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + var requestInit = {"mode": "cors", "referrerPolicy": referrerPolicy}; + + if (referrer) + requestInit.referrer = referrer; + + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + urlParameters += "&allow_headers=x-force-preflight"; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + assert_equals(resp.headers.get("x-preflight-referrer"), expectedReferrer, "Preflight's referrer is correct"); + assert_equals(resp.headers.get("x-referrer"), expectedReferrer, "Request's referrer is correct"); + assert_equals(resp.headers.get("x-control-request-headers"), "", "Access-Control-Allow-Headers value"); + }); + }); + }, desc + " and referrer: " + (referrer ? "'" + referrer + "'" : "default")); +} + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; +var origin = get_host_info().HTTP_ORIGIN + "/"; + +corsPreflightReferrer("Referrer policy: no-referrer", corsUrl, "no-referrer", undefined, ""); +corsPreflightReferrer("Referrer policy: no-referrer", corsUrl, "no-referrer", "myreferrer", ""); + +corsPreflightReferrer("Referrer policy: \"\"", corsUrl, "", undefined, origin); +corsPreflightReferrer("Referrer policy: \"\"", corsUrl, "", "myreferrer", origin); + +corsPreflightReferrer("Referrer policy: no-referrer-when-downgrade", corsUrl, "no-referrer-when-downgrade", undefined, location.toString()) +corsPreflightReferrer("Referrer policy: no-referrer-when-downgrade", corsUrl, "no-referrer-when-downgrade", "myreferrer", new URL("myreferrer", location).toString()); + +corsPreflightReferrer("Referrer policy: origin", corsUrl, "origin", undefined, origin); +corsPreflightReferrer("Referrer policy: origin", corsUrl, "origin", "myreferrer", origin); + +corsPreflightReferrer("Referrer policy: origin-when-cross-origin", corsUrl, "origin-when-cross-origin", undefined, origin); +corsPreflightReferrer("Referrer policy: origin-when-cross-origin", corsUrl, "origin-when-cross-origin", "myreferrer", origin); + +corsPreflightReferrer("Referrer policy: unsafe-url", corsUrl, "unsafe-url", undefined, location.toString()); +corsPreflightReferrer("Referrer policy: unsafe-url", corsUrl, "unsafe-url", "myreferrer", new URL("myreferrer", location).toString()); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-response-validation.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-response-validation.any.js new file mode 100644 index 0000000..718e351 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-response-validation.any.js @@ -0,0 +1,33 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsPreflightResponseValidation(desc, corsUrl, allowHeaders, allowMethods) { + var uuid_token = token(); + var url = corsUrl; + var requestInit = {"mode": "cors"}; + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&allow_headers=x-force-preflight"; + if (allowHeaders) + urlParameters += "," + allowHeaders; + if (allowMethods) + urlParameters += "&allow_methods="+ allowMethods; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(async function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + await promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + + return fetch(url + urlParameters).then(function(resp) { + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + }); + }); + }, desc); +} + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; +corsPreflightResponseValidation("Preflight response with a bad Access-Control-Allow-Headers", corsUrl, "Bad value", null); +corsPreflightResponseValidation("Preflight response with a bad Access-Control-Allow-Methods", corsUrl, null, "Bad value"); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-star.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-star.any.js new file mode 100644 index 0000000..f9fb204 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-star.any.js @@ -0,0 +1,86 @@ +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +const url = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py", + origin = location.origin // assuming an ASCII origin + +function preflightTest(succeeds, withCredentials, allowMethod, allowHeader, useMethod, useHeader) { + return promise_test(t => { + let testURL = url + "?", + requestInit = {} + if (withCredentials) { + testURL += "origin=" + origin + "&" + testURL += "credentials&" + requestInit.credentials = "include" + } + if (useMethod) { + requestInit.method = useMethod + } + if (useHeader.length > 0) { + requestInit.headers = [useHeader] + } + testURL += "allow_methods=" + allowMethod + "&" + testURL += "allow_headers=" + allowHeader + "&" + + if (succeeds) { + return fetch(testURL, requestInit).then(resp => { + assert_equals(resp.headers.get("x-origin"), origin) + }) + } else { + return promise_rejects_js(t, TypeError, fetch(testURL, requestInit)) + } + }, "CORS that " + (succeeds ? "succeeds" : "fails") + " with credentials: " + withCredentials + "; method: " + useMethod + " (allowed: " + allowMethod + "); header: " + useHeader + " (allowed: " + allowHeader + ")") +} + +// "GET" does not pass the case-sensitive method check, but in the safe list. +preflightTest(true, false, "get", "x-test", "GET", ["X-Test", "1"]) +// Headers check is case-insensitive, and "*" works as any for method. +preflightTest(true, false, "*", "x-test", "SUPER", ["X-Test", "1"]) +// "*" works as any only without credentials. +preflightTest(true, false, "*", "*", "OK", ["X-Test", "1"]) +preflightTest(false, true, "*", "*", "OK", ["X-Test", "1"]) +preflightTest(false, true, "*", "", "PUT", []) +preflightTest(false, true, "get", "*", "GET", ["X-Test", "1"]) +preflightTest(false, true, "*", "*", "GET", ["X-Test", "1"]) +// Exact character match works even for "*" with credentials. +preflightTest(true, true, "*", "*", "*", ["*", "1"]) + +// The following methods are upper-cased for init["method"] by +// https://fetch.spec.whatwg.org/#concept-method-normalize +// but not in Access-Control-Allow-Methods response. +// But they are https://fetch.spec.whatwg.org/#cors-safelisted-method, +// CORS anyway passes regardless of the cases. +for (const METHOD of ['GET', 'HEAD', 'POST']) { + const method = METHOD.toLowerCase(); + preflightTest(true, true, METHOD, "*", METHOD, []) + preflightTest(true, true, METHOD, "*", method, []) + preflightTest(true, true, method, "*", METHOD, []) + preflightTest(true, true, method, "*", method, []) +} + +// The following methods are upper-cased for init["method"] by +// https://fetch.spec.whatwg.org/#concept-method-normalize +// but not in Access-Control-Allow-Methods response. +// As they are not https://fetch.spec.whatwg.org/#cors-safelisted-method, +// Access-Control-Allow-Methods should contain upper-cased methods, +// while init["method"] can be either in upper or lower case. +for (const METHOD of ['DELETE', 'PUT']) { + const method = METHOD.toLowerCase(); + preflightTest(true, true, METHOD, "*", METHOD, []) + preflightTest(true, true, METHOD, "*", method, []) + preflightTest(false, true, method, "*", METHOD, []) + preflightTest(false, true, method, "*", method, []) +} + +// "PATCH" is NOT upper-cased in both places because it is not listed in +// https://fetch.spec.whatwg.org/#concept-method-normalize. +// So Access-Control-Allow-Methods value and init["method"] should match +// case-sensitively. +preflightTest(true, true, "PATCH", "*", "PATCH", []) +preflightTest(false, true, "PATCH", "*", "patch", []) +preflightTest(false, true, "patch", "*", "PATCH", []) +preflightTest(true, true, "patch", "*", "patch", []) + +// "Authorization" header can't be wildcarded. +preflightTest(false, false, "*", "*", "POST", ["Authorization", "123"]) +preflightTest(true, false, "*", "*, Authorization", "POST", ["Authorization", "123"]) diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight-status.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight-status.any.js new file mode 100644 index 0000000..a4467a6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight-status.any.js @@ -0,0 +1,37 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +/* Check preflight is ok if status is ok status (200 to 299)*/ +function corsPreflightStatus(desc, corsUrl, preflightStatus) { + var uuid_token = token(); + var url = corsUrl; + var requestInit = {"mode": "cors"}; + /* Force preflight */ + requestInit["headers"] = {"x-force-preflight": ""}; + + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&allow_headers=x-force-preflight"; + urlParameters += "&preflight_status=" + preflightStatus; + + promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + assert_equals(resp.status, 200, "Clean stash response's status is 200"); + if (200 <= preflightStatus && 299 >= preflightStatus) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + }); + } else { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + } + }); + }, desc); +} + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; +for (status of [200, 201, 202, 203, 204, 205, 206, + 300, 301, 302, 303, 304, 305, 306, 307, 308, + 400, 401, 402, 403, 404, 405, + 501, 502, 503, 504, 505]) + corsPreflightStatus("Preflight answered with status " + status, corsUrl, status); diff --git a/test/fixtures/wpt/fetch/api/cors/cors-preflight.any.js b/test/fixtures/wpt/fetch/api/cors/cors-preflight.any.js new file mode 100644 index 0000000..045422f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-preflight.any.js @@ -0,0 +1,62 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=resources/corspreflight.js + +var corsUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +corsPreflight("CORS [DELETE], server allows", corsUrl, "DELETE", true); +corsPreflight("CORS [DELETE], server refuses", corsUrl, "DELETE", false); +corsPreflight("CORS [PUT], server allows", corsUrl, "PUT", true); +corsPreflight("CORS [PUT], server allows, check preflight has user agent", corsUrl + "?checkUserAgentHeaderInPreflight", "PUT", true); +corsPreflight("CORS [PUT], server refuses", corsUrl, "PUT", false); +corsPreflight("CORS [PATCH], server allows", corsUrl, "PATCH", true); +corsPreflight("CORS [PATCH], server refuses", corsUrl, "PATCH", false); +corsPreflight("CORS [patcH], server allows", corsUrl, "patcH", true); +corsPreflight("CORS [patcH], server refuses", corsUrl, "patcH", false); +corsPreflight("CORS [NEW], server allows", corsUrl, "NEW", true); +corsPreflight("CORS [NEW], server refuses", corsUrl, "NEW", false); +corsPreflight("CORS [chicken], server allows", corsUrl, "chicken", true); +corsPreflight("CORS [chicken], server refuses", corsUrl, "chicken", false); + +corsPreflight("CORS [GET] [x-test-header: allowed], server allows", corsUrl, "GET", true, [["x-test-header1", "allowed"]]); +corsPreflight("CORS [GET] [x-test-header: refused], server refuses", corsUrl, "GET", false, [["x-test-header1", "refused"]]); + +var headers = [ + ["x-test-header1", "allowedOrRefused"], + ["x-test-header2", "allowedOrRefused"], + ["X-test-header3", "allowedOrRefused"], + ["x-test-header-b", "allowedOrRefused"], + ["x-test-header-D", "allowedOrRefused"], + ["x-test-header-C", "allowedOrRefused"], + ["x-test-header-a", "allowedOrRefused"], + ["Content-Type", "allowedOrRefused"], +]; +var safeHeaders= [ + ["Accept", "*"], + ["Accept-Language", "bzh"], + ["Content-Language", "eu"], +]; + +corsPreflight("CORS [GET] [several headers], server allows", corsUrl, "GET", true, headers, safeHeaders); +corsPreflight("CORS [GET] [several headers], server refuses", corsUrl, "GET", false, headers, safeHeaders); +corsPreflight("CORS [PUT] [several headers], server allows", corsUrl, "PUT", true, headers, safeHeaders); +corsPreflight("CORS [PUT] [several headers], server refuses", corsUrl, "PUT", false, headers, safeHeaders); + +corsPreflight("CORS [PUT] [only safe headers], server allows", corsUrl, "PUT", true, null, safeHeaders); + +promise_test(async t => { + const url = `${corsUrl}?allow_headers=*`; + await promise_rejects_js(t, TypeError, fetch(url, { + headers: { + authorization: 'foobar' + } + })); +}, '"authorization" should not be covered by the wildcard symbol'); + +promise_test(async t => { + const url = `${corsUrl}?allow_headers=authorization`; + await fetch(url, { headers: { + authorization: 'foobar' + }}); +}, '"authorization" should be covered by "authorization"'); \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/cors/cors-redirect-credentials.any.js b/test/fixtures/wpt/fetch/api/cors/cors-redirect-credentials.any.js new file mode 100644 index 0000000..2aff313 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-redirect-credentials.any.js @@ -0,0 +1,52 @@ +// META: timeout=long +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsRedirectCredentials(desc, redirectUrl, redirectLocation, redirectStatus, locationCredentials) { + var url = redirectUrl + var urlParameters = "?redirect_status=" + redirectStatus; + urlParameters += "&location=" + redirectLocation.replace("://", "://" + locationCredentials + "@"); + + var requestInit = {"mode": "cors", "redirect": "follow"}; + + promise_test(t => { + const result = fetch(url + urlParameters, requestInit) + if(locationCredentials === "") { + return result; + } else { + return promise_rejects_js(t, TypeError, result); + } + }, desc); +} + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var preflightPath = dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +var host_info = get_host_info(); + +var localRedirect = host_info.HTTP_ORIGIN + redirPath; +var remoteRedirect = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + redirPath; + +var localLocation = host_info.HTTP_ORIGIN + preflightPath; +var remoteLocation = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + preflightPath; +var remoteLocation2 = host_info.HTTP_REMOTE_ORIGIN + preflightPath; + +for (var code of [301, 302, 303, 307, 308]) { + corsRedirectCredentials("Redirect " + code + " from same origin to remote without user and password", localRedirect, remoteLocation, code, ""); + + corsRedirectCredentials("Redirect " + code + " from same origin to remote with user and password", localRedirect, remoteLocation, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from same origin to remote with user", localRedirect, remoteLocation, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from same origin to remote with password", localRedirect, remoteLocation, code, ":password"); + + corsRedirectCredentials("Redirect " + code + " from remote to same origin with user and password", remoteRedirect, localLocation, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from remote to same origin with user", remoteRedirect, localLocation, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from remote to same origin with password", remoteRedirect, localLocation, code, ":password"); + + corsRedirectCredentials("Redirect " + code + " from remote to same remote with user and password", remoteRedirect, remoteLocation, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from remote to same remote with user", remoteRedirect, remoteLocation, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from remote to same remote with password", remoteRedirect, remoteLocation, code, ":password"); + + corsRedirectCredentials("Redirect " + code + " from remote to another remote with user and password", remoteRedirect, remoteLocation2, code, "user:password"); + corsRedirectCredentials("Redirect " + code + " from remote to another remote with user", remoteRedirect, remoteLocation2, code, "user:"); + corsRedirectCredentials("Redirect " + code + " from remote to another remote with password", remoteRedirect, remoteLocation2, code, ":password"); +} diff --git a/test/fixtures/wpt/fetch/api/cors/cors-redirect-preflight.any.js b/test/fixtures/wpt/fetch/api/cors/cors-redirect-preflight.any.js new file mode 100644 index 0000000..5084817 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-redirect-preflight.any.js @@ -0,0 +1,46 @@ +// META: timeout=long +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsRedirect(desc, redirectUrl, redirectLocation, redirectStatus, expectSuccess) { + var urlBaseParameters = "&redirect_status=" + redirectStatus; + var urlParametersSuccess = urlBaseParameters + "&allow_headers=x-w3c&location=" + encodeURIComponent(redirectLocation + "?allow_headers=x-w3c"); + var urlParametersFailure = urlBaseParameters + "&location=" + encodeURIComponent(redirectLocation); + + var requestInit = {"mode": "cors", "redirect": "follow", "headers" : [["x-w3c", "test"]]}; + + promise_test(function(test) { + var uuid_token = token(); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + return fetch(redirectUrl + "?token=" + uuid_token + "&max_age=0" + urlParametersSuccess, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + }); + }); + }, desc + " (preflight after redirection success case)"); + promise_test(function(test) { + var uuid_token = token(); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + return promise_rejects_js(test, TypeError, fetch(redirectUrl + "?token=" + uuid_token + "&max_age=0" + urlParametersFailure, requestInit)); + }); + }, desc + " (preflight after redirection failure case)"); +} + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var preflightPath = dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +var host_info = get_host_info(); + +var localRedirect = host_info.HTTP_ORIGIN + redirPath; +var remoteRedirect = host_info.HTTP_REMOTE_ORIGIN + redirPath; + +var localLocation = host_info.HTTP_ORIGIN + preflightPath; +var remoteLocation = host_info.HTTP_REMOTE_ORIGIN + preflightPath; +var remoteLocation2 = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + preflightPath; + +for (var code of [301, 302, 303, 307, 308]) { + corsRedirect("Redirect " + code + ": same origin to cors", localRedirect, remoteLocation, code); + corsRedirect("Redirect " + code + ": cors to same origin", remoteRedirect, localLocation, code); + corsRedirect("Redirect " + code + ": cors to another cors", remoteRedirect, remoteLocation2, code); +} diff --git a/test/fixtures/wpt/fetch/api/cors/cors-redirect.any.js b/test/fixtures/wpt/fetch/api/cors/cors-redirect.any.js new file mode 100644 index 0000000..cdf4097 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/cors-redirect.any.js @@ -0,0 +1,42 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function corsRedirect(desc, redirectUrl, redirectLocation, redirectStatus, expectedOrigin) { + var uuid_token = token(); + var url = redirectUrl; + var urlParameters = "?token=" + uuid_token + "&max_age=0"; + urlParameters += "&redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + var requestInit = {"mode": "cors", "redirect": "follow"}; + + return promise_test(function(test) { + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(resp) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "0", "No preflight request has been made"); + assert_equals(resp.headers.get("x-origin"), expectedOrigin, "Origin is correctly set after redirect"); + }); + }); + }, desc); +} + +var redirPath = dirname(location.pathname) + RESOURCES_DIR + "redirect.py"; +var preflightPath = dirname(location.pathname) + RESOURCES_DIR + "preflight.py"; + +var host_info = get_host_info(); + +var localRedirect = host_info.HTTP_ORIGIN + redirPath; +var remoteRedirect = host_info.HTTP_REMOTE_ORIGIN + redirPath; + +var localLocation = host_info.HTTP_ORIGIN + preflightPath; +var remoteLocation = host_info.HTTP_REMOTE_ORIGIN + preflightPath; +var remoteLocation2 = host_info.HTTP_ORIGIN_WITH_DIFFERENT_PORT + preflightPath; + +for (var code of [301, 302, 303, 307, 308]) { + corsRedirect("Redirect " + code + ": cors to same cors", remoteRedirect, remoteLocation, code, location.origin); + corsRedirect("Redirect " + code + ": cors to another cors", remoteRedirect, remoteLocation2, code, "null"); + corsRedirect("Redirect " + code + ": same origin to cors", localRedirect, remoteLocation, code, location.origin); + corsRedirect("Redirect " + code + ": cors to same origin", remoteRedirect, localLocation, code, "null"); +} diff --git a/test/fixtures/wpt/fetch/api/cors/data-url-iframe.html b/test/fixtures/wpt/fetch/api/cors/data-url-iframe.html new file mode 100644 index 0000000..217baa3 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/data-url-iframe.html @@ -0,0 +1,58 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/api/cors/data-url-shared-worker.html b/test/fixtures/wpt/fetch/api/cors/data-url-shared-worker.html new file mode 100644 index 0000000..d69748a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/data-url-shared-worker.html @@ -0,0 +1,53 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/api/cors/data-url-worker.html b/test/fixtures/wpt/fetch/api/cors/data-url-worker.html new file mode 100644 index 0000000..13113e6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/data-url-worker.html @@ -0,0 +1,50 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/api/cors/resources/corspreflight.js b/test/fixtures/wpt/fetch/api/cors/resources/corspreflight.js new file mode 100644 index 0000000..18b8f6d --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/resources/corspreflight.js @@ -0,0 +1,58 @@ +function headerNames(headers) { + let names = []; + for (let header of headers) { + names.push(header[0].toLowerCase()); + } + return names; +} + +/* + Check preflight is done + Control if server allows method and headers and check accordingly + Check control access headers added by UA (for method and headers) +*/ +function corsPreflight(desc, corsUrl, method, allowed, headers, safeHeaders) { + return promise_test(function(test) { + var uuid_token = token(); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token).then(function(response) { + var url = corsUrl + (corsUrl.indexOf("?") === -1 ? "?" : "&"); + var urlParameters = "token=" + uuid_token + "&max_age=0"; + var requestInit = {"mode": "cors", "method": method}; + var requestHeaders = []; + if (headers) + requestHeaders.push.apply(requestHeaders, headers); + if (safeHeaders) + requestHeaders.push.apply(requestHeaders, safeHeaders); + requestInit["headers"] = requestHeaders; + + if (allowed) { + urlParameters += "&allow_methods=" + method + "&control_request_headers"; + if (headers) { + //Make the server allow the headers + urlParameters += "&allow_headers=" + headerNames(headers).join("%20%2C"); + } + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.headers.get("x-did-preflight"), "1", "Preflight request has been made"); + if (headers) { + var actualHeaders = resp.headers.get("x-control-request-headers").toLowerCase().split(","); + for (var i in actualHeaders) + actualHeaders[i] = actualHeaders[i].trim(); + for (var header of headers) + assert_in_array(header[0].toLowerCase(), actualHeaders, "Preflight asked permission for header: " + header); + + let accessControlAllowHeaders = headerNames(headers).sort().join(","); + assert_equals(resp.headers.get("x-control-request-headers"), accessControlAllowHeaders, "Access-Control-Allow-Headers value"); + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token); + } else { + assert_equals(resp.headers.get("x-control-request-headers"), null, "Access-Control-Request-Headers should be omitted") + } + }); + } else { + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)).then(function(){ + return fetch(RESOURCES_DIR + "clean-stash.py?token=" + uuid_token); + }); + } + }); + }, desc); +} diff --git a/test/fixtures/wpt/fetch/api/cors/resources/not-cors-safelisted.json b/test/fixtures/wpt/fetch/api/cors/resources/not-cors-safelisted.json new file mode 100644 index 0000000..945dc0f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/resources/not-cors-safelisted.json @@ -0,0 +1,13 @@ +[ + ["accept", "\""], + ["accept", "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678"], + ["accept-language", "\u0001"], + ["accept-language", "@"], + ["authorization", "basics"], + ["content-language", "\u0001"], + ["content-language", "@"], + ["content-type", "text/html"], + ["content-type", "text/plain; long=0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901"], + ["range", "bytes 0-"], + ["test", "hi"] +] diff --git a/test/fixtures/wpt/fetch/api/cors/sandboxed-iframe.html b/test/fixtures/wpt/fetch/api/cors/sandboxed-iframe.html new file mode 100644 index 0000000..feb9f1f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/cors/sandboxed-iframe.html @@ -0,0 +1,14 @@ + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/crashtests/aborted-fetch-response.https.html b/test/fixtures/wpt/fetch/api/crashtests/aborted-fetch-response.https.html new file mode 100644 index 0000000..fa1ad17 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/crashtests/aborted-fetch-response.https.html @@ -0,0 +1,11 @@ + + diff --git a/test/fixtures/wpt/fetch/api/crashtests/body-window-destroy.html b/test/fixtures/wpt/fetch/api/crashtests/body-window-destroy.html new file mode 100644 index 0000000..646d3c5 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/crashtests/body-window-destroy.html @@ -0,0 +1,11 @@ + + + diff --git a/test/fixtures/wpt/fetch/api/crashtests/huge-fetch.any.js b/test/fixtures/wpt/fetch/api/crashtests/huge-fetch.any.js new file mode 100644 index 0000000..1b09925 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/crashtests/huge-fetch.any.js @@ -0,0 +1,16 @@ +// META: global=window,worker + +'use strict'; + +promise_test(async t => { + const response = await fetch('../resources/huge-response.py'); + const reader = response.body.getReader(); + // Read one chunk just to show willing. + const { value, done } = await reader.read(); + assert_false(done, 'there should be some data'); + assert_greater_than(value.byteLength, 0, 'the chunk should be non-empty'); + // Wait 2 seconds to give it a chance to crash. + await new Promise(resolve => t.step_timeout(resolve, 2000)); + // If we get here without crashing we passed the test. + reader.cancel(); +}, 'fetching a huge cacheable file but not reading it should not crash'); diff --git a/test/fixtures/wpt/fetch/api/crashtests/request.html b/test/fixtures/wpt/fetch/api/crashtests/request.html new file mode 100644 index 0000000..2d21930 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/crashtests/request.html @@ -0,0 +1,8 @@ + + + + diff --git a/test/fixtures/wpt/fetch/api/credentials/authentication-basic.any.js b/test/fixtures/wpt/fetch/api/credentials/authentication-basic.any.js new file mode 100644 index 0000000..31ccc38 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/credentials/authentication-basic.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker + +function basicAuth(desc, user, pass, mode, status) { + promise_test(function(test) { + var headers = { "Authorization": "Basic " + btoa(user + ":" + pass)}; + var requestInit = {"credentials": mode, "headers": headers}; + return fetch("../resources/authentication.py?realm=test", requestInit).then(function(resp) { + assert_equals(resp.status, status, "HTTP status is " + status); + assert_equals(resp.type , "basic", "Response's type is basic"); + }); + }, desc); +} + +basicAuth("User-added Authorization header with include mode", "user", "password", "include", 200); +basicAuth("User-added Authorization header with same-origin mode", "user", "password", "same-origin", 200); +basicAuth("User-added Authorization header with omit mode", "user", "password", "omit", 200); +basicAuth("User-added bogus Authorization header with omit mode", "notuser", "notpassword", "omit", 401); diff --git a/test/fixtures/wpt/fetch/api/credentials/authentication-redirection.any.js b/test/fixtures/wpt/fetch/api/credentials/authentication-redirection.any.js new file mode 100644 index 0000000..5a15507 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/credentials/authentication-redirection.any.js @@ -0,0 +1,29 @@ +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +const authorizationValue = "Basic " + btoa("user:pass"); +async function getAuthorizationHeaderValue(url) +{ + const headers = { "Authorization": authorizationValue}; + const requestInit = {"headers": headers}; + const response = await fetch(url, requestInit); + return response.text(); +} + +promise_test(async test => { + const result = await getAuthorizationHeaderValue("/fetch/api/resources/dump-authorization-header.py"); + assert_equals(result, authorizationValue); +}, "getAuthorizationHeaderValue - no redirection"); + +promise_test(async test => { + result = await getAuthorizationHeaderValue("/fetch/api/resources/redirect.py?location=" + encodeURIComponent("/fetch/api/resources/dump-authorization-header.py")); + assert_equals(result, authorizationValue); + + result = await getAuthorizationHeaderValue(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/api/resources/redirect.py?allow_headers=Authorization&location=" + encodeURIComponent(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/api/resources/dump-authorization-header.py")); + assert_equals(result, authorizationValue); +}, "getAuthorizationHeaderValue - same origin redirection"); + +promise_test(async (test) => { + const result = await getAuthorizationHeaderValue(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/api/resources/redirect.py?allow_headers=Authorization&location=" + encodeURIComponent(get_host_info().HTTPS_ORIGIN + "/fetch/api/resources/dump-authorization-header.py?strip_auth_header=true")); + assert_equals(result, "none"); +}, "getAuthorizationHeaderValue - cross origin redirection"); diff --git a/test/fixtures/wpt/fetch/api/credentials/cookies.any.js b/test/fixtures/wpt/fetch/api/credentials/cookies.any.js new file mode 100644 index 0000000..de30e47 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/credentials/cookies.any.js @@ -0,0 +1,49 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +function cookies(desc, credentials1, credentials2 ,cookies) { + var url = RESOURCES_DIR + "top.txt" + var urlParameters = ""; + var urlCleanParameters = ""; + if (cookies) { + urlParameters +="?pipe=header(Set-Cookie,"; + urlParameters += cookies.join(",True)|header(Set-Cookie,") + ",True)"; + urlCleanParameters +="?pipe=header(Set-Cookie,"; + urlCleanParameters += cookies.join("%3B%20max-age=0,True)|header(Set-Cookie,") + "%3B%20max-age=0,True)"; + } + + var requestInit = {"credentials": credentials1} + promise_test(function(test){ + var requestInit = {"credentials": credentials1} + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + //check cookies sent + return fetch(RESOURCES_DIR + "inspect-headers.py?headers=cookie" , {"credentials": credentials2}); + }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_false(resp.headers.has("Cookie") , "Cookie header is not exposed in response"); + if (credentials1 != "omit" && credentials2 != "omit") { + assert_equals(resp.headers.get("x-request-cookie") , cookies.join("; "), "Request include cookie(s)"); + } + else { + assert_false(resp.headers.has("x-request-cookie") , "Request does not have cookie(s)"); + } + //clean cookies + return fetch(url + urlCleanParameters, {"credentials": "include"}); + }).catch(function(e) { + return fetch(url + urlCleanParameters, {"credentials": "include"}).then(function() { + return Promise.reject(e); + }); + }); + }, desc); +} + +cookies("Include mode: 1 cookie", "include", "include", ["a=1"]); +cookies("Include mode: 2 cookies", "include", "include", ["b=2", "c=3"]); +cookies("Omit mode: discard cookies", "omit", "omit", ["d=4"]); +cookies("Omit mode: no cookie is stored", "omit", "include", ["e=5"]); +cookies("Omit mode: no cookie is sent", "include", "omit", ["f=6"]); +cookies("Same-origin mode: 1 cookie", "same-origin", "same-origin", ["a=1"]); +cookies("Same-origin mode: 2 cookies", "same-origin", "same-origin", ["b=2", "c=3"]); diff --git a/test/fixtures/wpt/fetch/api/headers/header-setcookie.any.js b/test/fixtures/wpt/fetch/api/headers/header-setcookie.any.js new file mode 100644 index 0000000..cafb780 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/header-setcookie.any.js @@ -0,0 +1,266 @@ +// META: title=Headers set-cookie special cases +// META: global=window,worker + +const headerList = [ + ["set-cookie", "foo=bar"], + ["Set-Cookie", "fizz=buzz; domain=example.com"], +]; + +const setCookie2HeaderList = [ + ["set-cookie2", "foo2=bar2"], + ["Set-Cookie2", "fizz2=buzz2; domain=example2.com"], +]; + +function assert_nested_array_equals(actual, expected) { + assert_equals(actual.length, expected.length, "Array length is not equal"); + for (let i = 0; i < expected.length; i++) { + assert_array_equals(actual[i], expected[i]); + } +} + +test(function () { + const headers = new Headers(headerList); + assert_equals( + headers.get("set-cookie"), + "foo=bar, fizz=buzz; domain=example.com", + ); +}, "Headers.prototype.get combines set-cookie headers in order"); + +test(function () { + const headers = new Headers(headerList); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz; domain=example.com"], + ]); +}, "Headers iterator does not combine set-cookie headers"); + +test(function () { + const headers = new Headers(setCookie2HeaderList); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"], + ]); +}, "Headers iterator does not special case set-cookie2 headers"); + +test(function () { + const headers = new Headers([...headerList, ...setCookie2HeaderList]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz; domain=example.com"], + ["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"], + ]); +}, "Headers iterator does not combine set-cookie & set-cookie2 headers"); + +test(function () { + // Values are in non alphabetic order, and the iterator should yield in the + // headers in the exact order of the input. + const headers = new Headers([ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); +}, "Headers iterator preserves set-cookie ordering"); + +test( + function () { + const headers = new Headers([ + ["xylophone-header", "1"], + ["best-header", "2"], + ["set-cookie", "3"], + ["a-cool-header", "4"], + ["set-cookie", "5"], + ["a-cool-header", "6"], + ["best-header", "7"], + ]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["a-cool-header", "4, 6"], + ["best-header", "2, 7"], + ["set-cookie", "3"], + ["set-cookie", "5"], + ["xylophone-header", "1"], + ]); + }, + "Headers iterator preserves per header ordering, but sorts keys alphabetically", +); + +test( + function () { + const headers = new Headers([ + ["xylophone-header", "7"], + ["best-header", "6"], + ["set-cookie", "5"], + ["a-cool-header", "4"], + ["set-cookie", "3"], + ["a-cool-header", "2"], + ["best-header", "1"], + ]); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["a-cool-header", "4, 2"], + ["best-header", "6, 1"], + ["set-cookie", "5"], + ["set-cookie", "3"], + ["xylophone-header", "7"], + ]); + }, + "Headers iterator preserves per header ordering, but sorts keys alphabetically (and ignores value ordering)", +); + +test(function () { + const headers = new Headers([["fizz", "buzz"], ["X-Header", "test"]]); + const iterator = headers[Symbol.iterator](); + assert_array_equals(iterator.next().value, ["fizz", "buzz"]); + headers.append("Set-Cookie", "a=b"); + assert_array_equals(iterator.next().value, ["set-cookie", "a=b"]); + headers.append("Accept", "text/html"); + assert_array_equals(iterator.next().value, ["set-cookie", "a=b"]); + assert_array_equals(iterator.next().value, ["x-header", "test"]); + headers.append("set-cookie", "c=d"); + assert_array_equals(iterator.next().value, ["x-header", "test"]); + assert_true(iterator.next().done); +}, "Headers iterator is correctly updated with set-cookie changes"); + +test(function () { + const headers = new Headers([ + ["set-cookie", "a"], + ["set-cookie", "b"], + ["set-cookie", "c"] + ]); + const iterator = headers[Symbol.iterator](); + assert_array_equals(iterator.next().value, ["set-cookie", "a"]); + headers.delete("set-cookie"); + headers.append("set-cookie", "d"); + headers.append("set-cookie", "e"); + headers.append("set-cookie", "f"); + assert_array_equals(iterator.next().value, ["set-cookie", "e"]); + assert_array_equals(iterator.next().value, ["set-cookie", "f"]); + assert_true(iterator.next().done); +}, "Headers iterator is correctly updated with set-cookie changes #2"); + +test(function () { + const headers = new Headers(headerList); + assert_true(headers.has("sEt-cOoKiE")); +}, "Headers.prototype.has works for set-cookie"); + +test(function () { + const headers = new Headers(setCookie2HeaderList); + headers.append("set-Cookie", "foo=bar"); + headers.append("sEt-cOoKiE", "fizz=buzz"); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo=bar"], + ["set-cookie", "fizz=buzz"], + ["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"], + ]); +}, "Headers.prototype.append works for set-cookie"); + +test(function () { + const headers = new Headers(headerList); + headers.set("set-cookie", "foo2=bar2"); + const list = [...headers]; + assert_nested_array_equals(list, [ + ["set-cookie", "foo2=bar2"], + ]); +}, "Headers.prototype.set works for set-cookie"); + +test(function () { + const headers = new Headers(headerList); + headers.delete("set-Cookie"); + const list = [...headers]; + assert_nested_array_equals(list, []); +}, "Headers.prototype.delete works for set-cookie"); + +test(function () { + const headers = new Headers(); + assert_array_equals(headers.getSetCookie(), []); +}, "Headers.prototype.getSetCookie with no headers present"); + +test(function () { + const headers = new Headers([headerList[0]]); + assert_array_equals(headers.getSetCookie(), ["foo=bar"]); +}, "Headers.prototype.getSetCookie with one header"); + +test(function () { + const headers = new Headers({ "Set-Cookie": "foo=bar" }); + assert_array_equals(headers.getSetCookie(), ["foo=bar"]); +}, "Headers.prototype.getSetCookie with one header created from an object"); + +test(function () { + const headers = new Headers(headerList); + assert_array_equals(headers.getSetCookie(), [ + "foo=bar", + "fizz=buzz; domain=example.com", + ]); +}, "Headers.prototype.getSetCookie with multiple headers"); + +test(function () { + const headers = new Headers([["set-cookie", ""]]); + assert_array_equals(headers.getSetCookie(), [""]); +}, "Headers.prototype.getSetCookie with an empty header"); + +test(function () { + const headers = new Headers([["set-cookie", "x"], ["set-cookie", "x"]]); + assert_array_equals(headers.getSetCookie(), ["x", "x"]); +}, "Headers.prototype.getSetCookie with two equal headers"); + +test(function () { + const headers = new Headers([ + ["set-cookie2", "x"], + ["set-cookie", "y"], + ["set-cookie2", "z"], + ]); + assert_array_equals(headers.getSetCookie(), ["y"]); +}, "Headers.prototype.getSetCookie ignores set-cookie2 headers"); + +test(function () { + // Values are in non alphabetic order, and the iterator should yield in the + // headers in the exact order of the input. + const headers = new Headers([ + ["set-cookie", "z=z"], + ["set-cookie", "a=a"], + ["set-cookie", "n=n"], + ]); + assert_array_equals(headers.getSetCookie(), ["z=z", "a=a", "n=n"]); +}, "Headers.prototype.getSetCookie preserves header ordering"); + +test(function () { + const headers = new Headers({"Set-Cookie": " a=b\n"}); + headers.append("set-cookie", "\n\rc=d "); + assert_nested_array_equals([...headers], [ + ["set-cookie", "a=b"], + ["set-cookie", "c=d"] + ]); + headers.set("set-cookie", "\te=f "); + assert_nested_array_equals([...headers], [["set-cookie", "e=f"]]); +}, "Adding Set-Cookie headers normalizes their value"); + +test(function () { + assert_throws_js(TypeError, () => { + new Headers({"set-cookie": "\0"}); + }); + + const headers = new Headers(); + assert_throws_js(TypeError, () => { + headers.append("Set-Cookie", "a\nb"); + }); + assert_throws_js(TypeError, () => { + headers.set("Set-Cookie", "a\rb"); + }); +}, "Adding invalid Set-Cookie headers throws"); + +test(function () { + const response = new Response(); + response.headers.append("Set-Cookie", "foo=bar"); + assert_array_equals(response.headers.getSetCookie(), []); + response.headers.append("sEt-cOokIe", "bar=baz"); + assert_array_equals(response.headers.getSetCookie(), []); +}, "Set-Cookie is a forbidden response header"); diff --git a/test/fixtures/wpt/fetch/api/headers/header-values-normalize.any.js b/test/fixtures/wpt/fetch/api/headers/header-values-normalize.any.js new file mode 100644 index 0000000..5710554 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/header-values-normalize.any.js @@ -0,0 +1,72 @@ +// META: title=Header value normalizing test +// META: global=window,worker +// META: timeout=long + +"use strict"; + +for(let i = 0; i < 0x21; i++) { + let fail = false, + strip = false + + // REMOVE 0x0B/0x0C exception once https://github.com/web-platform-tests/wpt/issues/8372 is fixed + if(i === 0x0B || i === 0x0C) + continue + + if(i === 0) { + fail = true + } + + if(i === 0x09 || i === 0x0A || i === 0x0D || i === 0x20) { + strip = true + } + + let url = "../resources/inspect-headers.py?headers=val1|val2|val3", + val = String.fromCharCode(i), + expectedVal = strip ? "" : val, + val1 = val, + expectedVal1 = expectedVal, + val2 = "x" + val, + expectedVal2 = "x" + expectedVal, + val3 = val + "x", + expectedVal3 = expectedVal + "x" + + // XMLHttpRequest is not available in service workers + if (!self.GLOBAL.isWorker()) { + async_test((t) => { + let xhr = new XMLHttpRequest() + xhr.open("POST", url) + if(fail) { + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("val1", val1)) + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("val2", val2)) + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("val3", val3)) + t.done() + } else { + xhr.setRequestHeader("val1", val1) + xhr.setRequestHeader("val2", val2) + xhr.setRequestHeader("val3", val3) + xhr.onload = t.step_func_done(() => { + assert_equals(xhr.getResponseHeader("x-request-val1"), expectedVal1) + assert_equals(xhr.getResponseHeader("x-request-val2"), expectedVal2) + assert_equals(xhr.getResponseHeader("x-request-val3"), expectedVal3) + }) + xhr.send() + } + }, "XMLHttpRequest with value " + encodeURI(val)) + } + + promise_test((t) => { + if(fail) { + return Promise.all([ + promise_rejects_js(t, TypeError, fetch(url, { headers: {"val1": val1} })), + promise_rejects_js(t, TypeError, fetch(url, { headers: {"val2": val2} })), + promise_rejects_js(t, TypeError, fetch(url, { headers: {"val3": val3} })) + ]) + } else { + return fetch(url, { headers: {"val1": val1, "val2": val2, "val3": val3} }).then((res) => { + assert_equals(res.headers.get("x-request-val1"), expectedVal1) + assert_equals(res.headers.get("x-request-val2"), expectedVal2) + assert_equals(res.headers.get("x-request-val3"), expectedVal3) + }) + } + }, "fetch() with value " + encodeURI(val)) +} diff --git a/test/fixtures/wpt/fetch/api/headers/header-values.any.js b/test/fixtures/wpt/fetch/api/headers/header-values.any.js new file mode 100644 index 0000000..bb7570c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/header-values.any.js @@ -0,0 +1,63 @@ +// META: title=Header value test +// META: global=window,worker +// META: timeout=long + +"use strict"; + +// Invalid values +[0, 0x0A, 0x0D].forEach(val => { + val = "x" + String.fromCharCode(val) + "x" + + // XMLHttpRequest is not available in service workers + if (!self.GLOBAL.isWorker()) { + test(() => { + let xhr = new XMLHttpRequest() + xhr.open("POST", "/") + assert_throws_dom("SyntaxError", () => xhr.setRequestHeader("value-test", val)) + }, "XMLHttpRequest with value " + encodeURI(val) + " needs to throw") + } + + promise_test(t => promise_rejects_js(t, TypeError, fetch("/", { headers: {"value-test": val} })), "fetch() with value " + encodeURI(val) + " needs to throw") +}) + +// Valid values +let headerValues =[] +for(let i = 0; i < 0x100; i++) { + if(i === 0 || i === 0x0A || i === 0x0D) { + continue + } + headerValues.push("x" + String.fromCharCode(i) + "x") +} +var url = "../resources/inspect-headers.py?headers=" +headerValues.forEach((_, i) => { + url += "val" + i + "|" +}) + +// XMLHttpRequest is not available in service workers +if (!self.GLOBAL.isWorker()) { + async_test((t) => { + let xhr = new XMLHttpRequest() + xhr.open("POST", url) + headerValues.forEach((val, i) => { + xhr.setRequestHeader("val" + i, val) + }) + xhr.onload = t.step_func_done(() => { + headerValues.forEach((val, i) => { + assert_equals(xhr.getResponseHeader("x-request-val" + i), val) + }) + }) + xhr.send() + }, "XMLHttpRequest with all valid values") +} + +promise_test((t) => { + const headers = new Headers + headerValues.forEach((val, i) => { + headers.append("val" + i, val) + }) + return fetch(url, { headers }).then((res) => { + headerValues.forEach((val, i) => { + assert_equals(res.headers.get("x-request-val" + i), val) + }) + }) +}, "fetch() with all valid values") diff --git a/test/fixtures/wpt/fetch/api/headers/headers-basic.any.js b/test/fixtures/wpt/fetch/api/headers/headers-basic.any.js new file mode 100644 index 0000000..ead1047 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-basic.any.js @@ -0,0 +1,275 @@ +// META: title=Headers structure +// META: global=window,worker + +"use strict"; + +test(function() { + new Headers(); +}, "Create headers from no parameter"); + +test(function() { + new Headers(undefined); +}, "Create headers from undefined parameter"); + +test(function() { + new Headers({}); +}, "Create headers from empty object"); + +var parameters = [null, 1]; +parameters.forEach(function(parameter) { + test(function() { + assert_throws_js(TypeError, function() { new Headers(parameter) }); + }, "Create headers with " + parameter + " should throw"); +}); + +var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3", + "name4": null, + "name5": undefined, + "name6": 1, + "Content-Type": "value4" +}; + +var headerSeq = []; +for (var name in headerDict) + headerSeq.push([name, headerDict[name]]); + +test(function() { + var headers = new Headers(headerSeq); + for (name in headerDict) { + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } + assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary"); +}, "Create headers with sequence"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) { + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Create headers with record"); + +test(function() { + var headers = new Headers(headerDict); + var headers2 = new Headers(headers); + for (name in headerDict) { + assert_equals(headers2.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Create headers with existing headers"); + +test(function() { + var headers = new Headers() + headers[Symbol.iterator] = function *() { + yield ["test", "test"] + } + var headers2 = new Headers(headers) + assert_equals(headers2.get("test"), "test") +}, "Create headers with existing headers with custom iterator"); + +test(function() { + var headers = new Headers(); + for (name in headerDict) { + headers.append(name, headerDict[name]); + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Check append method"); + +test(function() { + var headers = new Headers(); + for (name in headerDict) { + headers.set(name, headerDict[name]); + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + } +}, "Check set method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) + assert_true(headers.has(name),"headers has name " + name); + + assert_false(headers.has("nameNotInHeaders"),"headers do not have header: nameNotInHeaders"); +}, "Check has method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) { + assert_true(headers.has(name),"headers have a header: " + name); + headers.delete(name) + assert_true(!headers.has(name),"headers do not have anymore a header: " + name); + } +}, "Check delete method"); + +test(function() { + var headers = new Headers(headerDict); + for (name in headerDict) + assert_equals(headers.get(name), String(headerDict[name]), + "name: " + name + " has value: " + headerDict[name]); + + assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value"); +}, "Check get method"); + +var headerEntriesDict = {"name1": "value1", + "Name2": "value2", + "name": "value3", + "content-Type": "value4", + "Content-Typ": "value5", + "Content-Types": "value6" +}; +var sortedHeaderDict = {}; +var headerValues = []; +var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) { + sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]; + headerValues.push(headerEntriesDict[value]); + return value.toLowerCase(); +}).sort(); + +var iteratorPrototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())); +function checkIteratorProperties(iterator) { + var prototype = Object.getPrototypeOf(iterator); + assert_equals(Object.getPrototypeOf(prototype), iteratorPrototype); + + var descriptor = Object.getOwnPropertyDescriptor(prototype, "next"); + assert_true(descriptor.configurable, "configurable"); + assert_true(descriptor.enumerable, "enumerable"); + assert_true(descriptor.writable, "writable"); +} + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.keys(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value, key); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const key of headers.keys()) + assert_true(sortedHeaderKeys.indexOf(key) != -1); +}, "Check keys method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.values(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value, sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const value of headers.values()) + assert_true(headerValues.indexOf(value) != -1); +}, "Check values method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers.entries(); + checkIteratorProperties(actual); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value[0], key); + assert_equals(entry.value[1], sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); + + for (const entry of headers.entries()) + assert_equals(entry[1], sortedHeaderDict[entry[0]]); +}, "Check entries method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var actual = headers[Symbol.iterator](); + + sortedHeaderKeys.forEach(function(key) { + const entry = actual.next(); + assert_false(entry.done); + assert_equals(entry.value[0], key); + assert_equals(entry.value[1], sortedHeaderDict[key]); + }); + assert_true(actual.next().done); + assert_true(actual.next().done); +}, "Check Symbol.iterator method"); + +test(function() { + var headers = new Headers(headerEntriesDict); + var reference = sortedHeaderKeys[Symbol.iterator](); + headers.forEach(function(value, key, container) { + assert_equals(headers, container); + const entry = reference.next(); + assert_false(entry.done); + assert_equals(key, entry.value); + assert_equals(value, sortedHeaderDict[entry.value]); + }); + assert_true(reference.next().done); +}, "Check forEach method"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + headers.delete("foo"); + } + assert_array_equals(actualKeys, ["bar", "baz"]); + assert_array_equals(actualValues, ["0", "1"]); +}, "Iteration skips elements removed while iterating"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.delete("bar"); + } + assert_array_equals(actualKeys, ["bar", "baz", "quux"]); + assert_array_equals(actualValues, ["0", "1", "3"]); +}, "Removing elements already iterated over causes an element to be skipped during iteration"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.append("X-yZ", "4"); + } + assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"]); + assert_array_equals(actualValues, ["0", "1", "2", "3", "4"]); +}, "Appending a value pair during iteration causes it to be reached during iteration"); + +test(() => { + const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"}); + const actualKeys = []; + const actualValues = []; + for (const [header, value] of headers) { + actualKeys.push(header); + actualValues.push(value); + if (header === "baz") + headers.append("abc", "-1"); + } + assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"]); + assert_array_equals(actualValues, ["0", "1", "1", "2", "3"]); +}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time"); diff --git a/test/fixtures/wpt/fetch/api/headers/headers-casing.any.js b/test/fixtures/wpt/fetch/api/headers/headers-casing.any.js new file mode 100644 index 0000000..20b8a9d --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-casing.any.js @@ -0,0 +1,54 @@ +// META: title=Headers case management +// META: global=window,worker + +"use strict"; + +var headerDictCase = {"UPPERCASE": "value1", + "lowercase": "value2", + "mixedCase": "value3", + "Content-TYPE": "value4" + }; + +function checkHeadersCase(originalName, headersToCheck, expectedDict) { + var lowCaseName = originalName.toLowerCase(); + var upCaseName = originalName.toUpperCase(); + var expectedValue = expectedDict[originalName]; + assert_equals(headersToCheck.get(originalName), expectedValue, + "name: " + originalName + " has value: " + expectedValue); + assert_equals(headersToCheck.get(lowCaseName), expectedValue, + "name: " + lowCaseName + " has value: " + expectedValue); + assert_equals(headersToCheck.get(upCaseName), expectedValue, + "name: " + upCaseName + " has value: " + expectedValue); +} + +test(function() { + var headers = new Headers(headerDictCase); + for (const name in headerDictCase) + checkHeadersCase(name, headers, headerDictCase) +}, "Create headers, names use characters with different case"); + +test(function() { + var headers = new Headers(); + for (const name in headerDictCase) { + headers.append(name, headerDictCase[name]); + checkHeadersCase(name, headers, headerDictCase); + } +}, "Check append method, names use characters with different case"); + +test(function() { + var headers = new Headers(); + for (const name in headerDictCase) { + headers.set(name, headerDictCase[name]); + checkHeadersCase(name, headers, headerDictCase); + } +}, "Check set method, names use characters with different case"); + +test(function() { + var headers = new Headers(); + for (const name in headerDictCase) + headers.set(name, headerDictCase[name]); + for (const name in headerDictCase) + headers.delete(name.toLowerCase()); + for (const name in headerDictCase) + assert_false(headers.has(name), "header " + name + " should have been deleted"); +}, "Check delete method, names use characters with different case"); diff --git a/test/fixtures/wpt/fetch/api/headers/headers-combine.any.js b/test/fixtures/wpt/fetch/api/headers/headers-combine.any.js new file mode 100644 index 0000000..4f3b6d1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-combine.any.js @@ -0,0 +1,66 @@ +// META: title=Headers have combined (and sorted) values +// META: global=window,worker + +"use strict"; + +var headerSeqCombine = [["single", "singleValue"], + ["double", "doubleValue1"], + ["double", "doubleValue2"], + ["triple", "tripleValue1"], + ["triple", "tripleValue2"], + ["triple", "tripleValue3"] +]; +var expectedDict = {"single": "singleValue", + "double": "doubleValue1, doubleValue2", + "triple": "tripleValue1, tripleValue2, tripleValue3" +}; + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) + assert_equals(headers.get(name), expectedDict[name]); +}, "Create headers using same name for different values"); + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) { + assert_true(headers.has(name), "name: " + name + " has value(s)"); + headers.delete(name); + assert_false(headers.has(name), "name: " + name + " has no value(s) anymore"); + } +}, "Check delete and has methods when using same name for different values"); + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) { + headers.set(name,"newSingleValue"); + assert_equals(headers.get(name), "newSingleValue", "name: " + name + " has value: newSingleValue"); + } +}, "Check set methods when called with already used name"); + +test(function() { + var headers = new Headers(headerSeqCombine); + for (const name in expectedDict) { + var value = headers.get(name); + headers.append(name,"newSingleValue"); + assert_equals(headers.get(name), (value + ", " + "newSingleValue")); + } +}, "Check append methods when called with already used name"); + +test(() => { + const headers = new Headers([["1", "a"],["1", "b"]]); + for(let header of headers) { + assert_array_equals(header, ["1", "a, b"]); + } +}, "Iterate combined values"); + +test(() => { + const headers = new Headers([["2", "a"], ["1", "b"], ["2", "b"]]), + expected = [["1", "b"], ["2", "a, b"]]; + let i = 0; + for(let header of headers) { + assert_array_equals(header, expected[i]); + i++; + } + assert_equals(i, 2); +}, "Iterate combined values in sorted order") diff --git a/test/fixtures/wpt/fetch/api/headers/headers-errors.any.js b/test/fixtures/wpt/fetch/api/headers/headers-errors.any.js new file mode 100644 index 0000000..82dadd8 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-errors.any.js @@ -0,0 +1,96 @@ +// META: title=Headers errors +// META: global=window,worker + +"use strict"; + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["name"]]); }); +}, "Create headers giving an array having one string as init argument"); + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]); }); +}, "Create headers giving an array having three strings as init argument"); + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["invalidĀ", "Value1"]]); }); +}, "Create headers giving bad header name as init argument"); + +test(function() { + assert_throws_js(TypeError, function() { new Headers([["name", "invalidValueĀ"]]); }); +}, "Create headers giving bad header value as init argument"); + +var badNames = ["invalidĀ", {}]; +var badValues = ["invalidĀ"]; + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.get(name); }); + }, "Check headers get with an invalid name " + name); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.delete(name); }); + }, "Check headers delete with an invalid name " + name); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.has(name); }); + }, "Check headers has with an invalid name " + name); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.set(name, "Value1"); }); + }, "Check headers set with an invalid name " + name); +}); + +badValues.forEach(function(value) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.set("name", value); }); + }, "Check headers set with an invalid value " + value); +}); + +badNames.forEach(function(name) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.append("invalidĀ", "Value1"); }); + }, "Check headers append with an invalid name " + name); +}); + +badValues.forEach(function(value) { + test(function() { + var headers = new Headers(); + assert_throws_js(TypeError, function() { headers.append("name", value); }); + }, "Check headers append with an invalid value " + value); +}); + +test(function() { + var headers = new Headers([["name", "value"]]); + assert_throws_js(TypeError, function() { headers.forEach(); }); + assert_throws_js(TypeError, function() { headers.forEach(undefined); }); + assert_throws_js(TypeError, function() { headers.forEach(1); }); +}, "Headers forEach throws if argument is not callable"); + +test(function() { + var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]]); + var counter = 0; + try { + headers.forEach(function(value, name) { + counter++; + if (name == "name2") + throw "error"; + }); + } catch (e) { + assert_equals(counter, 2); + assert_equals(e, "error"); + return; + } + assert_unreached(); +}, "Headers forEach loop should stop if callback is throwing exception"); diff --git a/test/fixtures/wpt/fetch/api/headers/headers-no-cors.any.js b/test/fixtures/wpt/fetch/api/headers/headers-no-cors.any.js new file mode 100644 index 0000000..60dbb9e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-no-cors.any.js @@ -0,0 +1,59 @@ +// META: global=window,worker + +"use strict"; + +promise_test(() => fetch("../cors/resources/not-cors-safelisted.json").then(res => res.json().then(runTests)), "Loading data…"); + +const longValue = "s".repeat(127); + +[ + { + "headers": ["accept", "accept-language", "content-language"], + "values": [longValue, "", longValue] + }, + { + "headers": ["accept", "accept-language", "content-language"], + "values": ["", longValue] + }, + { + "headers": ["content-type"], + "values": ["text/plain;" + "s".repeat(116), "text/plain"] + } +].forEach(testItem => { + testItem.headers.forEach(header => { + test(() => { + const noCorsHeaders = new Request("about:blank", { mode: "no-cors" }).headers; + testItem.values.forEach((value) => { + noCorsHeaders.append(header, value); + assert_equals(noCorsHeaders.get(header), testItem.values[0], '1'); + }); + noCorsHeaders.set(header, testItem.values.join(", ")); + assert_equals(noCorsHeaders.get(header), testItem.values[0], '2'); + noCorsHeaders.delete(header); + assert_false(noCorsHeaders.has(header)); + }, "\"no-cors\" Headers object cannot have " + header + " set to " + testItem.values.join(", ")); + }); +}); + +function runTests(testArray) { + testArray = testArray.concat([ + ["dpr", "2"], + ["rtt", "1.0"], + ["downlink", "-1.0"], + ["ect", "6g"], + ["save-data", "on"], + ["viewport-width", "100"], + ["width", "100"], + ["unknown", "doesitmatter"] + ]); + testArray.forEach(testItem => { + const [headerName, headerValue] = testItem; + test(() => { + const noCorsHeaders = new Request("about:blank", { mode: "no-cors" }).headers; + noCorsHeaders.append(headerName, headerValue); + assert_false(noCorsHeaders.has(headerName)); + noCorsHeaders.set(headerName, headerValue); + assert_false(noCorsHeaders.has(headerName)); + }, "\"no-cors\" Headers object cannot have " + headerName + "/" + headerValue + " as header"); + }); +} diff --git a/test/fixtures/wpt/fetch/api/headers/headers-normalize.any.js b/test/fixtures/wpt/fetch/api/headers/headers-normalize.any.js new file mode 100644 index 0000000..68cf5b8 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-normalize.any.js @@ -0,0 +1,56 @@ +// META: title=Headers normalize values +// META: global=window,worker + +"use strict"; + +const expectations = { + "name1": [" space ", "space"], + "name2": ["\ttab\t", "tab"], + "name3": [" spaceAndTab\t", "spaceAndTab"], + "name4": ["\r\n newLine", "newLine"], //obs-fold cases + "name5": ["newLine\r\n ", "newLine"], + "name6": ["\r\n\tnewLine", "newLine"], + "name7": ["\t\f\tnewLine\n", "\f\tnewLine"], + "name8": ["newLine\xa0", "newLine\xa0"], // \xa0 == non breaking space +}; + +test(function () { + const headerDict = Object.fromEntries( + Object.entries(expectations).map(([name, [actual]]) => [name, actual]), + ); + var headers = new Headers(headerDict); + for (const name in expectations) { + const expected = expectations[name][1]; + assert_equals( + headers.get(name), + expected, + "name: " + name + " has normalized value: " + expected, + ); + } +}, "Create headers with not normalized values"); + +test(function () { + var headers = new Headers(); + for (const name in expectations) { + headers.append(name, expectations[name][0]); + const expected = expectations[name][1]; + assert_equals( + headers.get(name), + expected, + "name: " + name + " has value: " + expected, + ); + } +}, "Check append method with not normalized values"); + +test(function () { + var headers = new Headers(); + for (const name in expectations) { + headers.set(name, expectations[name][0]); + const expected = expectations[name][1]; + assert_equals( + headers.get(name), + expected, + "name: " + name + " has value: " + expected, + ); + } +}, "Check set method with not normalized values"); diff --git a/test/fixtures/wpt/fetch/api/headers/headers-record.any.js b/test/fixtures/wpt/fetch/api/headers/headers-record.any.js new file mode 100644 index 0000000..fa85391 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-record.any.js @@ -0,0 +1,357 @@ +// META: global=window,worker + +"use strict"; + +var log = []; +function clearLog() { + log = []; +} +function addLogEntry(name, args) { + log.push([ name, ...args ]); +} + +var loggingHandler = { +}; + +setup(function() { + for (let prop of Object.getOwnPropertyNames(Reflect)) { + loggingHandler[prop] = function(...args) { + addLogEntry(prop, args); + return Reflect[prop](...args); + } + } +}); + +test(function() { + var h = new Headers(); + assert_equals([...h].length, 0); +}, "Passing nothing to Headers constructor"); + +test(function() { + var h = new Headers(undefined); + assert_equals([...h].length, 0); +}, "Passing undefined to Headers constructor"); + +test(function() { + assert_throws_js(TypeError, function() { + var h = new Headers(null); + }); +}, "Passing null to Headers constructor"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "b" }; + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 4); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["a"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); +}, "Basic operation with one property"); + +test(function() { + this.add_cleanup(clearLog); + var recordProto = { c: "d" }; + var record = Object.create(recordProto, { a: { value: "b", enumerable: true } }); + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 4); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["a"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); +}, "Basic operation with one property and a proto"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "b", c: "d" }; + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 6); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[4], ["getOwnPropertyDescriptor", record, "c"]); + // Then the second [[Get]] from step 5.2. + assert_array_equals(log[5], ["get", record, "c", proxy]); + + // Check the results. + assert_equals([...h].length, 2); + assert_array_equals([...h.keys()], ["a", "c"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Correct operation ordering with two properties"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "b", "\uFFFF": "d" }; + var proxy = new Proxy(record, loggingHandler); + assert_throws_js(TypeError, function() { + var h = new Headers(proxy); + }); + + assert_equals(log.length, 5); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[4], ["getOwnPropertyDescriptor", record, "\uFFFF"]); + // The second [[Get]] never happens, because we convert the invalid name to a + // ByteString first and throw. +}, "Correct operation ordering with two properties one of which has an invalid name"); + +test(function() { + this.add_cleanup(clearLog); + var record = { a: "\uFFFF", c: "d" } + var proxy = new Proxy(record, loggingHandler); + assert_throws_js(TypeError, function() { + var h = new Headers(proxy); + }); + + assert_equals(log.length, 4); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Nothing else after this, because converting the result of that [[Get]] to a + // ByteString throws. +}, "Correct operation ordering with two properties one of which has an invalid value"); + +test(function() { + this.add_cleanup(clearLog); + var record = {}; + Object.defineProperty(record, "a", { value: "b", enumerable: false }); + Object.defineProperty(record, "c", { value: "d", enumerable: true }); + Object.defineProperty(record, "e", { value: "f", enumerable: false }); + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 6); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // No [[Get]] because not enumerable + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[3], ["getOwnPropertyDescriptor", record, "c"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[4], ["get", record, "c", proxy]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[5], ["getOwnPropertyDescriptor", record, "e"]); + // No [[Get]] because not enumerable + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["c"]); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Correct operation ordering with non-enumerable properties"); + +test(function() { + this.add_cleanup(clearLog); + var record = {a: "b", c: "d", e: "f"}; + var lyingHandler = { + getOwnPropertyDescriptor: function(target, name) { + if (name == "a" || name == "e") { + return undefined; + } + return Reflect.getOwnPropertyDescriptor(target, name); + } + }; + var lyingProxy = new Proxy(record, lyingHandler); + var proxy = new Proxy(lyingProxy, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 6); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", lyingProxy, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", lyingProxy]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", lyingProxy, "a"]); + // No [[Get]] because no descriptor + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[3], ["getOwnPropertyDescriptor", lyingProxy, "c"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[4], ["get", lyingProxy, "c", proxy]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[5], ["getOwnPropertyDescriptor", lyingProxy, "e"]); + // No [[Get]] because no descriptor + + // Check the results. + assert_equals([...h].length, 1); + assert_array_equals([...h.keys()], ["c"]); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Correct operation ordering with undefined descriptors"); + +test(function() { + this.add_cleanup(clearLog); + var record = {a: "b", c: "d"}; + var lyingHandler = { + ownKeys: function() { + return [ "a", "c", "a", "c" ]; + }, + }; + var lyingProxy = new Proxy(record, lyingHandler); + var proxy = new Proxy(lyingProxy, loggingHandler); + + // Returning duplicate keys from ownKeys() throws a TypeError. + assert_throws_js(TypeError, + function() { var h = new Headers(proxy); }); + + assert_equals(log.length, 2); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", lyingProxy, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", lyingProxy]); +}, "Correct operation ordering with repeated keys"); + +test(function() { + this.add_cleanup(clearLog); + var record = { + a: "b", + [Symbol.toStringTag]: { + // Make sure the ToString conversion of the value happens + // after the ToString conversion of the key. + toString: function () { addLogEntry("toString", [this]); return "nope"; } + }, + c: "d" }; + var proxy = new Proxy(record, loggingHandler); + assert_throws_js(TypeError, + function() { var h = new Headers(proxy); }); + + assert_equals(log.length, 7); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[4], ["getOwnPropertyDescriptor", record, "c"]); + // Then the second [[Get]] from step 5.2. + assert_array_equals(log[5], ["get", record, "c", proxy]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[6], ["getOwnPropertyDescriptor", record, + Symbol.toStringTag]); + // Then we throw an exception converting the Symbol to a string, before we do + // the third [[Get]]. +}, "Basic operation with Symbol keys"); + +test(function() { + this.add_cleanup(clearLog); + var record = { + a: { + toString: function() { addLogEntry("toString", [this]); return "b"; } + }, + [Symbol.toStringTag]: { + toString: function () { addLogEntry("toString", [this]); return "nope"; } + }, + c: { + toString: function() { addLogEntry("toString", [this]); return "d"; } + } + }; + // Now make that Symbol-named property not enumerable. + Object.defineProperty(record, Symbol.toStringTag, { enumerable: false }); + assert_array_equals(Reflect.ownKeys(record), + ["a", "c", Symbol.toStringTag]); + + var proxy = new Proxy(record, loggingHandler); + var h = new Headers(proxy); + + assert_equals(log.length, 9); + // The first thing is the [[Get]] of Symbol.iterator to figure out whether + // we're a sequence, during overload resolution. + assert_array_equals(log[0], ["get", record, Symbol.iterator, proxy]); + // Then we have the [[OwnPropertyKeys]] from + // https://webidl.spec.whatwg.org/#es-to-record step 4. + assert_array_equals(log[1], ["ownKeys", record]); + // Then the [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[2], ["getOwnPropertyDescriptor", record, "a"]); + // Then the [[Get]] from step 5.2. + assert_array_equals(log[3], ["get", record, "a", proxy]); + // Then the ToString on the value. + assert_array_equals(log[4], ["toString", record.a]); + // Then the second [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[5], ["getOwnPropertyDescriptor", record, "c"]); + // Then the second [[Get]] from step 5.2. + assert_array_equals(log[6], ["get", record, "c", proxy]); + // Then the ToString on the value. + assert_array_equals(log[7], ["toString", record.c]); + // Then the third [[GetOwnProperty]] from step 5.1. + assert_array_equals(log[8], ["getOwnPropertyDescriptor", record, + Symbol.toStringTag]); + // No [[Get]] because not enumerable. + + // Check the results. + assert_equals([...h].length, 2); + assert_array_equals([...h.keys()], ["a", "c"]); + assert_true(h.has("a")); + assert_equals(h.get("a"), "b"); + assert_true(h.has("c")); + assert_equals(h.get("c"), "d"); +}, "Operation with non-enumerable Symbol keys"); diff --git a/test/fixtures/wpt/fetch/api/headers/headers-structure.any.js b/test/fixtures/wpt/fetch/api/headers/headers-structure.any.js new file mode 100644 index 0000000..d826bca --- /dev/null +++ b/test/fixtures/wpt/fetch/api/headers/headers-structure.any.js @@ -0,0 +1,20 @@ +// META: title=Headers basic +// META: global=window,worker + +"use strict"; + +var headers = new Headers(); +var methods = ["append", + "delete", + "get", + "has", + "set", + //Headers is iterable + "entries", + "keys", + "values" + ]; +for (var idx in methods) + test(function() { + assert_true(methods[idx] in headers, "headers has " + methods[idx] + " method"); + }, "Headers has " + methods[idx] + " method"); diff --git a/test/fixtures/wpt/fetch/api/idlharness.any.js b/test/fixtures/wpt/fetch/api/idlharness.any.js new file mode 100644 index 0000000..7b3c694 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/idlharness.any.js @@ -0,0 +1,21 @@ +// META: global=window,worker +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: timeout=long + +idl_test( + ['fetch'], + ['referrer-policy', 'html', 'dom'], + idl_array => { + idl_array.add_objects({ + Headers: ["new Headers()"], + Request: ["new Request('about:blank')"], + Response: ["new Response()"], + }); + if (self.GLOBAL.isWindow()) { + idl_array.add_objects({ Window: ['window'] }); + } else if (self.GLOBAL.isWorker()) { + idl_array.add_objects({ WorkerGlobalScope: ['self'] }); + } + } +); diff --git a/test/fixtures/wpt/fetch/api/policies/csp-blocked-worker.html b/test/fixtures/wpt/fetch/api/policies/csp-blocked-worker.html new file mode 100644 index 0000000..e8660df --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/csp-blocked-worker.html @@ -0,0 +1,16 @@ + + + + + Fetch in worker: blocked by CSP + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/csp-blocked.html b/test/fixtures/wpt/fetch/api/policies/csp-blocked.html new file mode 100644 index 0000000..99e90df --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/csp-blocked.html @@ -0,0 +1,15 @@ + + + + + Fetch: blocked by CSP + + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/csp-blocked.html.headers b/test/fixtures/wpt/fetch/api/policies/csp-blocked.html.headers new file mode 100644 index 0000000..c8c1e9f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/csp-blocked.html.headers @@ -0,0 +1 @@ +Content-Security-Policy: connect-src 'none'; \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/csp-blocked.js b/test/fixtures/wpt/fetch/api/policies/csp-blocked.js new file mode 100644 index 0000000..28653ff --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/csp-blocked.js @@ -0,0 +1,13 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); +} + +//Content-Security-Policy: connect-src 'none'; cf .headers file +cspViolationUrl = RESOURCES_DIR + "top.txt"; + +promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(cspViolationUrl)); +}, "Fetch is blocked by CSP, got a TypeError"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/policies/csp-blocked.js.headers b/test/fixtures/wpt/fetch/api/policies/csp-blocked.js.headers new file mode 100644 index 0000000..c8c1e9f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/csp-blocked.js.headers @@ -0,0 +1 @@ +Content-Security-Policy: connect-src 'none'; \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/nested-policy.js b/test/fixtures/wpt/fetch/api/policies/nested-policy.js new file mode 100644 index 0000000..b0d1769 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/nested-policy.js @@ -0,0 +1 @@ +// empty, but referrer-policy set on this file diff --git a/test/fixtures/wpt/fetch/api/policies/nested-policy.js.headers b/test/fixtures/wpt/fetch/api/policies/nested-policy.js.headers new file mode 100644 index 0000000..7ffbf17 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/nested-policy.js.headers @@ -0,0 +1 @@ +Referrer-Policy: no-referrer diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-service-worker.https.html b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-service-worker.https.html new file mode 100644 index 0000000..af898aa --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-service-worker.https.html @@ -0,0 +1,18 @@ + + + + + Fetch in service worker: referrer with no-referrer policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-worker.html b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-worker.html new file mode 100644 index 0000000..dbef9bb --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer with no-referrer policy + + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html new file mode 100644 index 0000000..22a6f34 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html @@ -0,0 +1,15 @@ + + + + + Fetch: referrer with no-referrer policy + + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html.headers b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html.headers new file mode 100644 index 0000000..7ffbf17 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.html.headers @@ -0,0 +1 @@ +Referrer-Policy: no-referrer diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js new file mode 100644 index 0000000..60600bf --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js @@ -0,0 +1,19 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); +} + +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py?headers=origin"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + var referrer = resp.headers.get("x-request-referer"); + //Either no referrer header is sent or it is empty + if (referrer) + assert_equals(referrer, "", "request's referrer is empty"); + }); +}, "Request's referrer is empty"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js.headers b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js.headers new file mode 100644 index 0000000..7ffbf17 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-no-referrer.js.headers @@ -0,0 +1 @@ +Referrer-Policy: no-referrer diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-service-worker.https.html b/test/fixtures/wpt/fetch/api/policies/referrer-origin-service-worker.https.html new file mode 100644 index 0000000..4018b83 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-service-worker.https.html @@ -0,0 +1,18 @@ + + + + + Fetch in service worker: referrer with no-referrer policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html new file mode 100644 index 0000000..d87192e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-service-worker.https.html @@ -0,0 +1,17 @@ + + + + + Fetch in service worker: referrer with origin-when-cross-origin policy + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-worker.html b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-worker.html new file mode 100644 index 0000000..f95ae8c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin-worker.html @@ -0,0 +1,16 @@ + + + + + Fetch in worker: referrer with origin-when-cross-origin policy + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html new file mode 100644 index 0000000..5cd79e4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html @@ -0,0 +1,16 @@ + + + + + Fetch: referrer with origin-when-cross-origin policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html.headers b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html.headers new file mode 100644 index 0000000..ad768e6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.html.headers @@ -0,0 +1 @@ +Referrer-Policy: origin-when-cross-origin diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js new file mode 100644 index 0000000..0adadbc --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js @@ -0,0 +1,21 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); + importScripts("/common/get-host-info.sub.js"); + + // A nested importScripts() with a referrer-policy should have no effect + // on overall worker policy. + importScripts("nested-policy.js"); +} + +var referrerOrigin = location.origin + '/'; +var fetchedUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=referer"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.headers.get("x-request-referer"), referrerOrigin, "request's referrer is " + referrerOrigin); + }); +}, "Request's referrer is origin"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js.headers b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js.headers new file mode 100644 index 0000000..ad768e6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-when-cross-origin.js.headers @@ -0,0 +1 @@ +Referrer-Policy: origin-when-cross-origin diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin-worker.html b/test/fixtures/wpt/fetch/api/policies/referrer-origin-worker.html new file mode 100644 index 0000000..bb80dd5 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer with origin policy + + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin.html b/test/fixtures/wpt/fetch/api/policies/referrer-origin.html new file mode 100644 index 0000000..b164afe --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin.html @@ -0,0 +1,16 @@ + + + + + Fetch: referrer with origin policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin.html.headers b/test/fixtures/wpt/fetch/api/policies/referrer-origin.html.headers new file mode 100644 index 0000000..5b29739 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin.html.headers @@ -0,0 +1 @@ +Referrer-Policy: origin diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin.js b/test/fixtures/wpt/fetch/api/policies/referrer-origin.js new file mode 100644 index 0000000..918f8f2 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin.js @@ -0,0 +1,30 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); + + // A nested importScripts() with a referrer-policy should have no effect + // on overall worker policy. + importScripts("nested-policy.js"); +} + +var referrerOrigin = (new URL("/", location.href)).href; +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py?headers=referer"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-request-referer"), referrerOrigin, "request's referrer is " + referrerOrigin); + }); +}, "Request's referrer is origin"); + +promise_test(function(test) { + var referrerUrl = "https://{{domains[www]}}:{{ports[https][0]}}/"; + return fetch(fetchedUrl, { "referrer": referrerUrl }).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-request-referer"), referrerOrigin, "request's referrer is " + referrerOrigin); + }); +}, "Cross-origin referrer is overridden by client origin"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-origin.js.headers b/test/fixtures/wpt/fetch/api/policies/referrer-origin.js.headers new file mode 100644 index 0000000..5b29739 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-origin.js.headers @@ -0,0 +1 @@ +Referrer-Policy: origin diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-service-worker.https.html b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-service-worker.https.html new file mode 100644 index 0000000..634877e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-service-worker.https.html @@ -0,0 +1,18 @@ + + + + + Fetch in worker: referrer with unsafe-url policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-worker.html b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-worker.html new file mode 100644 index 0000000..4204577 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url-worker.html @@ -0,0 +1,17 @@ + + + + + Fetch in worker: referrer with unsafe-url policy + + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html new file mode 100644 index 0000000..10dd79e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html @@ -0,0 +1,16 @@ + + + + + Fetch: referrer with unsafe-url policy + + + + + + + + + + + \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html.headers b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html.headers new file mode 100644 index 0000000..8e23770 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.html.headers @@ -0,0 +1 @@ +Referrer-Policy: unsafe-url diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js new file mode 100644 index 0000000..4d61172 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js @@ -0,0 +1,21 @@ +if (this.document === undefined) { + importScripts("/resources/testharness.js"); + importScripts("../resources/utils.js"); + + // A nested importScripts() with a referrer-policy should have no effect + // on overall worker policy. + importScripts("nested-policy.js"); +} + +var referrerUrl = location.href; +var fetchedUrl = RESOURCES_DIR + "inspect-headers.py?headers=referer"; + +promise_test(function(test) { + return fetch(fetchedUrl).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + assert_equals(resp.headers.get("x-request-referer"), referrerUrl, "request's referrer is " + referrerUrl); + }); +}, "Request's referrer is the full url of current document/worker"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js.headers b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js.headers new file mode 100644 index 0000000..8e23770 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/policies/referrer-unsafe-url.js.headers @@ -0,0 +1 @@ +Referrer-Policy: unsafe-url diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-back-to-original-origin.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-back-to-original-origin.any.js new file mode 100644 index 0000000..74d731f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-back-to-original-origin.any.js @@ -0,0 +1,38 @@ +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +const BASE = location.href; +const IS_HTTPS = new URL(BASE).protocol === 'https:'; +const REMOTE_HOST = get_host_info()['REMOTE_HOST']; +const REMOTE_PORT = + IS_HTTPS ? get_host_info()['HTTPS_PORT'] : get_host_info()['HTTP_PORT']; + +const REMOTE_ORIGIN = + new URL(`//${REMOTE_HOST}:${REMOTE_PORT}`, BASE).origin; +const DESTINATION = new URL('../resources/cors-top.txt', BASE); + +function CreateURL(url, BASE, params) { + const u = new URL(url, BASE); + for (const {name, value} of params) { + u.searchParams.append(name, value); + } + return u; +} + +const redirect = + CreateURL('/fetch/api/resources/redirect.py', REMOTE_ORIGIN, + [{name: 'redirect_status', value: 303}, + {name: 'location', value: DESTINATION.href}]); + +promise_test(async (test) => { + const res = await fetch(redirect.href, {mode: 'no-cors'}); + // This is discussed at https://github.com/whatwg/fetch/issues/737. + assert_equals(res.type, 'opaque'); +}, 'original => remote => original with mode: "no-cors"'); + +promise_test(async (test) => { + const res = await fetch(redirect.href, {mode: 'cors'}); + assert_equals(res.type, 'cors'); +}, 'original => remote => original with mode: "cors"'); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-count.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-count.any.js new file mode 100644 index 0000000..420f9c0 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-count.any.js @@ -0,0 +1,51 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: timeout=long + +/** + * Fetches a target that returns response with HTTP status code `statusCode` to + * redirect `maxCount` times. + */ +function redirectCountTest(maxCount, {statusCode, shouldPass = true} = {}) { + const desc = `Redirect ${statusCode} ${maxCount} times`; + + const fromUrl = `${RESOURCES_DIR}redirect.py`; + const toUrl = fromUrl; + const token1 = token(); + const url = `${fromUrl}?token=${token1}` + + `&max_age=0` + + `&redirect_status=${statusCode}` + + `&max_count=${maxCount}` + + `&location=${encodeURIComponent(toUrl)}`; + + const requestInit = {'redirect': 'follow'}; + + promise_test((test) => { + return fetch(`${RESOURCES_DIR}clean-stash.py?token=${token1}`) + .then((resp) => { + assert_equals( + resp.status, 200, 'Clean stash response\'s status is 200'); + + if (!shouldPass) + return promise_rejects_js(test, TypeError, fetch(url, requestInit)); + + return fetch(url, requestInit) + .then((resp) => { + assert_equals(resp.status, 200, 'Response\'s status is 200'); + return resp.text(); + }) + .then((body) => { + assert_equals( + body, maxCount.toString(), `Redirected ${maxCount} times`); + }); + }); + }, desc); +} + +for (const statusCode of [301, 302, 303, 307, 308]) { + redirectCountTest(20, {statusCode}); + redirectCountTest(21, {statusCode, shouldPass: false}); +} + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-empty-location.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-empty-location.any.js new file mode 100644 index 0000000..487f4d4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-empty-location.any.js @@ -0,0 +1,21 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +// Tests receiving a redirect response with a Location header with an empty +// value. + +const url = RESOURCES_DIR + 'redirect-empty-location.py'; + +promise_test(t => { + return promise_rejects_js(t, TypeError, fetch(url, {redirect:'follow'})); +}, 'redirect response with empty Location, follow mode'); + +promise_test(t => { + return fetch(url, {redirect:'manual'}) + .then(resp => { + assert_equals(resp.type, 'opaqueredirect'); + assert_equals(resp.status, 0); + }); +}, 'redirect response with empty Location, manual mode'); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.any.js new file mode 100644 index 0000000..c9ac13f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.any.js @@ -0,0 +1,35 @@ +// META: global=window +// META: timeout=long +// META: title=Fetch API: keepalive handling +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTP_REMOTE_ORIGIN, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +} = get_host_info(); + + +keepaliveRedirectInUnloadTest('same-origin redirect'); +keepaliveRedirectInUnloadTest( + 'same-origin redirect + preflight', {withPreflight: true}); +keepaliveRedirectInUnloadTest('cross-origin redirect', { + origin1: HTTP_REMOTE_ORIGIN, + origin2: HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT +}); +keepaliveRedirectInUnloadTest('cross-origin redirect + preflight', { + origin1: HTTP_REMOTE_ORIGIN, + origin2: HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT, + withPreflight: true +}); +keepaliveRedirectInUnloadTest( + 'redirect to file URL', + {url2: 'file://tmp/bar.txt', expectFetchSucceed: false}); +keepaliveRedirectInUnloadTest('redirect to data URL', { + url2: 'data:text/plain;base64,cmVzcG9uc2UncyBib2R5', + expectFetchSucceed: false +}); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.https.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.https.any.js new file mode 100644 index 0000000..54e4bc3 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-keepalive.https.any.js @@ -0,0 +1,18 @@ +// META: global=window +// META: title=Fetch API: keepalive handling +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=../resources/keepalive-helper.js + +'use strict'; + +const { + HTTP_NOTSAMESITE_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +keepaliveRedirectTest(`mixed content redirect`, { + origin1: HTTPS_NOTSAMESITE_ORIGIN, + origin2: HTTP_NOTSAMESITE_ORIGIN, + expectFetchSucceed: false +}); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-location-escape.tentative.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-location-escape.tentative.any.js new file mode 100644 index 0000000..779ad70 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-location-escape.tentative.any.js @@ -0,0 +1,46 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +// See https://github.com/whatwg/fetch/issues/883 for the behavior covered by +// this test. As of writing, the Fetch spec has not been updated to cover these. + +// redirectLocation tests that a Location header of |locationHeader| is resolved +// to a URL which ends in |expectedUrlSuffix|. |locationHeader| is interpreted +// as a byte sequence via isomorphic encode, as described in [INFRA]. This +// allows the caller to specify byte sequences which are not valid UTF-8. +// However, this means, e.g., U+2603 must be passed in as "\xe2\x98\x83", its +// UTF-8 encoding, not "\u2603". +// +// [INFRA] https://infra.spec.whatwg.org/#isomorphic-encode +function redirectLocation( + desc, redirectUrl, locationHeader, expectedUrlSuffix) { + promise_test(function(test) { + // Note we use escape() instead of encodeURIComponent(), so that characters + // are escaped as bytes in the isomorphic encoding. + var url = redirectUrl + '?simple=1&location=' + escape(locationHeader); + + return fetch(url, {'redirect': 'follow'}).then(function(resp) { + assert_true( + resp.url.endsWith(expectedUrlSuffix), + resp.url + ' ends with ' + expectedUrlSuffix); + }); + }, desc); +} + +var redirUrl = RESOURCES_DIR + 'redirect.py'; +redirectLocation( + 'Redirect to escaped UTF-8', redirUrl, 'top.txt?%E2%98%83%e2%98%83', + 'top.txt?%E2%98%83%e2%98%83'); +redirectLocation( + 'Redirect to unescaped UTF-8', redirUrl, 'top.txt?\xe2\x98\x83', + 'top.txt?%E2%98%83'); +redirectLocation( + 'Redirect to escaped and unescaped UTF-8', redirUrl, + 'top.txt?\xe2\x98\x83%e2%98%83', 'top.txt?%E2%98%83%e2%98%83'); +redirectLocation( + 'Escaping produces double-percent', redirUrl, 'top.txt?%\xe2\x98\x83', + 'top.txt?%%E2%98%83'); +redirectLocation( + 'Redirect to invalid UTF-8', redirUrl, 'top.txt?\xff', 'top.txt?%FF'); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-location.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-location.any.js new file mode 100644 index 0000000..3d483bd --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-location.any.js @@ -0,0 +1,73 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +const VALID_URL = 'top.txt'; +const INVALID_URL = 'invalidurl:'; +const DATA_URL = 'data:text/plain;base64,cmVzcG9uc2UncyBib2R5'; + +/** + * A test to fetch a URL that returns response redirecting to `toUrl` with + * `status` as its HTTP status code. `expectStatus` can be set to test the + * status code in fetch's Promise response. + */ +function redirectLocationTest(toUrlDesc, { + toUrl = undefined, + status, + expectStatus = undefined, + mode, + shouldPass = true +} = {}) { + toUrlDesc = toUrl ? `with ${toUrlDesc}` : `without`; + const desc = `Redirect ${status} in "${mode}" mode ${toUrlDesc} location`; + const url = `${RESOURCES_DIR}redirect.py?redirect_status=${status}` + + (toUrl ? `&location=${encodeURIComponent(toUrl)}` : ''); + const requestInit = {'redirect': mode}; + if (!expectStatus) + expectStatus = status; + + promise_test((test) => { + if (mode === 'error' || !shouldPass) + return promise_rejects_js(test, TypeError, fetch(url, requestInit)); + if (mode === 'manual') + return fetch(url, requestInit).then((resp) => { + assert_equals(resp.status, 0, "Response's status is 0"); + assert_equals(resp.type, "opaqueredirect", "Response's type is opaqueredirect"); + assert_equals(resp.statusText, '', `Response's statusText is ""`); + assert_true(resp.headers.entries().next().done, "Headers should be empty"); + }); + + if (mode === 'follow') + return fetch(url, requestInit).then((resp) => { + assert_equals( + resp.status, expectStatus, `Response's status is ${expectStatus}`); + }); + assert_unreached(`${mode} is not a valid redirect mode`); + }, desc); +} + +// FIXME: We may want to mix redirect-mode and cors-mode. +for (const status of [301, 302, 303, 307, 308]) { + redirectLocationTest('without location', {status, mode: 'follow'}); + redirectLocationTest('without location', {status, mode: 'manual'}); + // FIXME: Add tests for "error" redirect-mode without location. + + // When succeeded, `follow` mode should have followed all redirects. + redirectLocationTest( + 'valid', {toUrl: VALID_URL, status, expectStatus: 200, mode: 'follow'}); + redirectLocationTest('valid', {toUrl: VALID_URL, status, mode: 'manual'}); + redirectLocationTest('valid', {toUrl: VALID_URL, status, mode: 'error'}); + + redirectLocationTest( + 'invalid', + {toUrl: INVALID_URL, status, mode: 'follow', shouldPass: false}); + redirectLocationTest('invalid', {toUrl: INVALID_URL, status, mode: 'manual'}); + redirectLocationTest('invalid', {toUrl: INVALID_URL, status, mode: 'error'}); + + redirectLocationTest( + 'data', {toUrl: DATA_URL, status, mode: 'follow', shouldPass: false}); + // FIXME: Should this pass? + redirectLocationTest('data', {toUrl: DATA_URL, status, mode: 'manual'}); + redirectLocationTest('data', {toUrl: DATA_URL, status, mode: 'error'}); +} + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-method.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-method.any.js new file mode 100644 index 0000000..9fe086a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-method.any.js @@ -0,0 +1,112 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +// Creates a promise_test that fetches a URL that returns a redirect response. +// +// |opts| has additional options: +// |opts.body|: the request body as a string or blob (default is empty body) +// |opts.expectedBodyAsString|: the expected response body as a string. The +// server is expected to echo the request body. The default is the empty string +// if the request after redirection isn't POST; otherwise it's |opts.body|. +// |opts.expectedRequestContentType|: the expected Content-Type of redirected +// request. +function redirectMethod(desc, redirectUrl, redirectLocation, redirectStatus, method, expectedMethod, opts) { + let url = redirectUrl; + let urlParameters = "?redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + let requestHeaders = { + "Content-Encoding": "Identity", + "Content-Language": "en-US", + "Content-Location": "foo", + }; + let requestInit = {"method": method, "redirect": "follow", "headers" : requestHeaders}; + opts = opts || {}; + if (opts.body) { + requestInit.body = opts.body; + } + + promise_test(function(test) { + return fetch(url + urlParameters, requestInit).then(function(resp) { + let expectedRequestContentType = "NO"; + if (opts.expectedRequestContentType) { + expectedRequestContentType = opts.expectedRequestContentType; + } + + assert_equals(resp.status, 200, "Response's status is 200"); + assert_equals(resp.type, "basic", "Response's type basic"); + assert_equals( + resp.headers.get("x-request-method"), + expectedMethod, + "Request method after redirection is " + expectedMethod); + let hasRequestBodyHeader = true; + if (opts.expectedStripRequestBodyHeader) { + hasRequestBodyHeader = !opts.expectedStripRequestBodyHeader; + } + assert_equals( + resp.headers.get("x-request-content-type"), + expectedRequestContentType, + "Request Content-Type after redirection is " + expectedRequestContentType); + [ + "Content-Encoding", + "Content-Language", + "Content-Location" + ].forEach(header => { + let xHeader = "x-request-" + header.toLowerCase(); + let expectedValue = hasRequestBodyHeader ? requestHeaders[header] : "NO"; + assert_equals( + resp.headers.get(xHeader), + expectedValue, + "Request " + header + " after redirection is " + expectedValue); + }); + assert_true(resp.redirected); + return resp.text().then(function(text) { + let expectedBody = ""; + if (expectedMethod == "POST") { + expectedBody = opts.expectedBodyAsString || requestInit.body; + } + let expectedContentLength = expectedBody ? expectedBody.length.toString() : "NO"; + assert_equals(text, expectedBody, "request body"); + assert_equals( + resp.headers.get("x-request-content-length"), + expectedContentLength, + "Request Content-Length after redirection is " + expectedContentLength); + }); + }); + }, desc); +} + +promise_test(function(test) { + assert_false(new Response().redirected); + return fetch(RESOURCES_DIR + "method.py").then(function(resp) { + assert_equals(resp.status, 200, "Response's status is 200"); + assert_false(resp.redirected); + }); +}, "Response.redirected should be false on not-redirected responses"); + +var redirUrl = RESOURCES_DIR + "redirect.py"; +var locationUrl = "method.py"; + +const stringBody = "this is my body"; +const blobBody = new Blob(["it's me the blob!", " ", "and more blob!"]); +const blobBodyAsString = "it's me the blob! and more blob!"; + +redirectMethod("Redirect 301 with GET", redirUrl, locationUrl, 301, "GET", "GET"); +redirectMethod("Redirect 301 with POST", redirUrl, locationUrl, 301, "POST", "GET", { body: stringBody, expectedStripRequestBodyHeader: true }); +redirectMethod("Redirect 301 with HEAD", redirUrl, locationUrl, 301, "HEAD", "HEAD"); + +redirectMethod("Redirect 302 with GET", redirUrl, locationUrl, 302, "GET", "GET"); +redirectMethod("Redirect 302 with POST", redirUrl, locationUrl, 302, "POST", "GET", { body: stringBody, expectedStripRequestBodyHeader: true }); +redirectMethod("Redirect 302 with HEAD", redirUrl, locationUrl, 302, "HEAD", "HEAD"); + +redirectMethod("Redirect 303 with GET", redirUrl, locationUrl, 303, "GET", "GET"); +redirectMethod("Redirect 303 with POST", redirUrl, locationUrl, 303, "POST", "GET", { body: stringBody, expectedStripRequestBodyHeader: true }); +redirectMethod("Redirect 303 with HEAD", redirUrl, locationUrl, 303, "HEAD", "HEAD"); +redirectMethod("Redirect 303 with TESTING", redirUrl, locationUrl, 303, "TESTING", "GET", { expectedStripRequestBodyHeader: true }); + +redirectMethod("Redirect 307 with GET", redirUrl, locationUrl, 307, "GET", "GET"); +redirectMethod("Redirect 307 with POST (string body)", redirUrl, locationUrl, 307, "POST", "POST", { body: stringBody , expectedRequestContentType: "text/plain;charset=UTF-8"}); +redirectMethod("Redirect 307 with POST (blob body)", redirUrl, locationUrl, 307, "POST", "POST", { body: blobBody, expectedBodyAsString: blobBodyAsString }); +redirectMethod("Redirect 307 with HEAD", redirUrl, locationUrl, 307, "HEAD", "HEAD"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-mode.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-mode.any.js new file mode 100644 index 0000000..9f1ff98 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-mode.any.js @@ -0,0 +1,59 @@ +// META: script=/common/get-host-info.sub.js + +var redirectLocation = "cors-top.txt"; +const { ORIGIN, REMOTE_ORIGIN } = get_host_info(); + +function testRedirect(origin, redirectStatus, redirectMode, corsMode) { + var url = new URL("../resources/redirect.py", self.location); + if (origin === "cross-origin") { + url.host = get_host_info().REMOTE_HOST; + url.port = get_host_info().HTTP_PORT; + } + + var urlParameters = "?redirect_status=" + redirectStatus; + urlParameters += "&location=" + encodeURIComponent(redirectLocation); + + var requestInit = {redirect: redirectMode, mode: corsMode}; + + promise_test(function(test) { + if (redirectMode === "error" || + (corsMode === "no-cors" && redirectMode !== "follow" && origin !== "same-origin")) + return promise_rejects_js(test, TypeError, fetch(url + urlParameters, requestInit)); + if (redirectMode === "manual") + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 0, "Response's status is 0"); + assert_equals(resp.type, "opaqueredirect", "Response's type is opaqueredirect"); + assert_equals(resp.statusText, "", "Response's statusText is \"\""); + assert_equals(resp.url, url + urlParameters, "Response URL should be the original one"); + }); + if (redirectMode === "follow") + return fetch(url + urlParameters, requestInit).then(function(resp) { + if (corsMode !== "no-cors" || origin === "same-origin") { + assert_true(new URL(resp.url).pathname.endsWith(redirectLocation), "Response's url should be the redirected one"); + assert_equals(resp.status, 200, "Response's status is 200"); + } else { + assert_equals(resp.type, "opaque", "Response is opaque"); + } + }); + assert_unreached(redirectMode + " is no a valid redirect mode"); + }, origin + " redirect " + redirectStatus + " in " + redirectMode + " redirect and " + corsMode + " mode"); +} + +for (var origin of ["same-origin", "cross-origin"]) { + for (var statusCode of [301, 302, 303, 307, 308]) { + for (var redirect of ["error", "manual", "follow"]) { + for (var mode of ["cors", "no-cors"]) + testRedirect(origin, statusCode, redirect, mode); + } + } +} + +promise_test(async (t) => { + const destination = `${ORIGIN}/common/blank.html`; + // We use /common/redirect.py intentionally, as we want a CORS error. + const url = + `${REMOTE_ORIGIN}/common/redirect.py?location=${destination}`; + await promise_rejects_js(t, TypeError, fetch(url, { redirect: "manual" })); +}, "manual redirect with a CORS error should be rejected"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-origin.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-origin.any.js new file mode 100644 index 0000000..6001c50 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-origin.any.js @@ -0,0 +1,68 @@ +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +const { + HTTP_ORIGIN, + HTTP_REMOTE_ORIGIN, +} = get_host_info(); + +/** + * Fetches `fromUrl` with 'cors' and 'follow' modes that returns response to + * redirect to `toUrl`. + */ +function testOriginAfterRedirection( + desc, method, fromUrl, toUrl, statusCode, expectedOrigin) { + desc = `[${method}] Redirect ${statusCode} ${desc}`; + const token1 = token(); + const url = `${fromUrl}?token=${token1}&max_age=0` + + `&redirect_status=${statusCode}` + + `&location=${encodeURIComponent(toUrl)}`; + + const requestInit = {method, 'mode': 'cors', 'redirect': 'follow'}; + + promise_test(function(test) { + return fetch(`${RESOURCES_DIR}clean-stash.py?token=${token1}`) + .then((cleanResponse) => { + assert_equals( + cleanResponse.status, 200, + `Clean stash response's status is 200`); + return fetch(url, requestInit).then((redirectResponse) => { + assert_equals( + redirectResponse.status, 200, + `Inspect header response's status is 200`); + assert_equals( + redirectResponse.headers.get('x-request-origin'), + expectedOrigin, 'Check origin header'); + }); + }); + }, desc); +} + +const FROM_URL = `${RESOURCES_DIR}redirect.py`; +const CORS_FROM_URL = + `${HTTP_REMOTE_ORIGIN}${dirname(location.pathname)}${FROM_URL}`; +const TO_URL = `${HTTP_ORIGIN}${dirname(location.pathname)}${ + RESOURCES_DIR}inspect-headers.py?headers=origin`; +const CORS_TO_URL = `${HTTP_REMOTE_ORIGIN}${dirname(location.pathname)}${ + RESOURCES_DIR}inspect-headers.py?cors&headers=origin`; + +for (const statusCode of [301, 302, 303, 307, 308]) { + for (const method of ['GET', 'POST']) { + testOriginAfterRedirection( + 'Same origin to same origin', method, FROM_URL, TO_URL, statusCode, + null); + testOriginAfterRedirection( + 'Same origin to other origin', method, FROM_URL, CORS_TO_URL, + statusCode, HTTP_ORIGIN); + testOriginAfterRedirection( + 'Other origin to other origin', method, CORS_FROM_URL, CORS_TO_URL, + statusCode, HTTP_ORIGIN); + // TODO(crbug.com/1432059): Fix broken tests. + testOriginAfterRedirection( + 'Other origin to same origin', method, CORS_FROM_URL, `${TO_URL}&cors`, + statusCode, 'null'); + } +} + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-referrer-override.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-referrer-override.any.js new file mode 100644 index 0000000..337f8dd --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-referrer-override.any.js @@ -0,0 +1,104 @@ +// META: timeout=long +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function getExpectation(expectations, initPolicy, initScenario, redirectPolicy, redirectScenario) { + let policies = [ + expectations[initPolicy][initScenario], + expectations[redirectPolicy][redirectScenario] + ]; + + if (policies.includes("omitted")) { + return null; + } else if (policies.includes("origin")) { + return referrerOrigin; + } else { + // "stripped-referrer" + return referrerUrl; + } +} + +function testReferrerAfterRedirection(desc, redirectUrl, redirectLocation, referrerPolicy, redirectReferrerPolicy, expectedReferrer) { + var url = redirectUrl; + var urlParameters = "?location=" + encodeURIComponent(redirectLocation); + var description = desc + ", " + referrerPolicy + " init, " + redirectReferrerPolicy + " redirect header "; + + if (redirectReferrerPolicy) + urlParameters += "&redirect_referrerpolicy=" + redirectReferrerPolicy; + + var requestInit = {"redirect": "follow", "referrerPolicy": referrerPolicy}; + promise_test(function(test) { + return fetch(url + urlParameters, requestInit).then(function(response) { + assert_equals(response.status, 200, "Inspect header response's status is 200"); + assert_equals(response.headers.get("x-request-referer"), expectedReferrer ? expectedReferrer : null, "Check referrer header"); + }); + }, description); +} + +var referrerOrigin = get_host_info().HTTP_ORIGIN + "/"; +var referrerUrl = location.href; + +var redirectUrl = RESOURCES_DIR + "redirect.py"; +var locationUrl = get_host_info().HTTP_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?headers=referer"; +var crossLocationUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=referer"; + +var expectations = { + "no-referrer": { + "same-origin": "omitted", + "cross-origin": "omitted" + }, + "no-referrer-when-downgrade": { + "same-origin": "stripped-referrer", + "cross-origin": "stripped-referrer" + }, + "origin": { + "same-origin": "origin", + "cross-origin": "origin" + }, + "origin-when-cross-origin": { + "same-origin": "stripped-referrer", + "cross-origin": "origin", + }, + "same-origin": { + "same-origin": "stripped-referrer", + "cross-origin": "omitted" + }, + "strict-origin": { + "same-origin": "origin", + "cross-origin": "origin" + }, + "strict-origin-when-cross-origin": { + "same-origin": "stripped-referrer", + "cross-origin": "origin" + }, + "unsafe-url": { + "same-origin": "stripped-referrer", + "cross-origin": "stripped-referrer" + } +}; + +for (var initPolicy in expectations) { + for (var redirectPolicy in expectations) { + + // Redirect to same-origin URL + testReferrerAfterRedirection( + "Same origin redirection", + redirectUrl, + locationUrl, + initPolicy, + redirectPolicy, + getExpectation(expectations, initPolicy, "same-origin", redirectPolicy, "same-origin")); + + // Redirect to cross-origin URL + testReferrerAfterRedirection( + "Cross origin redirection", + redirectUrl, + crossLocationUrl, + initPolicy, + redirectPolicy, + getExpectation(expectations, initPolicy, "same-origin", redirectPolicy, "cross-origin")); + } +} + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-referrer.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-referrer.any.js new file mode 100644 index 0000000..99fda42 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-referrer.any.js @@ -0,0 +1,66 @@ +// META: timeout=long +// META: script=/common/utils.js +// META: script=../resources/utils.js +// META: script=/common/get-host-info.sub.js + +function testReferrerAfterRedirection(desc, redirectUrl, redirectLocation, referrerPolicy, redirectReferrerPolicy, expectedReferrer) { + var url = redirectUrl; + var urlParameters = "?location=" + encodeURIComponent(redirectLocation); + + if (redirectReferrerPolicy) + urlParameters += "&redirect_referrerpolicy=" + redirectReferrerPolicy; + + var requestInit = {"redirect": "follow", "referrerPolicy": referrerPolicy}; + + promise_test(function(test) { + return fetch(url + urlParameters, requestInit).then(function(response) { + assert_equals(response.status, 200, "Inspect header response's status is 200"); + assert_equals(response.headers.get("x-request-referer"), expectedReferrer ? expectedReferrer : null, "Check referrer header"); + }); + }, desc); +} + +var referrerOrigin = get_host_info().HTTP_ORIGIN + "/"; +var referrerUrl = location.href; + +var redirectUrl = RESOURCES_DIR + "redirect.py"; +var locationUrl = get_host_info().HTTP_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?headers=referer"; +var crossLocationUrl = get_host_info().HTTP_REMOTE_ORIGIN + dirname(location.pathname) + RESOURCES_DIR + "inspect-headers.py?cors&headers=referer"; + +testReferrerAfterRedirection("Same origin redirection, empty init, unsafe-url redirect header ", redirectUrl, locationUrl, "", "unsafe-url", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, no-referrer-when-downgrade redirect header ", redirectUrl, locationUrl, "", "no-referrer-when-downgrade", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, same-origin redirect header ", redirectUrl, locationUrl, "", "same-origin", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, origin redirect header ", redirectUrl, locationUrl, "", "origin", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty init, origin-when-cross-origin redirect header ", redirectUrl, locationUrl, "", "origin-when-cross-origin", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty init, no-referrer redirect header ", redirectUrl, locationUrl, "", "no-referrer", null); +testReferrerAfterRedirection("Same origin redirection, empty init, strict-origin redirect header ", redirectUrl, locationUrl, "", "strict-origin", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty init, strict-origin-when-cross-origin redirect header ", redirectUrl, locationUrl, "", "strict-origin-when-cross-origin", referrerUrl); + +testReferrerAfterRedirection("Same origin redirection, empty redirect header, unsafe-url init ", redirectUrl, locationUrl, "unsafe-url", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, no-referrer-when-downgrade init ", redirectUrl, locationUrl, "no-referrer-when-downgrade", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, same-origin init ", redirectUrl, locationUrl, "same-origin", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, origin init ", redirectUrl, locationUrl, "origin", "", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, origin-when-cross-origin init ", redirectUrl, locationUrl, "origin-when-cross-origin", "", referrerUrl); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, no-referrer init ", redirectUrl, locationUrl, "no-referrer", "", null); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, strict-origin init ", redirectUrl, locationUrl, "strict-origin", "", referrerOrigin); +testReferrerAfterRedirection("Same origin redirection, empty redirect header, strict-origin-when-cross-origin init ", redirectUrl, locationUrl, "strict-origin-when-cross-origin", "", referrerUrl); + +testReferrerAfterRedirection("Cross origin redirection, empty init, unsafe-url redirect header ", redirectUrl, crossLocationUrl, "", "unsafe-url", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty init, no-referrer-when-downgrade redirect header ", redirectUrl, crossLocationUrl, "", "no-referrer-when-downgrade", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty init, same-origin redirect header ", redirectUrl, crossLocationUrl, "", "same-origin", null); +testReferrerAfterRedirection("Cross origin redirection, empty init, origin redirect header ", redirectUrl, crossLocationUrl, "", "origin", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty init, origin-when-cross-origin redirect header ", redirectUrl, crossLocationUrl, "", "origin-when-cross-origin", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty init, no-referrer redirect header ", redirectUrl, crossLocationUrl, "", "no-referrer", null); +testReferrerAfterRedirection("Cross origin redirection, empty init, strict-origin redirect header ", redirectUrl, crossLocationUrl, "", "strict-origin", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty init, strict-origin-when-cross-origin redirect header ", redirectUrl, crossLocationUrl, "", "strict-origin-when-cross-origin", referrerOrigin); + +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, unsafe-url init ", redirectUrl, crossLocationUrl, "unsafe-url", "", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, no-referrer-when-downgrade init ", redirectUrl, crossLocationUrl, "no-referrer-when-downgrade", "", referrerUrl); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, same-origin init ", redirectUrl, crossLocationUrl, "same-origin", "", null); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, origin init ", redirectUrl, crossLocationUrl, "origin", "", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, origin-when-cross-origin init ", redirectUrl, crossLocationUrl, "origin-when-cross-origin", "", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, no-referrer init ", redirectUrl, crossLocationUrl, "no-referrer", "", null); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, strict-origin init ", redirectUrl, crossLocationUrl, "strict-origin", "", referrerOrigin); +testReferrerAfterRedirection("Cross origin redirection, empty redirect header, strict-origin-when-cross-origin init ", redirectUrl, crossLocationUrl, "strict-origin-when-cross-origin", "", referrerOrigin); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-schemes.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-schemes.any.js new file mode 100644 index 0000000..31ec124 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-schemes.any.js @@ -0,0 +1,19 @@ +// META: title=Fetch: handling different schemes in redirects +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +// All non-HTTP(S) schemes cannot survive redirects +var url = "../resources/redirect.py?location="; +var tests = [ + url + "mailto:a@a.com", + url + "data:,HI", + url + "facetime:a@a.org", + url + "about:blank", + url + "about:unicorn", + url + "blob:djfksfjs" +]; +tests.forEach(function(url) { + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url)) + }) +}) diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-to-dataurl.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-to-dataurl.any.js new file mode 100644 index 0000000..9d0f147 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-to-dataurl.any.js @@ -0,0 +1,28 @@ +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +var dataURL = "data:text/plain;base64,cmVzcG9uc2UncyBib2R5"; +var body = "response's body"; +var contentType = "text/plain"; + +function redirectDataURL(desc, redirectUrl, mode) { + var url = redirectUrl + "?cors&location=" + encodeURIComponent(dataURL); + + var requestInit = {"mode": mode}; + + promise_test(function(test) { + return promise_rejects_js(test, TypeError, fetch(url, requestInit)); + }, desc); +} + +var redirUrl = get_host_info().HTTP_ORIGIN + "/fetch/api/resources/redirect.py"; +var corsRedirUrl = get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/api/resources/redirect.py"; + +redirectDataURL("Testing data URL loading after same-origin redirection (cors mode)", redirUrl, "cors"); +redirectDataURL("Testing data URL loading after same-origin redirection (no-cors mode)", redirUrl, "no-cors"); +redirectDataURL("Testing data URL loading after same-origin redirection (same-origin mode)", redirUrl, "same-origin"); + +redirectDataURL("Testing data URL loading after cross-origin redirection (cors mode)", corsRedirUrl, "cors"); +redirectDataURL("Testing data URL loading after cross-origin redirection (no-cors mode)", corsRedirUrl, "no-cors"); + +done(); diff --git a/test/fixtures/wpt/fetch/api/redirect/redirect-upload.h2.any.js b/test/fixtures/wpt/fetch/api/redirect/redirect-upload.h2.any.js new file mode 100644 index 0000000..521bd3a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/redirect/redirect-upload.h2.any.js @@ -0,0 +1,33 @@ +// META: global=window,worker +// META: script=../resources/utils.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +const redirectUrl = RESOURCES_DIR + "redirect.h2.py"; +const redirectLocation = "top.txt"; + +async function fetchStreamRedirect(statusCode) { + const url = RESOURCES_DIR + "redirect.h2.py" + + `?redirect_status=${statusCode}&location=${redirectLocation}`; + const requestInit = {method: "POST"}; + requestInit["body"] = new ReadableStream({start: controller => { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode("Test")); + controller.close(); + }}); + requestInit.duplex = "half"; + return fetch(url, requestInit); +} + +promise_test(async () => { + const resp = await fetchStreamRedirect(303); + assert_equals(resp.status, 200); + assert_true(new URL(resp.url).pathname.endsWith(redirectLocation), + "Response's url should be the redirected one"); +}, "Fetch upload streaming should be accepted on 303"); + +for (const statusCode of [301, 302, 307, 308]) { + promise_test(t => { + return promise_rejects_js(t, TypeError, fetchStreamRedirect(statusCode)); + }, `Fetch upload streaming should fail on ${statusCode}`); +} diff --git a/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-frame.https.html b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-frame.https.html new file mode 100644 index 0000000..f3f9f78 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-frame.https.html @@ -0,0 +1,51 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-iframe.https.html b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-iframe.https.html new file mode 100644 index 0000000..1aa5a56 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-iframe.https.html @@ -0,0 +1,51 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-no-load-event.https.html b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-no-load-event.https.html new file mode 100644 index 0000000..2fb4aae --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-no-load-event.https.html @@ -0,0 +1,138 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-prefetch.https.html b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-prefetch.https.html new file mode 100644 index 0000000..db99202 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-prefetch.https.html @@ -0,0 +1,46 @@ + +Fetch destination test for prefetching + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-worker.https.html b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-worker.https.html new file mode 100644 index 0000000..5935c1f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination-worker.https.html @@ -0,0 +1,60 @@ + +Fetch destination tests for resources with no load event + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/fetch-destination.https.html b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination.https.html new file mode 100644 index 0000000..1b6cf16 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/fetch-destination.https.html @@ -0,0 +1,485 @@ + +Fetch destination tests + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.css b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.css new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es.headers b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es.headers new file mode 100644 index 0000000..9bb8bad --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.es.headers @@ -0,0 +1 @@ +Content-Type: text/event-stream diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.html b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.html new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.json b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.json @@ -0,0 +1 @@ +{} diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.png b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.png new file mode 100644 index 0000000000000000000000000000000000000000..01c9666a8de9d5535615aff830810e5df4b2156f GIT binary patch literal 18299 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 bDgs((CoOJ&@$4~l+kmsZyIqm(x-I_(KvUo& literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.ttf b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9023592ef5aa83a03dd6957398897a585062ca57 GIT binary patch literal 2528 zcmds3+iM(E9RAMC>}--IZHkSlbcJb}Hmx+fn~4|=c}SaNsWzb@-3F9mI_^#~3%fJR z?rbg~7_kpEQi_7Nf1zrjK~Q`sQnW2nkwV|M-@%oK-E?>x_(MpM29?acOqAH}eAZX8>i{v90{QD@UK8 z?l;0S4h7BS^-meAn|!xZ@)uj54c;RECHbzRm$TF>nv6e6K2fq3%b3Ch^~cB?u2r(v zkH7ad5c`2P>9SY#cXvOjFn>GsdB|P~`n~De%#NWyu}z}@xPEE<^GzId1b6hq`YrNJ zpl7(G&#mANPHPA{)^6*E!$^@bL|Q0mLqc}TB|Swb8%8pe2%7wX7*!uCHz~QWfyJ-r z7tPW^=Wn!Ro%h$|>{uSdXvUa|I&fOQrS7LPvS9~Z(v&zMA=;65&=C=`DhY|mZ&Kj!3HhbNxDPn<$e@rAG|9>`>_U%Yaa|m@d0+T+9$}A07}*9%}w^Ondom zl3bI=hUcy>--uAx7wLGEX&Kzn_%3s9JFtg_4YL!pi){|FXP{H;ZW!kJ85v$FZVvZc z=7R1t%y+FTKz4K3D>LVrE9j_0Kg^W>kV`b=3OX8csX3V|_H9EhakU|rxWPtGG$xDs zeRLLqoPhkgUkcxKN!R$Lr8Sp8i&%_ketg8+5v}5Y_$i__v?%)`I)-*-GNN_L7dTC! z$#2##gbi9?mv|+j6|{;sB3i|`_#mP+>{8kyItD{YMzl`3g%NltV+j=$Fb4-d3>-ub zhlow2(MK>aNlgJoLYZ6^7Cnmetngdg!aW6>yiIwPzj@l!;1b)kFc{MzW$@m3p1uag z87D`H8(I%iBJ=u;J%|+dLb#J*WgAu=<5fZ*DXp;5R9MY}C{;>IjO(NK5lxbD9RfzY z@=~QR=lI6K+#$nE_oaaWNmxCd;m?tPvxY zJ8xC9c9rx5g?W}XCSRSBcZdsV~Z&>YL1E97mjWcg0TE4dJ(nei;|UEZ=>^4@JlJ9c3=csI~@nRO6C WZTNf5{_GpcUH|0vRERIFfAlvXi@|mP literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy_audio.mp3 b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy_audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0091330f1ecd9ba5a70c6e5e6da284a061e2b99f GIT binary patch literal 20498 zcmZsiWn5HU^zVlmV(6KnQy7NsQaXk%=@O+|Iz%z(?rxv)P(k3_ zb9nB(|NG+Z7sLnV?ET$q$6CL&j*hw{Aq;9WC&xR}_* zU;q089QgJA-(PinTz$b`!2gH!!(fIRFhUYC3TiYh0~6~dP8<)vkg%A9^c6WpWxTqU zuD+p(nWdG@4SOdScTaD>fZ&kO`(cq$vGGZ%=~=n?MJ1&bRkihv&21f>-Ope34GfP> zOijjUW*tU7Jq?YVH_4uh7pnj2Y*7qrj9@Paq<80cI8+sN9(;)6iMIS zy-U&)k+5)%pM2CkKwcPd@iVYbrtU~$ksw!0jN+t%5e=7o8nQh5MhYC@2kDq*#@`+# zsK7{ar)wk09d8FmI5^gLh~;2tehrx*jOfN-)=&RRuc|Inu!X0KP*w9bSA4&`UPVW@ zp7H2Un6kXd?biLMDl0ZL+M(~;dujLUd%b4cn?1l@=C-R_>@m*lJFF4{BBf8jR{tDj zg!NT41;_W)S>bNizZHnH8(#CbkZAN|B%SXen;bE#5Xg3Qn9nflbBSygIMr>Pnd{>W zpZiQvB4@>n#-Nob-PA1Q{mylmr)s6lr0(|FvK^Tdgb#*`KtQClrmlQ2{usWF zXHNoZoILb1(w|agqDMZ?nS)pRpLvlla=8 zekY?v7y!%TpqrPp%4JjU%(-o)$jqh)dFN?&eX4V6 zgw|#Czh_+F^91bP0DsC& zAPN@=rM;e9^i4?1XOR9tc zqt8{N3>b!u%YUfGrDI8Ws8$Rj?h3t;NU^lh%S+7q^n*iDYJYnYOQ_otK%p2rLBwCzW>nM{C0ga-ZT%n0;v?DEE(}>pXGoI_i+Gm)E^GS-65sTvs$Jr^weWeVq>&1Xb8rU&rx|v= z--l|ipO)>{c0^B)l@Dj|u6EPLOWm)-60$#F6HZSlE>g^}CO(#idI8`vFT(Z$q5?G` zpi_psjufnLnjAR}Kbp8&HhK=ESw=u?yipu;ctWMhd!+U@8mkNdXN}%ecf*DZg>l}# z-(J#{DX#EIICr)dV!O(|#=T*>w;mkZK0B;S8uZOsmP6;3{!7P=v@qcM#Wn^vyvpDS zqofZZ2F{c{T>l&4b)@n96>9jI^K-$mqzbxgG4DhcHWM2c*(*AHX)-9~$nSLd)5J=y zXfT)Oz$=#@_=lKAB-F1)*1G+skDx!RO}<>rJfRym**8>jt1j=%&CIEt<#<*e0y?Do z=ZeIDKtYOcz^x7r9RU8FH9S!2%$7^7h-XaQcipm*NXF7rk%_IC`7vU~fur`1MFn;7 zwGCo7_4UXa8+fx1+i$mxi*$bU66)a2Ev)l*%l18y_6j>9wF7{D%g=uxpuem%E{9}r zyNorpyvLE|81guRG6y~q%5bqn@^JmfIBqtT4h^wNKi0P?{3v`TW2`)D&k>me@ZI(L zlgY$l-@ac?+D>x^QkStig@u8osp<^bNzyS_IfuBf56S?aM>gz6FHX5t+7H)ItON!z zSzzZs?GBZ{BG=_`UuBGxFob?J@oEEK-&yOJ}?+)A>Y-F zr_tY;F>)eTzQ%E2P2gY4@V&2nkcGa|eYmhtcA4{8eB#<_ zWZmADCS3{D=5kp#Crlh2ZOZEpA6X7ti}|2hVWoo}Pz&;+)v6)Q6oJt5CsI#a8D3;1u+6$|IUIu+Itn+;Wmi72 zemdf=)*9q&b`=@ZwM2sAOr{UuBo4hEN#wa$a!hlcNLGTy?Ao0qt`ZR@)`u)Iv5k}Y zA(=0F^Yum2Nyn1I84~d(tKCu$UwFeYD!0qdL&@HaUGA2UeG056tnZ+Psf(_%CelSb zAXO^I9?D{_wQP;DPRq?_#jN-5>_D3Z1lnPc_k=M+6L-!PJO6-7?3Tz)dJ#;%C{YF` zE)AA~GJA-$ugr%VTAe>VE(U-j&*DGFn@qB$I$5(RI<19yuk{-cc#o|GlrXQLS%kGr z0)LYTC$@#?WBq13)Q2S4E|whKjw>{C8FKXuqIU~&*&POJG)a>5=@#u~DrA@A<9b?K=YM{G&ptCT+&%iyLUk(k!%f%vGLPJOQPJDptPapZxO| z_ip?H4y29?K779+3juwn8}Niqxony2-xEyV?;MfT^6jSY zgw&++aB252po~I-&$Txyhd|BGrF^;=akHAw4U^HRV@dpkaflj^q|qhcn%9;nD@P4d z^ni!s8}ca8We8}W@utTqjG!R>uR6N6RC3wA-7y1a!nD*EZt-=o?`A3$BQ*f*&t)S+ z)hF_ndI)z}0^tTlQzaxI6e^TW})0Ewao%yF;PB86xZ^Ju^E@dhh zGR~iRn+prk-Ss3yf))zLZFskL(O>Q1!ZFxgzw8U~X_0Q$&GE9`a7RUdo%V^$YQ@O3j)$7BrxQCHRp^K)S z7`|}u{*uoyQ#<~yQ5I9-@Wc4g8Z%?eoh?f_!MB>;an3!PzJWGi55Lm@pe*ep+a(+X z^pEMeBa9YqnSvZk%G!ik+=({M7U5X=j@vu@b_cFu9|j!bU#;@lJM~VP_3d-Sxvsm% zRO*RGCv(iACc6fQsOXq4C0kY?a@2_9%KQ6?;@$27Z*_VA0Q6k(Uu4FOn`r$~AfP=u zJzMA=%0X%yB;9bxm`QXh|7SpMHY2F5wJsIH3vij4X$NWRsba_s7-tZg6z1;PGAKIn zm?{(v->k?^7{_eEKd)2b>5YXX&6PFT> z>l>b!9CT<%e@En8-u*XJk)Ul`3|C2kfR_(Zf=?E;j8$o}$|IP?;)ZeYpUKm>%Zra&~w~8$~iXeCc__I~hn*4`p-0}S(McmB; zagK-tOR7ZHD6zXT(Il#)H3=t0KtVylrzU>5foQPQ6k^qx3`BIy_SBaJ3T1F?0AL85zSRhDDxth3GR$7d!>r;T%c!8R8d5yW zu4&+?#VwsC_MC2^idvp$EDV@G7>)a(?xE^_`#J=)&&1;L7u>5E&K_vK>u8#X0tt~_ zr+h<^1UNO~=BxVJ5|r@K7&228pMF}>LD_K_M-)tK2u-kqJGJl2>!t70I~6htifj2> zN?2+RAdoN$hhE8W*c&Rqx{FSZxLAfoMBR)KY^Pt8V#0S@h!ev< zV#tY!W4c(-9FD5M+k$5d@A3A&Fs9bk2Mn+);qogkj+3D4;!XUQWqB;wJ`Fz8KRQsu zNH7*FNnw8-g6Abi@W~L3dZ3JMF+f1SnQS}>(JDr;@m#({3NX$B+mO2mLJba66_|CE zWY+)81?vsRbD#*V{GXP!`#X`UeRxoU-~G4u7ruh1bm9`2W>wunio48PhNJsht%z9R zwNJ_e2kNi%UQ@X}&6b>zDFgxS^35%|WTlkCdg7)y@=FHH$ipM)w>ql;KD4S{>g}$< z+h!v4eBT=YCe`bQY(ca4dSeZQGnW|rzAVP$ZL2l$mTrAxWG z-7r6Rlale)4qbBS(o+bi0{2|tw_RF3!fRpCbE1hQuhQv?lGUt$dfQu`dfH4T40UE^ zO!##4mUBnsa1zPD6+|@wc6DTw3@eJm?s{Mw|ph#lYHqGZG)PIuo%q(xD zQG?RVWJ<`h-9bkB;$rpUl+#S*p45irrs~~mp_@S)mp06Q*_)1MD&hXp4S!?D!B4Ov zBhB%xx-&R7$L+~Vg68=U&zN?et2#GzX64@|3#+az+$A=`*p$p%sfG+ezomj~JUWWg z$su!eQ6q#hRmTzn!e1I+(kH}go)M+f(5dinn%sA4RK~@4Ov#G=kZ7_9pru!_FV?$B z`s7U<7#G$$s>upTdtSE+J?|@i`^Sdu+}8c>#GfPc^GE?5sTmnAq)8l$_@Q|0X!r;QHFK z+gX4M0y04TbEH5LnC2v@zMF62q#a)^mY}^`cfBZQewK0T2y`sB+3f}t)ml0y3!nU> z6uCMxuO3~%Gf=P4fQzW)PMFAQsRxb^ZWVO~1=@aft4|K?u&5x5ktbU~*P8QM7^J%S z0_PBr3+lr~2;P{s;i1Qe947S3==C2#+hgf}zPb|aHujFyj8Mf&)BDQ8LcYfs*Q>7r z1y&RCZDg~q?Z{9byi$VNbS-i|1T#7B3!Eya0;d7YB^-%}TjLtJ_A9Lcgu|D|h9IDO zC_S(r0@Lgz6a#(fZ4)mtVp5cwfY*-+CrtpNx+$t(eG-I6j*TM?*4#TdZ8*Jqp`C3cPtsK+qHd%0;*T z2PD5QJ@_0@vT3a%u5q-jGue3xnso1tY>y~^N^NI1t}H)NdxF>50|igVaq@+~R-a*} zkI2ziHPDb0k(CvZHisH~1grMf8uq|>LM2M?t?z{4=_~P#XsDlh)ZBj|xDmlO9k(zb z5wZQE7RAPqRgPK$J368ZJNPmyuiIM8r1mH~an@zO{10)?Q#l<)aveIp`(zSQIf@df zgC;a*ru&UPoI$XM_{aZzo(6{`?5|?K#@`4eZGwPalL=sq*kKy62-B6X(!k3ekOI_hyib#} zY{I4&!>?*Nkr!y!%h6$FMhK3BFC}Q$$8fI8SkPgg>BZI_Q5p4JC-}&@v7x_4rnGQ; zG6HO9H{9vzstoG@IGf8x8ESRiQJv9oc`n@kv8+&9~sZfN-`6KYng`) zNWN8lh^QrlEEIx8{MU~F2g^IhHGJ}An3c$AwyDs`nZZwEb@r(m+R;&awl+@Jix!+NZ&{3Zm+kcqNV&kz|rNgoIkZ zthomSq(^t)34?*Y+<3H8(-ckW6FF+5a*y*b)1OQ=+Lhw4wDIO>0ow4HXhe)cOeOuHV3gy^KrREYa1nQ1 z#oYBoLOSDiA6cM$v<|hipltqbWpl{9j(`alZ2;3VInITtFVIuxuU!u@-Yj-+(8*L` z;m)Pe+v?PD(X7iwYe?WpEM(-y{1KMRVe1Tkc^i`Hrh>?rtj+{5!{zn*pp2z5XpjB} zDYSW+7RG}1CWM0Gx`FQfqhAhs0N{~0>GWFRuAA&GsrvN$95aq(KH&u_#z8v?<#dV+G_Kq;uWHwP0_H-d)`elU>mD?TH&jQTQfG3u%pk;Kgr z$D_L>_ElhLI(aGU)vmhxuYp&Vpf$tJiQ%n>qAF6FFQZ)uA)pti15Xrm55Z1bo$(|Q z138)x1Q|*Dw+1BWUTgH02c3v}S^FfAI)I-B?kYqbQomKSj{7+l&5)@&aa+Nr{KUVd zi}COVHh{pVh4YPoXtS=!?+ZZz_}SW-49ar&!8ytga5FF|@VwPAH^L47lsa&e*rvjO!wuo@ZA8~{E}Y;$40k{>!_TgfrFg18D$?y7;(OmDUiSa0X`d>1GJY>-{?vY@?Us!(oks3w4RFB5z zX=Bf(Qy*p2F0$zusUXDif zv2O)or2PwZaUxOzmH3iBa{qIM*8l$-;_PJ%>c5{ zKE0_YAsM(?e~1v?N(1;jd|Bz~h>5OT7wB4|YNS3z6;=;pzxY~INEzNp(CB*2th+Q` zp;`BMi{}26vvJZ)8PJkcHX)9d!n&16D}fH||7n-$LTs*@aJNY>^Flxe43gfEM$&kF zfXm&)hcl8hs-K5&ogm$0yyFt9Prw3oKJ`iRrT9qN8?mYMAN@+>zfGFvIAGcw)zoM3 z4zv;)tJT!xcuh-UXQ_VPl{vNiUPCZY$_-mc&UiK1;<9pwXDk&0njz;*1oJ~0k+N5; zf|TB;I!2C=E|Cry_6>S3@}`O@t;voi~Es)`U@xb$ckk}YeKSUhq57A$`~Aj{NS<6}hp$WtSg zLRyJ+qst4Z{1*v1H)#`6Lb2u{Y0sUXebIi$&fiHt=~TF+)#y^ldzup9 zeWaq*DThiQaO{p8Y=3_jVlyrZ_Hou?S)0jn0d&gv0Sfi)GG8X@v08;5VI)c4X;#kH zdnss^W^+)%MvE=61ih`VOlgX*1#mw)fwEZt)86)+O*xED;j<4dT<_OP=IVx~Kd@uy zIuoe~@$FWPt1k0sU*OlRgn$ki*C9&5w1zT~xTl78wb=cMN?g z9@w^hcC@{{z|@z{z!4sqP3;#rNh0G#o*a^4corh<61G#BG1NHuLY+h`kSt0}H&tBu zA;&7U%A?CN5YR3&80wR&Siy|d>wAE$Nmn~yW0uN&NH<7Z3N9lRA%kAF7n5l;V=l72-@DhP!i6VAbn^+y=>=1EE2di*K(2v@VUqoDD6 zyl`T>x~Tfmk9oa1#haR0b^H}O`um;*U6fpQXH~|tfq+KB`#mb+O23EV!S!REAxy!< zLX4dk&S?$KGxg7<v-oFXRd7`{YWkiqKp)5x3=FyLv0hU zKDiB@Jv=8_=3`DAZEvo2?m|Kx>gH3u0>9q;=qb8jh52Gij#=1ATGmUS0MswYeC>uo9 zLl%eCxhcp+)L7=> zsFwa)Si=(c>29V0dj2+@+-==ei4WxHB=*yE0gZA?Qcr020H>pA;MZeGWjkec$U<2$6gj#b1`$@@2 z=X`PKM_!)1j<|#;$cU(QM;%&+i>24YyS5SMUU@GkgC~3(@fy6}z1r4&LbHA{_L&lX zz`H3j5RhqJRxb;DLyTmmm~}4!>UwHBhkP0&%1aWpa&!zUD$0`}Aa(L>3o&+>R8k24icxjd((O28B=(X`FhHY+1H0FEBQf{O4(p zBfHHmZYflDaxHy)$5PzJ^6peH7IekuKXb(w5W^5dbg4Wz2*?*@0|t_yN1~wQI;PED zu8zwP<1BDgPaF*PWB^`kkaA&VZn?T$CSY8;YC;&TMHD2n7e)Axi{Tw;vdzs`YqCcR z9z;!gm)A>s%XMEr%^Wo-H=d;@ok>&)F%90nm0kMM_Co(l07ViMs3~M)^OH;o1M`!N zZt<-ou`+9xMFd_A@^n4z0f2z$7Xfb*i9O8cbDISms%=EZMn>abqo}0trOEMpf@D}~ zX7h0h<#VS4Nt*lfqb7=%Ze3%gcpMzcc?DnmoSO{-szlj%f)T4}`gds-aEKqnj_8zq zF`;b>%r*k9H)d}z*E`axSEB9H$7$V3)U-W$PnCZsi3%`Ebc_e3HKs}y;2$LVn7G%M z{ucW#%%oolYTKbM%|o|Omqy?5|EB!W*pv7u9J0_WbkT)d-+1TRfnk)|M-oaVR#M(7 zZ0+>Q@ebtv0jvMMc!m!68mO;6=8lZd3s8TUCC|uQ#~za|RKVas%#3mn!x~ptliKv; zI>a)H3Wh-+njHW8ggPKyWLs?UmjVPdiwg8ap`h^Ldr2)#bNDr0CXR-#NQ+BjQDs5% zR#(_H1~?jN?){2h0F%VvBmU{D+t8SPoTJVZIh@YdtRw8h-}=QQi?I@D??!3&%bFOF zE}7lJ4WT6(+%DWJTysyb_Cw9xZuHaiW#ON zCrw7f%^q{)w6$s(zWMS3W+Ts)KLA@-ec8oM+uGx2Tpkc2mF=tCoi!m-|Q`AAFv%C(5}dq`ChS55PqC{aHL~R`%vw#A<=d^iG!4s znwSYJOID{_9=QwcBiuYRa z7qt&P+aX#~M69NcK(~0{P?4Q z|HcF?KtS&}cRawz_)A0|1)gU@Y3y)!yB!xn-iP4O__o)Qv+M37mS%yeN(TpRZ%dqX~}L00FG9<<2GOZEKa&$QZpnWYZ& zSMiJP_1j!-eE}Bul}O@kGHvR;7jO9W23?51K65nBR;m%F5^|P9#aoWlU2_t@NUXV| z=RBbzsq9vGp0P3vtY!g#5#^nqwL%A7H+W1PT%{2j)MP9R*sJ!aLIaxD+R0$D;|zUJ zFzuo)#>MwkMoaFHZDDG_HKkIpw{TSW`9SYw}L&yqPlAr%LJOr9^mF<_*Z{YvY@L#axB>4?1Kn;$S`yv|I^gcMc$+anwIs$U-?%pvc0x_1JI1 zX*tvGl1`n7gy(*VQPhfAI0Dm%ido=duwd3oVJAf~=tbXx=cVP0TKUg*87-merO5`G!uT=h)MevfEG!Dy8 zi7A*;h^kUWobxy97k^@!HxpKORmSHIfbd z>t1V+BLwt=fy)uByU2^#idJAGz0T=iFSZ;lK$8$i=p_J(J?95G$;PgQzehH2X`YB0 z+;`UFpEqrY!4~MLa=$;g#a8dmS9AHO;!gOhIx;u@g__(J z)X?MdPEINc{tg8NFpau9NDKY>z)Y3C&uY)n1N2PDXA#-fnvp!XN4oVW@poGh%Mw$* z0uQmfaZ%BGF6%yHbG>)FiyxWFB)i*B_iz zbns!Q@{wpr1w7dEY6!oHspLyCGlj2I8U@|sNyEJy|)BeXcIMXRg4uj zlxoRbXV+=2eAecHODBBlQ#4SYjgmgz}_sZFd-CQj(HiOK>O9@OQf=Dnk|m*w;LEL1{Z2IMESL zU{kwBwchPNF_0m#mCHaum7f~*zT8X?X}s*HpcOxmV02mYSL0n`lW*7uU})Cz0q{Mf z-v%ai0*Y9`GtHJA0W;(tJ{YkY({w>NslQDaG*PL(EKtY{|tE0Qs zRqN+##}2qHh7=1EX))2vYGTqXy{g~Z3)-30ac}Zc4BqaX>*O$Du%VvuFVBs%Lkn^- zee{fvHgG!FcGZ4e3cm1ce3!uLWDD@uCT`jP5(ydt+GpNzI3-YVCkO~LoQmaE8-*O5(NRR;!Kwa2_oEK#ePK?Vr{1EIh~>_ zs?UYTU3cA|RJ&F<;g1_iGY=I{y9UdOyXNyIbpmN_HC@*KqX zh7V4G;UnpgUDE&xpDKvIH+eF{?>@cyL@NdYYT$l$zHgV|4%fRT{m8KKgT+u0{dtWJ z{C(HfctUDwORZC#R0-*~gq<#3;;9a9s=+nKS}dWCVWy!){_eN!I-^5YD^H*>zRhfO~I+qO0o)y5ZGJE7>% z0$@sru9tdH?lJMd&dqJgt+1b!6r-TCDb_7<@iCNla7-b+p~O^VCZzG#CR_rkOLG0X zIdM6^lnzfk2b}h0r<|EWI3JtQ6C{QVELg!0YH%0kdX-AP-@4UY!bwx%^2tU|IB^vT zORGpv)>RA^m*toCNP5yDO;&_={QajXq%dyzx-+-H4)?Rq9>Gm+kB@eI?!9a5{_f)K z#tctJs~V%Sjn8}*;?vZ1wQPWsfAzBFDHrMz8J&|O2MndvRF*OOfr;%w{g?o&HcC}h zo3GKOJl)8IjI1esI#n~KJ)qom`{ur@Ss+P=dyK9Lud@R+?30h8pZ@W>F!j#|KVohnF-!v z0b%l2E5Lep{Gj*$PPS*`_;7r`IKH8<$oba>ezUQo>is$_G_du4Ueo&HDi_=4M&$UY zrvK10^5Vw8vd^+HDQ%km&hLL^&prHR(GYfAsM22T>T1m8J$Y}xtLxiPm)mFESfqWVWKA(T%*n{O{PvolyM`1xSlF{kYCA(H zEq`P)OcT@-j>NFVMdb;EJmvc3*vn%6oN?!wDvlqCImzK%Y1={GDbK`HgvL#CUZg=l zMVK`o$k}deGRtgH^wh1gPAq;dX!M2#bSGqYceEVtp)x-(Q0&}jjl?B;Y%Km3tB)3T zA$B-p-8Ixw!-!QfuZ!OqO5O6m%xSId@PvzhaAb$ZLbBVM!uWJ+p?Js_I8$eYfEuU= zJTD47-u&&VqA=R}Z67$4f&EBUneNiss@+`Q zwjMorau}plWQ#a*+U;(6=;rxY?sA@kn~A<1gZ}8MrBpK|9ZoYTyZNRnJxWk;-5<~i-#IM;tx;(kgqRu3d`zZ}w{%6C6tW?pH2vRTa z1}9Sa&ol0U~>KhQy62}^tGzKG9g?D?VjnU!v zveE}=W-)sDd!MdGmgiow=Z*|&X0W%99P{%FPoWK~=W!8%*UfFk5fcyL#`#3& zy%UHq8ILU70u27dMGKjp3No61It>FY@#zNzg7MBU?~fTy6K20R6>dJK$*J~ZXV3@4$4+>1-#m!4nTRpmEuskR6V*H$BlGbS^e$V?Ny4XaYZVL$a)joAa>WY z9^jnE<%asK@mB<#X)+Q53dhX3LIsUYnj+Qumn*P3SPpul;%NQZNCYD(l6vc!IA`{5 zef3?NTRSGkdY;&_=0Rn_dgD>k1_O$l;<8Yl=WONMsU&*r$cBG1yPY5OI_NwX1l3Jxlo? zaLEB5omh=&LlyS$-DvT}lG#DeG3USn#d7QexMqBOMha=)z(D;uu-J6%Lvdn|AmS$N zs2X4E$iZMP8olb&F8Me#JKd=?kRJwo?)~S zZGy2;ckP36N)2p`lgU77|D;3s7~xwZwCJgn9J`Dm@fi5@$thV$;VIIeR5EQBlJht^ z>A$1^^Pss$z|YyTzw6PIrU@=<@7a}jF~eo+I_^Y;Fg;)s6irX|RfU2=-PxL*jNa{E zGT=p~Uu!+^8hh3x^v_a^jE(U3?8MKmnnygdY2u`iVs#J0n**67O^BK%5q*UEG6Depyv%BMJJ6yxH1<`>WPR{Emea%f zdAHwG_dym;_SuHj+K>8|(%N0=6vz5boXVhWu$4yraTkZeA!F1MQEi{l%O zX6PV6z&ZVc6AP7CIB7XxaI8w$dA)ws0^!?8Ol7pEe(dF7KcsE?1FKqBSmPRK%sw33dr>;y^4mm}%@QUlyNyD*Wr_*VuG761Fu$@!`GpOif#_?beBcV^HJbL z5PbaZ^E{EGkmE*a%$*T7iLXha5D>tOa0FjYF^w25^^tr1F1p~p15ZqzUJLGF_0;?L z!-9@TSJ83xXRHI#ynFx7D1=wdjA3!LMH~wZ{^LSI&DegYh$0lNy&#fGA~~2U!3c|6 z*~-4Qis%6jZf$*8V5Q1-+Xh4PTfsQ+JRM7=X-Vz z##qkDQ?FNh=4amLi^bwEBw2O!bMFeM>ScX?@KHP4>V5@|8kIV?sQF?D^5E*T!um{#n;U!`p7aa-WskkVYW|CMhH1LX#Tdw1&6)aRFoD-3F^+N0s$}U-oa9R;yHP4r zqqRri3lJ9<_%857x3)+~FDVh>H@7}O z3`0RcdrW#4g@%o=NWPg&kuZcVE{md5Ud4~28|EA?zQ2aM)3X}Hi~k+vHdacT4ImyRim%KvcZy-BKT-1`7veHm}Ec1G@g+d0@~px zpY69xDMeIdRXnM#rV6F_GgV`}5wVA$MKN6z>=yk=!uWXtb&(gr@i zW0Bj8^IJMjy+1tKwa9==DiMlnf9Uq-zW)JexqsLFwl|fJU2eMt9jBM{5*R{2W!!6* zXARPx3=2L5*N$wi{giw5W2d3u$DXcd*um@Z`X!+RGk;ukX5IK(T;A965wAIO z!$;=demwf0Pe*@iZG| zN%NP4-`u=BYax6Y8Q1xFa6H)yuDh)?BFL3lFWqt8`7O~>s`1*BMOJeb866176l>}z zMCK4TB$n=6$w*1`$MFY4_(XmSuqgyLs#v?))lM9D(jkA^epYm&$o$y%>y3i-g83@G z;~yG2pJUDIk&M)P?%W!HrHelpPzhsw<4w;w@Jfl!E@>1$K01sRJ^WEj6C_)Qg{|m zx6TD0KOy>-V>Ld-m`Hz6pf{5mYM%4b4%n7#i1Fg=$=Mc|oR^en;s6gH{Gb?E#Ri4? zjoC>>`0!gqV}uh(;&@#?UTq38abWvg>svsuWOjS)6(zcp$B#5~I6nr;l3CL02xyqd zF5CdL0Kn$7hC;Go*UYy+a8oBApH)Slr6E{>T?rYdbnNt&rcge909i;IwRTbEoW38> zv!iJIadgUc;Zur+X-WK4B3>#+13P>I)|Z~Ik1vRdiIv6tVHR>wDXS3T#H$R^B;|X} zL`rT?YI^U7hRv9yRc?#T(WPr-Vx`V)odXk58rn*=PR(u?Z)n@0BwbNponiK_^BwTb zv~1IPr>TGrl}OIAK|a(#5*yt|KJb0lghFnW>G%vybKscGB)hDm7YGeOdC)WF)VtW1 zE`(n$mt0C>RzxbFpZ@gx&`_0-;-Y50&b;83C=OXD10DFE7?T~-H0_@l+E(`gRM(vT zq3dO*8J!Sh@J1OLdyUFOQgT|p$oD*$$XXL4qbNE^e>Nwtr3MA^(o6PBn2v8fLUsgqAl}@QhE~`27@WD zV~LV?xHsIJ8%0Q0(Gt9XC4iy-^`J3?R$4SgdW18P!->h5q^ye74C5g7*BHMa|HMen zMSgl%E-9x6n4FvvJPmNYC?r{?I&cMFIgiUuy^@>6KKG*~$;jqv9i?rJXaVFGf-me9+*ynT&(tsmn3iTAV#m$tm%G&xsQKc`8K1FYDFDi#gn3I|9+O9;-M^; zC|elGVcxs=HAPCJ&SvWPt82hF8ar@ECxY{}dgujiyPuymNQEU5mT(RX<+#qoU)Q7E zvYwW~xi8)s*}wDwjKDz+9W>L!|JBNs$3wlfag1d!%#3{tF(O+GWl4;&jU`6*$kj|2 z4c#*2>eh|1gt1N5;i{}zLbpWRw%agFQ^}GWam!e0%2JV=kl%Yof4qO(_s{p=^ZR_x zIp5!T&hvcFdA`p%;3y+xmM2zo$<-udvzRtpTY@aD9q0MLL= z4oHH-v~sb}wv-w;HZMOs3`OsiXt9ZIp5A$(8m{k0sWy;w`aTd{LkiRaE9cT3CG&)){K*u-CtI7u!EwZQyp zJxqF&cx#N0?RRILx-7yys}_kX#XOohRg^jJD~rh#>AI$guPUmLzAXTBddHm;9|fim zx5oi`II(KJb03z*HMc2^W3y?bJFD6Z9@mJuc|a`Z!q2%FS44iD_?4;0U_=P&c)*{f zo~Kysw;oAPbk2*PdlXAZbz+v=T+qqXl#y?1)^XU1-&5TUBt_t;uV5t5cZ@Kin#MTz zpi*<)mS=(I6e347yw+brb*T{D5OoQ+HUdO_VOK7_&LE=se1l&U72S|6B>;3xPB#((G_*yz4YI4sO_NtZ zx67~R{n=Kmg9$HQnuTSk(TS1bob0ahpj?S;7(OKn)mSY)3cOj;mJL7m?tnT5?wpQ_ zGUJe|P%q)E5E*d?z-J(6aVutTQqKk+0}ibC-b6W13IeDH7(Y4Fb?bdXQy0hY&F4Gf|(cfjTSs-+ez8#7{Tv{FfzQmw6G_~wWx{TC2a zbM>i>r(d_2BfPPQIg1v>6N&q5(wM&r0A-=2DWLAkuMlp&wWn%{KkC6~pZJaO-rtDw z^F`B@?$L=u#cgi2R_G$IThx7Xk5=xQvW{DD)YhSMRv+272j?mc6aOmHPRW3=qmyNk zP6s6QQ**pJlKhKb9YGFY8VNoxw@nUpEnHhe*oAga^fq?$>2d|;__(qFcl{RfE&Img zl6rBZw0BgfPrjLY;j?S=PvQrlc4 zYkO-Wy-b^*HJkKstzT+vC#P?NhcyIu3MJG~(EIxboVtZ^p}+fK1USby+Iw2aE{;Ue zU5ST@Ajq3!+K4o+b*TyAaPR)#j@3kYWQesb<-X zYvZ85Z_kolyF~?nhLAnml>#Y`WNKf{8t?2ZHsz%?OS<*3z4esfLcHNVPvO+5Y%CXo zCemq?yoO+P7r$Mpnr$y6g0s#n~0z70=zQXlP0O~8I`_S+t@@R*E*+gKIpz@%;beR!P%q_xg0{cKD!?=Mo%b~ z7XW%IpjbeRbrCsb4eI1=aXTa>1`L;+RFiUH5{DR^IsTdLAPREPMFo&!%_r2%gWzJ9 zxOR9x$tgNDmZzuVkEACl*`1c0`$2VxdNt@qP?`PHb0*1PIB5O{?sXn_n<=a)8GV0; zLeGpUMMW2S#oPNy-tmhlG#CyL?vF^8*5d1)jKxz7T#r4!SXCK^Ab$pcN-RQ7SGsTm}>nAhy2`mQ)y4nCn@otCK7op9`I02wlMeu9`9Q-)$ZzCqz zF5-2z`eMTo{tg=MDV7h?6&0$9Nf$j44kLpUNq$E(%_A_ox-!g>(O#Klc37*x?6l~l zMpzgDM=g+#S9N-#O|JY^RO?es-d4xE@k^odga(h+@q^pTy9HVMjFR5^~0*dZz>9AN54$)=KTq4Y#+~U+(=+F5m z>#KK!%6N=nm!Nweq5+M3k{=b6Fi`fFhGBeQ7jIpbgBD|6$FyU)@7qIa2tM0P;fr!DMF<7Tho(pAzP%HTi8oo7r6!TO z1zkMD;ikuFa;wm$JGdwseaD%3=vjJ{^m)%L?A)h_MPdkC(@?XFXe=$ExyvQk8SOZ< z|B00N0+HbGarxWzRj`3VZa$C;ID6@ZV1-tcHojL19OSM}=q%F0^Cn2|><_W56r|bR zYlCE`*Gw_PYFsK2Bhm6Wh-pYJ)YCZ*YUTV~;4Bp7Wp62pI@fzSBTLa2a8v%OJIWC_^4!56V^wR%uB`&-KqZ$;rbHT|pYJAb0S zfWDpmaQK%ZHJ?Q9@ya?qrKXCJ_>Bn*_rkWz)X$~kqE9cM<{D!>{E;Hf+}u+N#zJA! zLK=essvth#dba>m5M()8$Z(r3je0-I;4!VJl8wzDLzlq$OHIesgVFeo=Nm9)nSOIY zCy16`hpBZGC%9T7mM%&_av?FWy51o*@CA_*f g)ON4`N>~1O)y=7-;_;hA7J1Y^gy=AC1%)Lmsnq7_^takHSp0$vi@8;MrKLCV*;? zPpz%$D$l){;g(0CJ?yeY4<4=4TMfJ5vVcc1!kh7~F}&Aj30)zm>zZKRe-wvwhDE@}2>+7yAzIus5b$@l_ z$h%4AR*vcg{78NM9X+4<9Uv9}vUAuYKC=@NQB~ss{Qy8D;X*1KN2*XoJv7dI6X&Kb z7AOz^2fR-1MZR1FQYXgJCX3Z4mpv_)qomqqu$pVAdgtA}tLAb5psGgN<4lTu)y^CM za7@@E@`Pg6bmEb^S=b>jO3{HJ0Kf#VBap2kRr4pK*|u2{8BW>!`=li^k&2{DV}?WOqd zh3RS=zy%ZCpfryKCm2F{)u}7kANBRJz>_G0pni;CmUU&8jb|Q+=aNql8LB}&m8Kpk z-O-%ZbeKJHFg|funYLItu~?Y4Fif^ss&g=$F*KZg`oBM~mL7x&1jVd@yAD3(V)Eti zu7ned1`q*3c|s9iSl-ae*?G6o z)>B$gc(;K^prh!e?lv=f!hI?3L>!wG z?u79CMAsPM!VEX!2iQq&gi@@a?+XI3!9V<*vDiCk5*gs(7 zlBOOVw@CieeqnfK;tUj0hMcigDx02{x8cs8F}30DoiY6Y>CJcuDGNCLh$ZR*Pi<|&)b&>Ir&N%0-7+Dvh~74Mb^cJ&mYr%*PXrdPtBFrjBt z)*GuAi5fhT*9xJ>Rn()S7s1<;;ugt0JEm6-j-ehY+@}{DUv*-nSAyy)q73YcN1!hY z25(B9@wr!aY~*%NYGyIgntOA~_7+EPmfJ|j$Hicxvh@txZ=yXPeZLD+EV%8L&8+Hw zc-)^6Jl6EHz?Vm6dOmo#4ky)(2)f1SzCYlVXnU`0-9T?gbcV|BgD}px-gijvvU+6e zY*u<@D>j4P5ZG041nM zJm||0A=cwysU?Qn6eUjRu_Nn}^`ankWYUnLP=p>QvNl<-n72;LtUD!fSQrAffJ4fX z5PBul3Guw_MZvLf2&4yrhy>;QVC$_r5uBr_TLWQJmOp`jAVCGNhxBOZN7lVSZFRR6 z!j`uV{Kz{S#tWeYTWo?J4S^a%m<<^m1K-Hbk4M-c^GP3W5Yfmm-VFS+YA3Q zINY{sI@!#wYGVo!4XFc9H1#H`zp6XQ05zjd19d`2-wAY@Fi^?Bm9zAu=tWk6YRS%0 ze>FG-*rJ}y>{d>#4%k&bu^BiTg?>y?ogR3iV9KMwvYC?b z9RT221?}EMijtkroCP2PV;4mig&7eQG6OWx;6%3(`GE}3@xwvYkdlMADNDh*Ek-2e zit1G1@Uk8Os8~*bh(d+-2r%4(YeSs?Lk)NWRK|EP+=mdsw@~wgAl<mgb+bDv%$sUfQvvy?Ek+-2!T=LDwhAhe*vco%H@B5VFUUbI?>wybo5ec z#gE|02IHfI8G-=-c#`Z$Q&1KxAh3XBCV-NP3r4`dg7yl>1sw<+=jw$f5g`SN3l!Yn z7wF%kL173o0d?Zhh&%+PfDj}~z<;m%d-N~3`9Ew3I?#BR8lWe(&v?S}wbYrf@k$>+ zV3$qm76ZqDB*r4%rsT#*K_K}EINoiu5&3OuFcg8_X;2sknV%@d!SHlM5}PG77J zW2M+w^5eme35j4^WFj2S;*OBwfFME3ElMPU%R!LJV9d)Wm11WI1((Z$j0bHlNka-3 zoB@J82kZr0b36h90IOG?8V5S?wi@TIQsX{AN>xn^fJj6l(S63IP@WNo_i-xQO-L_& zT|QSAk$Qai4^p!zVRjj5Lf*AR3UWA3POzy6Ym)S!s#;USlrUBgnVH{S6*&kCaL@tF z5KMrAo;?K(xPgtsgpE#vhDqoE1U5yavL?j-`VFBT|5fh>Ja2TGu-!}iz z9!esZ)%Yod(BT?v5dr&XK&&co`}X*-rjCJ$xwXBsyO&=O*f;8d1NZ>|Go!?#M|^H~ z$jB)usqRwK(9+Q}pi-EthlGU0)jK`-Dj{*5Dh0V*eFdOWB^1CsPkufjZf+`RWGW>g zDku(>5E2)cf`aej!cyXbqT-@ZaZz!Ygt&yLxTJ`Pptz{$LrV*BLnBE^0hkzgN=gI< zeWV0YSFl5icKtAG_l~vf`-QI?H5Km?UMAgTfWPtGC~DDy&#S-oT}|y2DpVg3=uL(W zy)>V&N1m+uB+u`#CD-OW@Z^tERl7|TC?U%rt1rioRcdc$inVy)iN<<$6t4&qbZlbxy1M9e4scSh_<=&C=R>X1p2 z%ACdQ^NpU1b)TpSNo5VWw0vKBM~C;|o?tym(u;xS>;Q4b`P>1aua0aKz_0q$gLp(b zfNL7BleHU@QJYD-hipT|8jTLR`OI9ZS;AK<-8qz$WV>T(i_hKqloCY;Tg>#3pI4HI7bC( z-fnp|TQ}`@`9G_j56ske7@mLCyR7(fJlZi|6FxpM{v~~FfR*bPwa6KhclAxOAO6V; zp`j}dySgU>3Oe2i&*T%SrOUa63KOEaBO;KJB%k#^k;PwFyg&~v&ETpJ7(yS<1poWB6rS0L07+P#r{#&^mLoegin8hJEiRbQCxAB=$ zMy7@wTh|C_Kb|j=p?ZK1kZIW^lYH#_)jKH(851|R|1~)(zp%>%(3b0r^d#4-yJ_Qe z@H;9PGFs6zdo)h5=Y?$kM5FzAu0Vo|haJScfSZg=iT)K?x2xHf@ybWSlui~?OuxlhF+cLjm&U9^4-36pt!`TC&;9xnVqLWJbOmj$Q`Ct`mcX z-VNqfm5zC)DvOPHoPIm1o)iCVw?aSRr?F-tQ3HzjzUeycLgg2~r{D6NDyX0|CA42U-wLCxT+M+B=VzF=*ezPCYJls3k8 z67}|*`|P7*L(v2VnQS8yO#MuJU&4=-HvwwA3VU3&8POgAUjG8q@gtTYPOGIAhFh~? za0&E(byRq+et$m{?!(i9%N(ejUXh9gbV$XNG5$`FQ5+^|^nzD>*87!bxX~k2=T@D^ zj+*y3B9uKppRYG-H=pwm=_j_-fd5R|Ab*Ou$ftncKv$Ivn0XbuVrqc%LC3K!pM2=V zFNKFA>W<$w4o?0g25RP%Xw29_DfZ#PMdic0vjuL;?cp=ZY%$fC_SL@6WpG%65AN)E z5Nu%kVVqYs9FLqpmyMhQcZ8QJUZGzbXFP^Pj4LZ)=>4B-h~sl1;f(@!6$%+NKG&tz zOT$So>EP7P4-326(d#til)?z&1C$?GoT8IB0EiS9$sil|&qqeM1tQ1ni*FME*{Obo z=PiB?v(aCQ{PeXv>FxL>mz$HAtndGFj8z@Y6vUt~s?iy@^iu_p^Etm@KC5cqic zk=SBF>ES}kDj7gcbf?C_`-)Gw zV%4SdtJ22W;gGu?Mh;q#@SSw|+D{MNgwY9J6Kxz&67Sv^{F&!csK9j#ocOg($ER>t z%83?ude3<5a@kfVQ5wzy&<9g5{TFhSd}fML`0ff=7spcYDsAXCFZ9Rh0Ds*WxY2Ae zEZZ1Ie(%KxON@`;&iUxCw7daphi7WLpA19!s&3&lY3}ffM}M?o@za_gIoXlTVZZjg zC}&a)@#Nr0q=0YyRw8iMURt}qW=3*@@aaNH5HSi00Bn98?;;afFps85d|Z_c zLkVx<%#c;uJ!vh$3D*)@wchg&c-?fHmf%T%kLxEjPyY4j)8`@%?ZWukC>IW@8ap+h zIK@IugAn>yp(@hkqTkW9%Ux5>ziaZtv1Jwd>cz$kKCTKoQp}f@|HhNCOs5?5FAG#C zr*Pf4y2oC!MYdS4+x{k+(8lco=`z9b>~_hbZ`L;^xD7-%?jQYnY5yxQB$syNqo!mE z`HPZ+wOjxc+V#@wQ+C^RyAd>z8|wZ>qkD_ijmENw3#|}qV6`%2X=NjgUeLkXbF!bl zF|=-%iQrArn-<2k7tD81+~7z#ivb?dg>tBnCIE1XGz6E z&wsb>GN|SrF~!|v@v4jvDtSFUhSJvSwsWqEszi1Pr_A50_gb+x*DlzfrBhj8ui6Zy zJmk$>nUl~R>BHU?yBPXBEEmDgF_?$=A90>pt+MjNuYA)c#s{Te;Fp#~> zRm-jU`^d}AAdA{@GdoUSVH$j_e|;HoqA>RuySZ1o(8&8sxWRD<)l_;Nd}JKKaLP{ByX1@UNJ*XqA3wX)pyZh0tG8EY z-qX=>sH-WB_f0zHH{^2-pBhJ~sJm>F%1$lw95^<(yMJ8H^>uHhJmqRx8W7i>{L%MQ z`O?Uunnk~A+MCM%t`R=n=f)9R{n9D>R3Uu>HN(BN+Celf$$C%IrvRa~U(-snc0JpJ zjE^CER52eT1w$?aemU{+zkG4Cc?OwKVe;fQK1YLXTFlqJ!yXAzr-cB?+TJ(MU(Uhs zn?8jRy_Q!*e0z5*xo-2B*~6`0)W^cc>IX)tB!lb=)HX-&gf|`!3ZT89DBX7Ocsk=^ zol%Dr6#nG7cXDzf5c^13RlTfOAoV+Oh~X(QcfCjYB+1@|ZDIGIAdAHv(l`*4zf5-r zfN1sll>|Q1w8$FyPrP=4pF8&Nr;a)m7;Dd;JC`o9z6i1C8Q>7P-P#$}$XKq@@E$|Z zGmfmq=s~qNpHMW!Ra9M}v!**$SotA@@rK`Kb-C?4<`VF$fJ1wy0LwbjF7w4n%*)L{ zBLa`UuEUNa|FY8r(c3C(88Fdsaa_VvKXTgj>TN#JuQ-p}@ZP+yWc#E{{}5AU{Zcxg zAwKc;SE@Jk9+1AXVzqJu);-f?6mze&w5xDZ4>KWYX!aLtVY8%SPrmf97B4 z&KcYSl3h(T)@aHdV%4&|=-)DWzlk>K7S8xkzbYw~6IhP2@Wlt{K^Q?vru9M!56=}F z5$W*7u?Q~8?=2phqo0@z(S#bsMqsdmqkCqo&bJbXpU;PTR?g*V`x@*w9#X?V3;ze( z4&#!ArUX;W>jeRwv^gH1 z28D}B^;4itsD$y}_&v&5D<=cOfLQX7lJ8c?D$DBDr!|*|3a@ zdHaT~KRQARRq*^sgAR~QlF(eb84rYv4AGxzs8VH`%Bn*>4yRR7QsyZ_MHQIMN} z2}p?v!6cwCn3$Nj7*qoE^`IWHQ{x8RVbR~Znd@Tn7XF?1kt2pm{D^c$_nx7{}%S zWJF_GwU)ojxjCS;Y)eEu1fv+dX(eRIKx{#W(#iv+yev*mlD;b8rTvl(pDa zvs>6*R;89Y&<8qNztnCiVFZ;~Fqk@qoiVl@l-2Oo$J?oyvUk-@m`5y^o5tj#^dH{o5a#A==UOz*D0#Jp)1U@9Mz6`m~vGi7ycducGR#6;JB8N2r^CIpdLb=qH zf`a+xw#H9BwUI!t2zSq6Fg*gry>SZw4@~PNq~#Sl)mgGW-CN1&6Rpvz5K_hlc5M0_ zMC9+_eJ?>S@<@?r){X@S*CUX9{iS>#P|)`ZeoEBq;EW|YcO^zJZlSwyn`5VSgrWhm zUWYA9k6vjTJC!YYjMpetQOWo+L=W=px7&cauRu%~&xFyRoD$srhJIB^i1iyRlTCkl z?AYKEee4ZgOS{j~_nU{6>+ihVzo;h?P4An$G;RMu{jaeOhx7iBAjFPGc`Oqx$-dq}E4xr!zHl$$Pq)CtS#D?1b_&By~{ih&mh`hdj?k zr%dhcZ#PpFWHEw-^j}{_rV8r4z3|Z_-(2^vlw~ou4U4T5QozOkPql>yJlgL`WEi^@ zY7mYNk-|vQ4HGr-uqASir{~K|s|Tt(-@Vc5bDzVy%jPXriQ%K&V2krtwcz{ZaKoNnw zPznV}(r@$6A4L14Xf>g*bzgtF|Uhdypp$=Wf!hoq69Ix{oyB$DV@SGfA-~H z5rU1cQ&9V=knA@k=lLHV*nP+|DUonFJBCZ@mIFh?=XVKnzLQBYH5*0!VK+Q3NE%-? zpi&Qi%Et964rP^BaRC?}&?9w@sFTYS#Yu9BEPSgKSVmzbtc5XFN+mzH{HpwpoB2}4 z2B-8L#aW!6P5x*t5z8a!rKs^aNeDsk=Qia*IHu8_&XR1*Itvf0j%oAI=s&7-?|vz} zxlO>f=G&G*SQ<4QkTDK_&GPIhyK@^CB``Z&>=872{d(@OHm7>yAC=wSIP@d|DT!&`Iz`LfDZP*}Ug&ek`ws!!=_w^NP~)K~6m$4>;??b_ z(!c5IK-esW4ZJ*PfjgE*^ZCk_^xngv@+qZf_iNB;wIIxkP(goZI^iNKB*q+Y&>*=*Px6>spKLrpqqTYP-n_Y;M8};Px^W99}PNY>UM#mhYp_Y9W z4}&t8`#QS@o^H;@z{eHGss0o#(HNp9q(gt7DU0hJBNf%^zr#sm#LDdz>`Ocz;SQ5w zgH5kfi_jEqlTT|)0p(M#g)B^LDhd)2#`o!N?Y|Ql_kHgVH6C8VVi^v>?~EhkipN@1 z`W_u(noo@y`HE@@nyb!K-ESwKe7DvNJJJa$`^@=To1J2z7#Os%;wm(UeZtd?Xsi*U zjHhm|3yTgPXj0A+?A<2Y`s)W1d9m54E%g<(TU)zdDC|TxD<75zkx&PT?AlE^GB@}o z=M;V?D4PgPIyQawtpu5<)HezIsyr|p$0lH*#yyuG4cJyvo*lzjy=`jNyf^!!E%|`Y zrso(NOGN20NjM60iv9`Qo!P}zF~-}A`mm`NTypdSDP(-J{#DKBm(`zs18zY~Gs68} zh7VI>MqigEE-ld#4{hNZd~@()B%g@ zF&QPkLXb?;rR({qLhthL;ja;VNy!)I&ViS;BCWj_;mOkpM7O8nLM$B0>}J36F6YS+ z#TfQ)eV!wXR3D63BB{Y^!hCA}^$XSB9(ldqp~zz$t*pAPrNA17(U(&I1WwME!{_uj*=h)#X z?9z%0h%+u%$+EpR%D@)QVNG?8#yGMGel`RkXXfTV1=X5D(JB6+5WZTV( z;O0?5XJ?ZP{#avvR;!mYq}6@@*F=ZAm}sMWL{M&Dp|plto3WzG3`LOmWlOJ|FTo*c z+(YlIWpTrd{xM~vLK321aUT@m1($e9t0(3JFTM{AV;{xkV>PJa{fp}v<_P-%h?2na zt0d#y=2A|^v)-=E3_mzFQiSY;$I1_n=o}(E9T4{60IzvjWi8q zmRd^`QUopf1~vd8aeFR1zjZMG^2bAYR)neS@~M)NO?LOeJNrh;XOD*_>Vt6o&z^sf zRtt$TYUIm|zY0B9v~#?@zAE*-lyFrWwz{`#$6}kkYh_6QS!J@zYMU2N*QG>V+QKd; zPd(Ag{4dOl4*F#gXIJ#p6}N9{T3=Z?D(jf~4SA=VDW|jf!M1L;^l9g|g80@8BB=_t z=0-tk+amMOwZVW+R=Q2i=5AY5;JPA-RuvC$#vW6gtpyq0vHNq_+LJC(r+LYSMr+}O zxL4wO6wsWgr#j~=x{+)`6fQv`RVDjXiUwRV=nyh6f`n}s(C17m201*(G`%6&1ykns zGCT0*K&&7)IIFVh7E}5ee=ut(v>kDdw|BR*mG%i%q0m2!9tdhos~ym^-W)Ceh7~%r zxoH?PBw~t9S_f5UJ%K%Vx~X}?zC!6;a<|5as+RpQ1J1P71)D#&8;W82n*?%?Vcs>W zSA13N0Ec?fzp2&p{eXuR6ndKM;5+Ql%6O)fuSq}9GyVY4#7p_Mys2iMQ66+=*GU09 ztO*!uXdXIFO*t-_$}@WEY&NCVyC@B#C6&E8)q4Bs%&%+ zH;d}#f2kOfh>tzu&gV!Lnr{kiqd9tk$Q$C;1pwBvvCUn&^d?;uCtv$&YyXd8TY7u!pUE;u;Kf(nvp^qgZq!y5{KjR3kxj?=I`Fd7k8Z5o^;3$c zQT6&8f7|EkFO*^a>y%)9=vBzJp;kksa+e{Wetelc_r#2hisDnbRSj$~VXyAe_x%n` z6`=L{@EZE8{^^O~NnKr*P1anQh@LPuN@yTt5W)_!9|TK>mgj^I#-~G(oYJ3&0}Z;Y zu&t}RC17o1d^c`9)u-X~Y%35R zuj=QyMPR{5>REX@BKn&Ps^37Zw|ACeRJs0QZ&G^Br}Zhit!342Q7=bck~ln)vrKim zjeg-28%ytamiIp8V=bAiR2V|zpwx4?M9O&bATaGt>Rk;~>U^yz^=s5Bf&SJYx+zxE zTPnt%yr`VwiV6zGl#7Z;UEYAibWl9>=^Q$;OPa0xx{=H-#74gN5A+7NS0Qns|Mo`lGh`spuQ(Kr5`FfxTEC{)cP z021=9OU6lA!aZ@jGRcAo$R<}7Pe>2h;(9xc1WMdw?WR15)g^!1`-ZwY4|^?K-v%ND z(-pQ}%p4)*4;Yf$5$t*2%YU0i+TP2=AUzijAH{syY1?%A?oPzxc*HSPfC;(^^xXGf zt57tpZ}+|T1A@!9)Sx0v$p?kLRsz+_3i84GqVgTlCQOLWS1#KOPooXc)B?M z!S9Xk46o#=8rGMbTv@CnGzw9S-8{2 zTfA~%V1h1}^^B?Xm{(t@Sk-+p88V`*M z^?L#@ltEmRy~71s`tGz610}x`&Mm!daq&Gkx}GE+JZ5|(Lg3`a={aRsYN9A_a&u4X zGJ=U(e)KZAOaeud|La9a@-VN1-g5BLmvx+Uxi3F~(x;`YL1IKQ`C zP=rwxt|+%J^26&mSRlJkh`O&Jp}$|`hQHn`Vzqu=P+4SmsDA$D z!>PN(k1a(7n{U=yw?@v;LUX1`EAM)Cj~+1e_bhGyLopa%7et^c9#EANT*J>}z={SD zQJAO*OoG|OMqJ-WS{x<<6%`kf6o-n6ffWrfNf{AgsHljDh=hcgI8+eDDuC<*jLpKt zIlJ{lt{+US{8M=} znMwKCnOep^7)*Aq1m*p*dShp&fd_+!J zq>T|hb7@ds$gdyzEQtjX_g_R10B;fXKDxa`0dpPv&?d5IHo3qNc+k<(clO3i5guSQ zn5K1;ozF}SMz`}Md%|Ir!5Wo{*o9*bpwY8w)dHTWb|c{qk}kuIgbbeRHhoO(z{NY7 zT!&Bx+{Vy26b*s%>4Sg{n){?BB5P#Y63kH6B zFxQ-KSSza;nZCO)j%fg4Q4bO7rxP?5~h;zPHV+G=U?$S-)MxW!WYhq&ho}q1t{rdAqw!KKh zHdX-`Tn$(8-&QXHF_(z^W366vt{~SkSX+zP`&Qrg85A#}75T3Z+@aJr_r5DFF8R(H z=H)FEa=rn(iLX|gS{_lh` zmB$01n!AtrUuEa7c5DXFf_Q#kqc$3d_PY0U{^bE;!r|3RawKfS1TQPWBLG^e=qfXS zeDriIMM=xWx)XP11CR4J#UQgZ>&e<<&QRcpi}se*E=y3rn9%EqR-WYKg3~X_^;*%U zT)(MYlR4g#)YPB(`=mZvn@aSK*jxCjJsh2BPZzjPZyc9^-h#NT&d&CZLDY^;^P_^R z{1Ogpo|#7c39{$0QW(xR=Fa4By}J@$sJs5Wm&+1^tq8O*xdeEe;zdxu(<^I~Q``G< z;m(o0UHc}NW%2U&=Sc`h`=L6fiSBsF=4GdGl|$BV5`DjuLy<35JAUC$&_=NBGVNmC z7Bn}Dzhd|o%RlBV3E~Cji&Ut_E0h{8lki4>`i)$(Og6Nw5|N}_*IR+X$3?<~m}+kn zk)5oQNS3yHd8`^oPn`aywoa^w)xb5jUdsJ@Grm%BA%+p3MEpQD=Dmo+7PtT1*YKD zzn6ICygD5}`6*M0JuF73Mr+50gH%o^-Zmd(a`wt*`*Z94}ty;R*ph{6!@}l#KFRM3OlMK5yYEi@`Nrj*m-fUkvdyuzmGZablUvuTy`tw~ zXg>YkO9k_Ly^cjUMY+SzD4A{bI95rs)F|0HcwLO*NbiO8ZR_rhDc}EbUzvKdw{>RJ zh87$c#@a&wP9DBs<4EjYmAYOXj9mNP>IJUs2O!p1=qey0pMR{@Y-@$g`k)y zZRF^O>r(qE_cLEDbkm)vDwJwoRoal!>sN5c&+Ey$CnUlc#w14Z9n4Ra81+v9%D>Esru2KF7aN%{%Jq2{K zE$`Z%R1KyLEXY`3vd|nfB|d}}AxMf|pRoQ@jsW6+;Q_K=tr~HAbR{9f#HsabPgBgs z@c_0213U4Qr>rVJ&gV#9SokvB-?Bf9W)ju%r9ln*A9i0A(T{hv3#FTHC&$0rU}A-) zCt7kmSsTyEgtOJeSO3I!Vw-i8)Rp34$VZTOy|M>TGvn>qa0s)C*1IiRZ}MAN)Mefi zYS-FnPg>0^7JJd6%l~PFL`9cZ|M-d94fgwg>NnW&Q1~5nBVQP-EaFC&-FUmMt`m`4 z(6k_ur{1ZFb}ksvLF`QGvZAMRgM7{dTbG^XOWY>rA;oy#k1Xf!YoJwt2)WWoVrEN^|cx z?Y??s9R3q$QQPKi&tOvE;*e>$_Y$Ek6c@!r1>oSpAvH zkw1tmd}aFnP}mi|W0eOXyuK+Rx|3I0!q_9x@Mj;2#y^dR@^EqikzYI_JhQxQMwd@& z91C+t;)pYe;W7{3nO2$MgN_fSoT$P(2Er9bCWV}eS5r)g+BRxj0NGM%GdZd3DVk9I zu+ODw=YUwbEKNsAf#AbtLgV zcJFvjzT4FdaeCf5n#l@;z*wi3`+{otz;P9bQKn2?2bFym)eRG*-4ZpD#G5Yh;cc*{ z2@Mt^A23d;`{M2X8Z*FXIJRUZR1S-6EZIhfof!qBQH3&iSA{Zo*8QBx-p#K!oP=J; z|CV+$jf??s2Gf%QE<6k()H*n7_zA=`EtzDqnCz*ZFTTR%x(T+wW@9r_KCqX_zYF9{ z*FZRH_~=Fo#&~S=^;hW4@Zr^WA#%LR z5XVP3D2v>=u5;woU!7(zy%(UY_abY`phU)dbh=a^gar@qGICnmfFu<{a$ot31i`8s zUG|dI?52lohzD3fH30_)E7z3kxwG;0&HdiScHdE1@Aqz48}zEm;Eb9z7c$JfH?_3LqAxszyPpD23#`eyE+GKIr%wPXct&Z z2$y2Y3Q1{zOtFs2aN|}ojHjmRLi2G_ZxwnA_^TqiYDE2$1*Nfnm7}0`d+x_&YXxV{ zl?Vorsuk*0wFd(4#GfE}__#-PR^ENH0k~udE2{t{b!^|>jle(e#}48|nHY-JM_>Q& zJGPCbdt*ielINqZ%MQI1y)UVEx~6$){urKjr36_Hxs~3^lgtwDgTCsU+Dyr@eDTd~ z_;8wD{N7wI+WD}p%vSIY^#3j-nYe*$w0D|OV{_UWn9?W6YH;3k{$jq zPD~G;{U+i5^toSM!^_}s@zhX9^uyz~)}aZNIzJEB7fagL?z>r8X=sku>K%Wbtecwa zQxK^!=Y3;1@jKyAvJ3sFwrMQU2S!XJ@rCoIeM1|0*Gyx8X!fAd^SnPqu4K zM>cdfN!E_wb6*tGXv@8J&q%S@ER2rlW<3UTtd}>Rc%I>l%+Z9R`;&CJ)V(b>v9ES| zyg=D-FEWSxh=;bX-Vl8YKdpCHKY2f36TALZBc3>@{Y)h8+M1(MeVMkKR6iQBxu^4@Cu)zYg8B)$=U{Cy~)uCzGU z?|nAd)b!K+Kid^Lz3pOxH+COge!Ejr5vmL9@d;1Way2ma>0-SKSvJgA%c3ABEg|wKtt|6npDW9`ji^b?cL|d~^cd@T* zo+r_?o(DiTPBA59^)x49e|hw>T+enB`5A;4Gvob+O|GOXl+SO=WI5Ni$QNsDYbY^l zRY{3m@eP<2<^S;w;P1?UYkD)?fwF1a5h>M7VOoSLz zL{w4)3gy0bbs!N37J%w(!GFZn!!ta(!zT_!oP+WVbPv8=P%h_+9s)pe9uU3XSvKI4E+Nl*TBKnYp~KuRP2p*G zXC*$vXq>3_sei3buHhT+hoM+akH~3g{*ri@*&%CjW20 zX!&DflLCY)!pKLd=GAtGLb^U`__mb`3cTe>!p(Jg3-b`4D=ZKCIZ(pY7B4Z@7kQrs zV#VI2^vwFfzlpwHO9*gduHrovPbr|`80<>oku96en~qSTc`F;7TXV&WUpH2)28&ei zHqNY4FU4Zujehu*k4B;+=aKyrU2kim4f|&W-?!rxTYJua$65-$ zzgI|$DpCSd%{&)CD*giveY^Z*6>GCB)A`5;bG09|!0%`DHw`Z}V-2cWWYj(Vdoq)P zDKkC&a@(hFNT4!_t9$6U5{lK+KkO&HD&{X@wg#K3WjK@Q7g`fBy>3;wdzMnpi$;)Z z7TE2D$~^Jao^8_=XO~w)-yk}G|cM;?LIK{?oFri zs3Pg?JB!B@?BaZ8nr#1p^F@RgIw~yTJ5E;B_8f#MQkvW^`+6NRV*2@EG1<>N-;*RSx)L zH`|)Zr?O|%_(ZYZ!Rnl@xq|ksF>c8L(X7MV3YcepWIVKz;z1of8Hb6aQ5ke~~|83W%_>ma>=`RXqO13GK z!DKuW$=}(qU%% z>1i7bEQJ0`5P~%^)C!M`MC2s}wzYZ($f{G?pA+t(NswD*JFZ-Ox!mN`)Tkd@Ytw;> z>IW)(3`hg(*ZyV^th$+4)G0bR3>P$izEzaF1`8XSakuA?<5)gS%M4gJbx*e=nOFR& zeP+D}$=J4ip?l-sn_NK|DcofBtM(2t(!X`mj1`jwzj!!*9Y@Pn6R%^Z^X1VV%%+oS z8WM|YywhJh(pJd-gctK_xu}|V%mHP&&oXmd!IzQH&6%6KuFRIL3BcwwZ|_hK;Zoqw z7EeSvl@{H=Bm?Ep&zkNSs+d16JO7a=Adpx6W?jB zE^b{q4jkEbylPt96>RH*2U#nBZrms}TY!T;pb?dIfj?RT?+T*F6ha+O(-{4Z%uB+m z7p=s1xPa)K+s!gXmCD6rW`CR{oLVG&C}?UvWGTLzzQ6$2e3j#x;0)TMiFI?jW;)o{ zC-dIdh5Jl52%P3|^8w2(HcmHexFoH^Qn9d`bJZl#%6>NQ# zBr}TKNKffV8x1KnjMjk(Ijg$NKyQCxrESU1TlP{@d5($oXSY+rM%z=%uKcPgA)YK= zEVm`TANkDf3ZX!emye5oFW8!?zhvGwhP>wN$tKn2B}NfL>Nql zT$k^F`5usv!tHEwq((NHq9m|#p<$??O^EqUc}n!YtkowjxbuOBO64_4n<9eZ!hQ(v?i!rykmFS_uy&D{c;RWpm&vf)z zFLqr7{wYcWM{QWQ2PK$J;;2O23t9fuBCUr#EQA$%!D5vmARc-|MRx71LCvnTfQ{RvtC+aqqg6N>+^*4MMbl{s z%Jo;byd~{NJRbdIT^;#|bttnwhiTgr4!enY6(n$PfO2A9K%*^EjQ*T8txdUHsHrEY zOO)+)3*y7x`nt}w683slEhqk`hw3k?*mYi_)1w=-?B|?JfDj-Tn?fD*ntU-$kRk;Q d6QJ@6rb$WXNq7@1&w4;YU_@oaXYvW{e*yIv*lqv- literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/dummy_video.mp4 b/test/fixtures/wpt/fetch/api/request/destination/resources/dummy_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7022e75c15ee5263f64bf5c2d86b19b72d2ab5f9 GIT binary patch literal 67369 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%0AxWB;E~=xj7<{Wt4OeKYACt+d&&88fiV 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
T9P1x>B_034Je7+PfzJUlp#H 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 O{=nA(AP!glu>TjvI>Ctm literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/empty.https.html b/test/fixtures/wpt/fetch/api/request/destination/resources/empty.https.html new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-frame.js b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-frame.js new file mode 100644 index 0000000..b69de0b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-frame.js @@ -0,0 +1,20 @@ +self.addEventListener('fetch', function(event) { + if (event.request.url.includes('dummy')) { + event.waitUntil(async function() { + let destination = new URL(event.request.url).searchParams.get("dest"); + let clients = await self.clients.matchAll({"includeUncontrolled": true}); + clients.forEach(function(client) { + if (client.url.includes("fetch-destination-frame")) { + if (event.request.destination == destination) { + client.postMessage("PASS"); + } else { + client.postMessage("FAIL"); + } + } + }) + }()); + } + event.respondWith(fetch(event.request)); +}); + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js new file mode 100644 index 0000000..7634583 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-iframe.js @@ -0,0 +1,20 @@ +self.addEventListener('fetch', function(event) { + if (event.request.url.includes('dummy')) { + event.waitUntil(async function() { + let destination = new URL(event.request.url).searchParams.get("dest"); + let clients = await self.clients.matchAll({"includeUncontrolled": true}); + clients.forEach(function(client) { + if (client.url.includes("fetch-destination-iframe")) { + if (event.request.destination == destination) { + client.postMessage("PASS"); + } else { + client.postMessage("FAIL"); + } + } + }) + }()); + } + event.respondWith(fetch(event.request)); +}); + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js new file mode 100644 index 0000000..a583b12 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker-no-load-event.js @@ -0,0 +1,20 @@ +self.addEventListener('fetch', function(event) { + const url = event.request.url; + if (url.includes('dummy') && url.includes('?')) { + event.waitUntil(async function() { + let destination = new URL(url).searchParams.get("dest"); + var result = "FAIL"; + if (event.request.destination == destination || + (event.request.destination == "empty" && destination == "")) { + result = "PASS"; + } + let cl = await clients.matchAll({includeUncontrolled: true}); + for (i = 0; i < cl.length; i++) { + cl[i].postMessage(result); + } + }()) + } + event.respondWith(fetch(event.request)); +}); + + diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker.js b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker.js new file mode 100644 index 0000000..904009c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/fetch-destination-worker.js @@ -0,0 +1,12 @@ +self.addEventListener('fetch', function(event) { + if (event.request.url.includes('dummy')) { + let destination = new URL(event.request.url).searchParams.get("dest"); + if (event.request.destination == destination || + (event.request.destination == "empty" && destination == "")) { + event.respondWith(fetch(event.request)); + } else { + event.respondWith(Response.error()); + } + } +}); + diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-css.js b/test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-css.js new file mode 100644 index 0000000..3c8cf1f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-css.js @@ -0,0 +1 @@ +import "./dummy.css?dest=style" with { type: "css" }; diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-json.js b/test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-json.js new file mode 100644 index 0000000..b2d964d --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/import-declaration-type-json.js @@ -0,0 +1 @@ +import "./dummy.json?dest=json" with { type: "json" }; diff --git a/test/fixtures/wpt/fetch/api/request/destination/resources/importer.js b/test/fixtures/wpt/fetch/api/request/destination/resources/importer.js new file mode 100644 index 0000000..9568474 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/destination/resources/importer.js @@ -0,0 +1 @@ +importScripts("dummy?t=importScripts&dest=script"); diff --git a/test/fixtures/wpt/fetch/api/request/forbidden-method.any.js b/test/fixtures/wpt/fetch/api/request/forbidden-method.any.js new file mode 100644 index 0000000..eb13f37 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/forbidden-method.any.js @@ -0,0 +1,13 @@ +// META: global=window,worker + +// https://fetch.spec.whatwg.org/#forbidden-method +for (const method of [ + 'CONNECT', 'TRACE', 'TRACK', + 'connect', 'trace', 'track' + ]) { + test(function() { + assert_throws_js(TypeError, + function() { new Request('./', {method: method}); } + ); + }, 'Request() with a forbidden method ' + method + ' must throw.'); +} diff --git a/test/fixtures/wpt/fetch/api/request/multi-globals/construct-in-detached-frame.window.js b/test/fixtures/wpt/fetch/api/request/multi-globals/construct-in-detached-frame.window.js new file mode 100644 index 0000000..b0d6ba5 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/multi-globals/construct-in-detached-frame.window.js @@ -0,0 +1,11 @@ +// This is a regression test for Chromium issue https://crbug.com/1427266. +test(() => { + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const otherRequest = iframe.contentWindow.Request; + iframe.remove(); + const r1 = new otherRequest('resource', { method: 'POST', body: 'string' }); + const r2 = new otherRequest(r1); + assert_true(r1.bodyUsed); + assert_false(r2.bodyUsed); +}, 'creating a request from another request in a detached realm should work'); diff --git a/test/fixtures/wpt/fetch/api/request/multi-globals/current/current.html b/test/fixtures/wpt/fetch/api/request/multi-globals/current/current.html new file mode 100644 index 0000000..9bb6e0b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/multi-globals/current/current.html @@ -0,0 +1,3 @@ + +Current page used as a test helper + diff --git a/test/fixtures/wpt/fetch/api/request/multi-globals/incumbent/incumbent.html b/test/fixtures/wpt/fetch/api/request/multi-globals/incumbent/incumbent.html new file mode 100644 index 0000000..a885b8a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/multi-globals/incumbent/incumbent.html @@ -0,0 +1,14 @@ + +Incumbent page used as a test helper + + + + diff --git a/test/fixtures/wpt/fetch/api/request/multi-globals/url-parsing.html b/test/fixtures/wpt/fetch/api/request/multi-globals/url-parsing.html new file mode 100644 index 0000000..df60e72 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/multi-globals/url-parsing.html @@ -0,0 +1,27 @@ + +Request constructor URL parsing, with multiple globals in play + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/request-bad-port.any.js b/test/fixtures/wpt/fetch/api/request/request-bad-port.any.js new file mode 100644 index 0000000..915063b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-bad-port.any.js @@ -0,0 +1,94 @@ +// META: global=window,worker + +// list of bad ports according to +// https://fetch.spec.whatwg.org/#port-blocking +var BLOCKED_PORTS_LIST = [ + 1, // tcpmux + 7, // echo + 9, // discard + 11, // systat + 13, // daytime + 15, // netstat + 17, // qotd + 19, // chargen + 20, // ftp-data + 21, // ftp + 22, // ssh + 23, // telnet + 25, // smtp + 37, // time + 42, // name + 43, // nicname + 53, // domain + 69, // tftp + 77, // priv-rjs + 79, // finger + 87, // ttylink + 95, // supdup + 101, // hostriame + 102, // iso-tsap + 103, // gppitnp + 104, // acr-nema + 109, // pop2 + 110, // pop3 + 111, // sunrpc + 113, // auth + 115, // sftp + 117, // uucp-path + 119, // nntp + 123, // ntp + 135, // loc-srv / epmap + 137, // netbios-ns + 139, // netbios-ssn + 143, // imap2 + 161, // snmp + 179, // bgp + 389, // ldap + 427, // afp (alternate) + 465, // smtp (alternate) + 512, // print / exec + 513, // login + 514, // shell + 515, // printer + 526, // tempo + 530, // courier + 531, // chat + 532, // netnews + 540, // uucp + 548, // afp + 554, // rtsp + 556, // remotefs + 563, // nntp+ssl + 587, // smtp (outgoing) + 601, // syslog-conn + 636, // ldap+ssl + 989, // ftps-data + 990, // ftps + 993, // ldap+ssl + 995, // pop3+ssl + 1719, // h323gatestat + 1720, // h323hostcall + 1723, // pptp + 2049, // nfs + 3659, // apple-sasl + 4045, // lockd + 4190, // sieve + 5060, // sip + 5061, // sips + 6000, // x11 + 6566, // sane-port + 6665, // irc (alternate) + 6666, // irc (alternate) + 6667, // irc (default) + 6668, // irc (alternate) + 6669, // irc (alternate) + 6679, // osaut + 6697, // irc+tls + 10080, // amanda +]; + +BLOCKED_PORTS_LIST.map(function(a){ + promise_test(function(t){ + return promise_rejects_js(t, TypeError, fetch(`${location.origin}:${a}`)) + }, 'Request on bad port ' + a + ' should throw TypeError.'); +}); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-default-conditional.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-default-conditional.any.js new file mode 100644 index 0000000..c5b2001 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-default-conditional.any.js @@ -0,0 +1,170 @@ +// META: global=window,worker +// META: title=Request cache - default with conditional requests +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "default" mode with an If-Modified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Modified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Modified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Modified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Modified-Since header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Modified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Modified-Since header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Modified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-None-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-None-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-None-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-None-Match header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-None-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Unmodified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Unmodified-Since": now.toGMTString()}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Unmodified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Unmodified-Since header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Unmodified-Since": now.toGMTString()}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Match header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Match": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Match header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Match header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Match": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Range header (following a request without additional headers) is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Range": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Range header (following a request without additional headers) is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{}, {"If-Range": '"foo"'}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "default" mode with an If-Range header is treated similarly to "no-store"', + state: "stale", + request_cache: ["default", "default"], + request_headers: [{"If-Range": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "default" mode with an If-Range header is treated similarly to "no-store"', + state: "fresh", + request_cache: ["default", "default"], + request_headers: [{"If-Range": '"foo"'}, {}], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-default.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-default.any.js new file mode 100644 index 0000000..dfa8369 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-default.any.js @@ -0,0 +1,39 @@ +// META: global=window,worker +// META: title=Request cache - default +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "default" mode checks the cache for previously cached content and goes to the network for stale responses', + state: "stale", + request_cache: ["default", "default"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "default" mode checks the cache for previously cached content and avoids going to the network if a fresh response exists', + state: "fresh", + request_cache: ["default", "default"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'Responses with the "Cache-Control: no-store" header are not stored in the cache', + state: "stale", + cache_control: "no-store", + request_cache: ["default", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'Responses with the "Cache-Control: no-store" header are not stored in the cache', + state: "fresh", + cache_control: "no-store", + request_cache: ["default", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-force-cache.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-force-cache.any.js new file mode 100644 index 0000000..00dce09 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-force-cache.any.js @@ -0,0 +1,67 @@ +// META: global=window,worker +// META: title=Request cache - force-cache +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and avoid revalidation for stale responses', + state: "stale", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and avoid revalidation for fresh responses', + state: "fresh", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response is not found', + state: "stale", + request_cache: ["force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response is not found', + state: "fresh", + request_cache: ["force-cache"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response would vary', + state: "stale", + vary: "*", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "force-cache" mode checks the cache for previously cached content and goes to the network if a cached response would vary', + state: "fresh", + vary: "*", + request_cache: ["default", "force-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "force-cache" stores the response in the cache if it goes to the network', + state: "stale", + request_cache: ["force-cache", "default"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "force-cache" stores the response in the cache if it goes to the network', + state: "fresh", + request_cache: ["force-cache", "default"], + expected_validation_headers: [false], + expected_no_cache_headers: [false], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-no-cache.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-no-cache.any.js new file mode 100644 index 0000000..41fc22b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-no-cache.any.js @@ -0,0 +1,25 @@ +// META: global=window,worker +// META: title=Request cache : no-cache +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "no-cache" mode revalidates stale responses found in the cache', + state: "stale", + request_cache: ["default", "no-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + expected_max_age_headers: [false, true], + }, + { + name: 'RequestCache "no-cache" mode revalidates fresh responses found in the cache', + state: "fresh", + request_cache: ["default", "no-cache"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [false, false], + expected_max_age_headers: [false, true], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-no-store.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-no-store.any.js new file mode 100644 index 0000000..9a28718 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-no-store.any.js @@ -0,0 +1,37 @@ +// META: global=window,worker +// META: title=Request cache - no store +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "no-store" mode does not check the cache for previously cached content and goes to the network regardless', + state: "stale", + request_cache: ["default", "no-store"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "no-store" mode does not check the cache for previously cached content and goes to the network regardless', + state: "fresh", + request_cache: ["default", "no-store"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "no-store" mode does not store the response in the cache', + state: "stale", + request_cache: ["no-store", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "no-store" mode does not store the response in the cache', + state: "fresh", + request_cache: ["no-store", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [true, false], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-only-if-cached.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-only-if-cached.any.js new file mode 100644 index 0000000..1305787 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-only-if-cached.any.js @@ -0,0 +1,66 @@ +// META: global=window,dedicatedworker,sharedworker +// META: title=Request cache - only-if-cached +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +// FIXME: avoid mixed content requests to enable service worker global +var tests = [ + { + name: 'RequestCache "only-if-cached" mode checks the cache for previously cached content and avoids revalidation for stale responses', + state: "stale", + request_cache: ["default", "only-if-cached"], + expected_validation_headers: [false], + expected_no_cache_headers: [false] + }, + { + name: 'RequestCache "only-if-cached" mode checks the cache for previously cached content and avoids revalidation for fresh responses', + state: "fresh", + request_cache: ["default", "only-if-cached"], + expected_validation_headers: [false], + expected_no_cache_headers: [false] + }, + { + name: 'RequestCache "only-if-cached" mode checks the cache for previously cached content and does not go to the network if a cached response is not found', + state: "fresh", + request_cache: ["only-if-cached"], + response: ["error"], + expected_validation_headers: [], + expected_no_cache_headers: [] + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") uses cached same-origin redirects to same-origin content', + state: "fresh", + request_cache: ["default", "only-if-cached"], + redirect: "same-origin", + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") uses cached same-origin redirects to same-origin content', + state: "stale", + request_cache: ["default", "only-if-cached"], + redirect: "same-origin", + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") does not follow redirects across origins and rejects', + state: "fresh", + request_cache: ["default", "only-if-cached"], + redirect: "cross-origin", + response: [null, "error"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, + { + name: 'RequestCache "only-if-cached" (with "same-origin") does not follow redirects across origins and rejects', + state: "stale", + request_cache: ["default", "only-if-cached"], + redirect: "cross-origin", + response: [null, "error"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, false], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache-reload.any.js b/test/fixtures/wpt/fetch/api/request/request-cache-reload.any.js new file mode 100644 index 0000000..c7bfffb --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache-reload.any.js @@ -0,0 +1,51 @@ +// META: global=window,worker +// META: title=Request cache - reload +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=request-cache.js + +var tests = [ + { + name: 'RequestCache "reload" mode does not check the cache for previously cached content and goes to the network regardless', + state: "stale", + request_cache: ["default", "reload"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "reload" mode does not check the cache for previously cached content and goes to the network regardless', + state: "fresh", + request_cache: ["default", "reload"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache', + state: "stale", + request_cache: ["reload", "default"], + expected_validation_headers: [false, true], + expected_no_cache_headers: [true, false], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache', + state: "fresh", + request_cache: ["reload", "default"], + expected_validation_headers: [false], + expected_no_cache_headers: [true], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache even if a previous response is already stored', + state: "stale", + request_cache: ["default", "reload", "default"], + expected_validation_headers: [false, false, true], + expected_no_cache_headers: [false, true, false], + }, + { + name: 'RequestCache "reload" mode does store the response in the cache even if a previous response is already stored', + state: "fresh", + request_cache: ["default", "reload", "default"], + expected_validation_headers: [false, false], + expected_no_cache_headers: [false, true], + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/api/request/request-cache.js b/test/fixtures/wpt/fetch/api/request/request-cache.js new file mode 100644 index 0000000..f2fbecf --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-cache.js @@ -0,0 +1,223 @@ +/** + * Each test is run twice: once using etag/If-None-Match and once with + * date/If-Modified-Since. Each test run gets its own URL and randomized + * content and operates independently. + * + * The test steps are run with request_cache.length fetch requests issued + * and their immediate results sanity-checked. The cache.py server script + * stashes an entry containing any If-None-Match, If-Modified-Since, Pragma, + * and Cache-Control observed headers for each request it receives. When + * the test fetches have run, this state is retrieved from cache.py and the + * expected_* lists are checked, including their length. + * + * This means that if a request_* fetch is expected to hit the cache and not + * touch the network, then there will be no entry for it in the expect_* + * lists. AKA (request_cache.length - expected_validation_headers.length) + * should equal the number of cache hits that didn't touch the network. + * + * Test dictionary keys: + * - state: required string that determines whether the Expires response for + * the fetched document should be set in the future ("fresh") or past + * ("stale"). + * - vary: optional string to be passed to the server for it to quote back + * in a Vary header on the response to us. + * - cache_control: optional string to be passed to the server for it to + * quote back in a Cache-Control header on the response to us. + * - redirect: optional string "same-origin" or "cross-origin". If + * provided, the server will issue an absolute redirect to the script on + * the same or a different origin, as appropriate. The redirected + * location is the script with the redirect parameter removed, so the + * content/state/etc. will be as if you hadn't specified a redirect. + * - request_cache: required array of cache modes to use (via `cache`). + * - request_headers: optional array of explicit fetch `headers` arguments. + * If provided, the server will log an empty dictionary for each request + * instead of the request headers it would normally log. + * - response: optional array of specialized response handling. Right now, + * "error" array entries indicate a network error response is expected + * which will reject with a TypeError. + * - expected_validation_headers: required boolean array indicating whether + * the server should have seen an If-None-Match/If-Modified-Since header + * in the request. + * - expected_no_cache_headers: required boolean array indicating whether + * the server should have seen Pragma/Cache-control:no-cache headers in + * the request. + * - expected_max_age_headers: optional boolean array indicating whether + * the server should have seen a Cache-Control:max-age=0 header in the + * request. + */ + +var now = new Date(); + +function base_path() { + return location.pathname.replace(/\/[^\/]*$/, '/'); +} +function make_url(uuid, id, value, content, info) { + var dates = { + fresh: new Date(now.getFullYear() + 1, now.getMonth(), now.getDay()).toGMTString(), + stale: new Date(now.getFullYear() - 1, now.getMonth(), now.getDay()).toGMTString(), + }; + var vary = ""; + if ("vary" in info) { + vary = "&vary=" + info.vary; + } + var cache_control = ""; + if ("cache_control" in info) { + cache_control = "&cache_control=" + info.cache_control; + } + var redirect = ""; + + var ignore_request_headers = ""; + if ("request_headers" in info) { + // Ignore the request headers that we send since they may be synthesized by the test. + ignore_request_headers = "&ignore"; + } + var url_sans_redirect = "resources/cache.py?token=" + uuid + + "&content=" + content + + "&" + id + "=" + value + + "&expires=" + dates[info.state] + + vary + cache_control + ignore_request_headers; + // If there's a redirect, the target is the script without any redirect at + // either the same domain or a different domain. + if ("redirect" in info) { + var host_info = get_host_info(); + var origin; + switch (info.redirect) { + case "same-origin": + origin = host_info['HTTP_ORIGIN']; + break; + case "cross-origin": + origin = host_info['HTTP_REMOTE_ORIGIN']; + break; + } + var redirected_url = origin + base_path() + url_sans_redirect; + return url_sans_redirect + "&redirect=" + encodeURIComponent(redirected_url); + } else { + return url_sans_redirect; + } +} +function expected_status(type, identifier, init) { + if (type == "date" && + init.headers && + init.headers["If-Modified-Since"] == identifier) { + // The server will respond with a 304 in this case. + return [304, "Not Modified"]; + } + return [200, "OK"]; +} +function expected_response_text(type, identifier, init, content) { + if (type == "date" && + init.headers && + init.headers["If-Modified-Since"] == identifier) { + // The server will respond with a 304 in this case. + return ""; + } + return content; +} +function server_state(uuid) { + return fetch("resources/cache.py?querystate&token=" + uuid) + .then(function(response) { + return response.text(); + }).then(function(text) { + // null will be returned if the server never received any requests + // for the given uuid. Normalize that to an empty list consistent + // with our representation. + return JSON.parse(text) || []; + }); +} +function make_test(type, info) { + return function(test) { + var uuid = token(); + var identifier = (type == "tag" ? Math.random() : now.toGMTString()); + var content = Math.random().toString(); + var url = make_url(uuid, type, identifier, content, info); + var fetch_functions = []; + for (var i = 0; i < info.request_cache.length; ++i) { + fetch_functions.push(function(idx) { + var init = {cache: info.request_cache[idx]}; + if ("request_headers" in info) { + init.headers = info.request_headers[idx]; + } + if (init.cache === "only-if-cached") { + // only-if-cached requires we use same-origin mode. + init.mode = "same-origin"; + } + return fetch(url, init) + .then(function(response) { + if ("response" in info && info.response[idx] === "error") { + assert_true(false, "fetch should have been an error"); + return; + } + assert_array_equals([response.status, response.statusText], + expected_status(type, identifier, init)); + return response.text(); + }).then(function(text) { + assert_equals(text, expected_response_text(type, identifier, init, content)); + }, function(reason) { + if ("response" in info && info.response[idx] === "error") { + assert_throws_js(TypeError, function() { throw reason; }); + } else { + throw reason; + } + }); + }); + } + var i = 0; + function run_next_step() { + if (fetch_functions.length) { + return fetch_functions.shift()(i++) + .then(run_next_step); + } else { + return Promise.resolve(); + } + } + return run_next_step() + .then(function() { + // Now, query the server state + return server_state(uuid); + }).then(function(state) { + var expectedState = []; + info.expected_validation_headers.forEach(function (validate) { + if (validate) { + if (type == "tag") { + expectedState.push({"If-None-Match": '"' + identifier + '"'}); + } else { + expectedState.push({"If-Modified-Since": identifier}); + } + } else { + expectedState.push({}); + } + }); + for (var i = 0; i < info.expected_no_cache_headers.length; ++i) { + if (info.expected_no_cache_headers[i]) { + expectedState[i]["Pragma"] = "no-cache"; + expectedState[i]["Cache-Control"] = "no-cache"; + } + } + if ("expected_max_age_headers" in info) { + for (var i = 0; i < info.expected_max_age_headers.length; ++i) { + if (info.expected_max_age_headers[i]) { + expectedState[i]["Cache-Control"] = "max-age=0"; + } + } + } + assert_equals(state.length, expectedState.length); + for (var i = 0; i < state.length; ++i) { + for (var header in state[i]) { + assert_equals(state[i][header], expectedState[i][header]); + delete expectedState[i][header]; + } + for (var header in expectedState[i]) { + assert_false(header in state[i]); + } + } + }); + }; +} + +function run_tests(tests) +{ + tests.forEach(function(info) { + promise_test(make_test("tag", info), info.name + " with Etag and " + info.state + " response"); + promise_test(make_test("date", info), info.name + " with Last-Modified and " + info.state + " response"); + }); +} diff --git a/test/fixtures/wpt/fetch/api/request/request-clone.sub.html b/test/fixtures/wpt/fetch/api/request/request-clone.sub.html new file mode 100644 index 0000000..c690bb3 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-clone.sub.html @@ -0,0 +1,63 @@ + + + + + Request clone + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/request-constructor-init-body-override.any.js b/test/fixtures/wpt/fetch/api/request/request-constructor-init-body-override.any.js new file mode 100644 index 0000000..c2bbf86 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-constructor-init-body-override.any.js @@ -0,0 +1,37 @@ +promise_test(async function () { + const req1 = new Request("https://example.com/", { + body: "req1", + method: "POST", + }); + + const text1 = await req1.text(); + assert_equals( + text1, + "req1", + "The body of the first request should be 'req1'." + ); + + const req2 = new Request(req1, { body: "req2" }); + const bodyText = await req2.text(); + assert_equals( + bodyText, + "req2", + "The body of the second request should be overridden to 'req2'." + ); + +}, "Check that the body of a new request can be overridden when created from an existing Request object"); + +promise_test(async function () { + const req1 = new Request("https://example.com/", { + body: "req1", + method: "POST", + }); + + const req2 = new Request("https://example.com/", req1); + const bodyText = await req2.text(); + assert_equals( + bodyText, + "req1", + "The body of the second request should be the same as the first." + ); +}, "Check that the body of a new request can be duplicated from an existing Request object"); diff --git a/test/fixtures/wpt/fetch/api/request/request-consume-empty.any.js b/test/fixtures/wpt/fetch/api/request/request-consume-empty.any.js new file mode 100644 index 0000000..0bf9672 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-consume-empty.any.js @@ -0,0 +1,89 @@ +// META: global=window,worker +// META: title=Request consume empty bodies + +function checkBodyText(test, request) { + return request.text().then(function(bodyAsText) { + assert_equals(bodyAsText, "", "Resolved value should be empty"); + assert_false(request.bodyUsed); + }); +} + +async function checkBodyBlob(test, request) { + const bodyAsBlob = await request.blob(); + const body = await bodyAsBlob.text(); + assert_equals(body, "", "Resolved value should be empty"); + assert_false(request.bodyUsed); +} + +function checkBodyArrayBuffer(test, request) { + return request.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_false(request.bodyUsed); + }); +} + +function checkBodyJSON(test, request) { + return request.json().then( + function(bodyAsJSON) { + assert_unreached("JSON parsing should fail"); + }, + function() { + assert_false(request.bodyUsed); + }); +} + +function checkBodyFormData(test, request) { + return request.formData().then(function(bodyAsFormData) { + assert_true(bodyAsFormData instanceof FormData, "Should receive a FormData"); + assert_false(request.bodyUsed); + }); +} + +function checkBodyFormDataError(test, request) { + return promise_rejects_js(test, TypeError, request.formData()).then(function() { + assert_false(request.bodyUsed); + }); +} + +function checkRequestWithNoBody(bodyType, checkFunction, headers = []) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "headers": headers}); + assert_false(request.bodyUsed); + return checkFunction(test, request); + }, "Consume request's body as " + bodyType); +} + +checkRequestWithNoBody("text", checkBodyText); +checkRequestWithNoBody("blob", checkBodyBlob); +checkRequestWithNoBody("arrayBuffer", checkBodyArrayBuffer); +checkRequestWithNoBody("json (error case)", checkBodyJSON); +checkRequestWithNoBody("formData with correct multipart type (error case)", checkBodyFormDataError, [["Content-Type", 'multipart/form-data; boundary="boundary"']]); +checkRequestWithNoBody("formData with correct urlencoded type", checkBodyFormData, [["Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"]]); +checkRequestWithNoBody("formData without correct type (error case)", checkBodyFormDataError); + +function checkRequestWithEmptyBody(bodyType, body, asText) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body}); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + if (asText) { + return request.text().then(function(bodyAsString) { + assert_equals(bodyAsString.length, 0, "Resolved value should be empty"); + assert_true(request.bodyUsed, "bodyUsed is true after being consumed"); + }); + } + return request.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_true(request.bodyUsed, "bodyUsed is true after being consumed"); + }); + }, "Consume empty " + bodyType + " request body as " + (asText ? "text" : "arrayBuffer")); +} + +// FIXME: Add BufferSource, FormData and URLSearchParams. +checkRequestWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), false); +checkRequestWithEmptyBody("text", "", false); +checkRequestWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), true); +checkRequestWithEmptyBody("text", "", true); +checkRequestWithEmptyBody("URLSearchParams", new URLSearchParams(""), true); +// FIXME: This test assumes that the empty string be returned but it is not clear whether that is right. See https://github.com/web-platform-tests/wpt/pull/3950. +checkRequestWithEmptyBody("FormData", new FormData(), true); +checkRequestWithEmptyBody("ArrayBuffer", new ArrayBuffer(), true); diff --git a/test/fixtures/wpt/fetch/api/request/request-consume.any.js b/test/fixtures/wpt/fetch/api/request/request-consume.any.js new file mode 100644 index 0000000..b4cbe74 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-consume.any.js @@ -0,0 +1,148 @@ +// META: global=window,worker +// META: title=Request consume +// META: script=../resources/utils.js + +function checkBodyText(request, expectedBody) { + return request.text().then(function(bodyAsText) { + assert_equals(bodyAsText, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as text: bodyUsed turned true"); + }); +} + +async function checkBodyBlob(request, expectedBody, checkContentType) { + const bodyAsBlob = await request.blob(); + + if (checkContentType) + assert_equals(bodyAsBlob.type, "text/plain", "Blob body type should be computed from the request Content-Type"); + + const body = await bodyAsBlob.text(); + assert_equals(body, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as blob: bodyUsed turned true"); +} + +function checkBodyArrayBuffer(request, expectedBody) { + return request.arrayBuffer().then(function(bodyAsArrayBuffer) { + validateBufferFromString(bodyAsArrayBuffer, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as arrayBuffer: bodyUsed turned true"); + }); +} + +function checkBodyBytes(request, expectedBody) { + return request.bytes().then(function(bodyAsUint8Array) { + assert_true(bodyAsUint8Array instanceof Uint8Array); + validateBufferFromString(bodyAsUint8Array.buffer, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as bytes: bodyUsed turned true"); + }); +} + +function checkBodyJSON(request, expectedBody) { + return request.json().then(function(bodyAsJSON) { + var strBody = JSON.stringify(bodyAsJSON) + assert_equals(strBody, expectedBody, "Retrieve and verify request's body"); + assert_true(request.bodyUsed, "body as json: bodyUsed turned true"); + }); +} + +function checkBodyFormData(request, expectedBody) { + return request.formData().then(function(bodyAsFormData) { + assert_true(bodyAsFormData instanceof FormData, "Should receive a FormData"); + assert_true(request.bodyUsed, "body as formData: bodyUsed turned true"); + }); +} + +function checkRequestBody(body, expected, bodyType) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body, "headers": [["Content-Type", "text/PLAIN"]] }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyText(request, expected); + }, "Consume " + bodyType + " request's body as text"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyBlob(request, expected); + }, "Consume " + bodyType + " request's body as blob"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyArrayBuffer(request, expected); + }, "Consume " + bodyType + " request's body as arrayBuffer"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyBytes(request, expected); + }, "Consume " + bodyType + " request's body as bytes"); + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": body }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyJSON(request, expected); + }, "Consume " + bodyType + " request's body as JSON"); +} + +var textData = JSON.stringify("This is response's body"); +var blob = new Blob([textData], { "type" : "text/plain" }); + +checkRequestBody(textData, textData, "String"); + +var string = "\"123456\""; +function getArrayBuffer() { + var arrayBuffer = new ArrayBuffer(8); + var int8Array = new Int8Array(arrayBuffer); + for (var cptr = 0; cptr < 8; cptr++) + int8Array[cptr] = string.charCodeAt(cptr); + return arrayBuffer; +} + +function getArrayBufferWithZeros() { + var arrayBuffer = new ArrayBuffer(10); + var int8Array = new Int8Array(arrayBuffer); + for (var cptr = 0; cptr < 8; cptr++) + int8Array[cptr + 1] = string.charCodeAt(cptr); + return arrayBuffer; +} + +checkRequestBody(getArrayBuffer(), string, "ArrayBuffer"); +checkRequestBody(new Uint8Array(getArrayBuffer()), string, "Uint8Array"); +checkRequestBody(new Int8Array(getArrayBufferWithZeros(), 1, 8), string, "Int8Array"); +checkRequestBody(new Float32Array(getArrayBuffer()), string, "Float32Array"); +checkRequestBody(new DataView(getArrayBufferWithZeros(), 1, 8), string, "DataView"); + +promise_test(function(test) { + var formData = new FormData(); + formData.append("name", "value") + var request = new Request("", {"method": "POST", "body": formData }); + assert_false(request.bodyUsed, "bodyUsed is false at init"); + return checkBodyFormData(request, formData); +}, "Consume FormData request's body as FormData"); + +function checkBlobResponseBody(blobBody, blobData, bodyType, checkFunction) { + promise_test(function(test) { + var response = new Response(blobBody); + assert_false(response.bodyUsed, "bodyUsed is false at init"); + return checkFunction(response, blobData); + }, "Consume blob response's body as " + bodyType); +} + +checkBlobResponseBody(blob, textData, "blob", checkBodyBlob); +checkBlobResponseBody(blob, textData, "text", checkBodyText); +checkBlobResponseBody(blob, textData, "json", checkBodyJSON); +checkBlobResponseBody(blob, textData, "arrayBuffer", checkBodyArrayBuffer); +checkBlobResponseBody(blob, textData, "bytes", checkBodyBytes); +checkBlobResponseBody(new Blob([""]), "", "blob (empty blob as input)", checkBodyBlob); + +var goodJSONValues = ["null", "1", "true", "\"string\""]; +goodJSONValues.forEach(function(value) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": value}); + return request.json().then(function(v) { + assert_equals(v, JSON.parse(value)); + }); + }, "Consume JSON from text: '" + JSON.stringify(value) + "'"); +}); + +var badJSONValues = ["undefined", "{", "a", "["]; +badJSONValues.forEach(function(value) { + promise_test(function(test) { + var request = new Request("", {"method": "POST", "body": value}); + return promise_rejects_js(test, SyntaxError, request.json()); + }, "Trying to consume bad JSON text as JSON: '" + value + "'"); +}); diff --git a/test/fixtures/wpt/fetch/api/request/request-disturbed.any.js b/test/fixtures/wpt/fetch/api/request/request-disturbed.any.js new file mode 100644 index 0000000..8a11de7 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-disturbed.any.js @@ -0,0 +1,109 @@ +// META: global=window,worker +// META: title=Request disturbed +// META: script=../resources/utils.js + +var initValuesDict = {"method" : "POST", + "body" : "Request's body" +}; + +var noBodyConsumed = new Request(""); +var bodyConsumed = new Request("", initValuesDict); + +test(() => { + assert_equals(noBodyConsumed.body, null, "body's default value is null"); + assert_false(noBodyConsumed.bodyUsed , "bodyUsed is false when request is not disturbed"); + assert_not_equals(bodyConsumed.body, null, "non-null body"); + assert_true(bodyConsumed.body instanceof ReadableStream, "non-null body type"); + assert_false(noBodyConsumed.bodyUsed, "bodyUsed is false when request is not disturbed"); +}, "Request's body: initial state"); + +noBodyConsumed.blob(); +bodyConsumed.blob(); + +test(function() { + assert_false(noBodyConsumed.bodyUsed , "bodyUsed is false when request is not disturbed"); + try { + noBodyConsumed.clone(); + } catch (e) { + assert_unreached("Can use request not disturbed for creating or cloning request"); + } +}, "Request without body cannot be disturbed"); + +test(function() { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_throws_js(TypeError, function() { bodyConsumed.clone(); }); +}, "Check cloning a disturbed request"); + +test(function() { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_throws_js(TypeError, function() { new Request(bodyConsumed); }); +}, "Check creating a new request from a disturbed request"); + +promise_test(function() { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + const originalBody = bodyConsumed.body; + const bodyReplaced = new Request(bodyConsumed, { body: "Replaced body" }); + assert_not_equals(bodyReplaced.body, originalBody, "new request's body is new"); + assert_false(bodyReplaced.bodyUsed, "bodyUsed is false when request is not disturbed"); + return bodyReplaced.text().then(text => { + assert_equals(text, "Replaced body"); + }); +}, "Check creating a new request with a new body from a disturbed request"); + +promise_test(function() { + var bodyRequest = new Request("", initValuesDict); + const originalBody = bodyRequest.body; + assert_false(bodyRequest.bodyUsed , "bodyUsed is false when request is not disturbed"); + var requestFromRequest = new Request(bodyRequest); + assert_true(bodyRequest.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_equals(bodyRequest.body, originalBody, "body should not change"); + assert_not_equals(originalBody, undefined, "body should not be undefined"); + assert_not_equals(originalBody, null, "body should not be null"); + assert_not_equals(requestFromRequest.body, originalBody, "new request's body is new"); + return requestFromRequest.text().then(text => { + assert_equals(text, "Request's body"); + }); +}, "Input request used for creating new request became disturbed"); + +promise_test(() => { + const bodyRequest = new Request("", initValuesDict); + const originalBody = bodyRequest.body; + assert_false(bodyRequest.bodyUsed , "bodyUsed is false when request is not disturbed"); + const requestFromRequest = new Request(bodyRequest, { body : "init body" }); + assert_true(bodyRequest.bodyUsed , "bodyUsed is true when request is disturbed"); + assert_equals(bodyRequest.body, originalBody, "body should not change"); + assert_not_equals(originalBody, undefined, "body should not be undefined"); + assert_not_equals(originalBody, null, "body should not be null"); + assert_not_equals(requestFromRequest.body, originalBody, "new request's body is new"); + + return requestFromRequest.text().then(text => { + assert_equals(text, "init body"); + }); +}, "Input request used for creating new request became disturbed even if body is not used"); + +promise_test(function(test) { + assert_true(bodyConsumed.bodyUsed , "bodyUsed is true when request is disturbed"); + return promise_rejects_js(test, TypeError, bodyConsumed.blob()); +}, "Check consuming a disturbed request"); + +test(function() { + var req = new Request(URL, {method: 'POST', body: 'hello'}); + assert_false(req.bodyUsed, + 'Request should not be flagged as used if it has not been ' + + 'consumed.'); + assert_throws_js(TypeError, + function() { new Request(req, {method: 'GET'}); }, + 'A get request may not have body.'); + + assert_false(req.bodyUsed, 'After the GET case'); + + assert_throws_js(TypeError, + function() { new Request(req, {method: 'CONNECT'}); }, + 'Request() with a forbidden method must throw.'); + + assert_false(req.bodyUsed, 'After the forbidden method case'); + + var req2 = new Request(req); + assert_true(req.bodyUsed, + 'Request should be flagged as used if it has been consumed.'); +}, 'Request construction failure should not set "bodyUsed"'); diff --git a/test/fixtures/wpt/fetch/api/request/request-error.any.js b/test/fixtures/wpt/fetch/api/request/request-error.any.js new file mode 100644 index 0000000..9ec8015 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-error.any.js @@ -0,0 +1,56 @@ +// META: global=window,worker +// META: title=Request error +// META: script=request-error.js + +// badRequestArgTests is from response-error.js +for (const { args, testName } of badRequestArgTests) { + test(() => { + assert_throws_js( + TypeError, + () => new Request(...args), + "Expect TypeError exception" + ); + }, testName); +} + +test(function() { + assert_throws_js( + TypeError, + () => Request("about:blank"), + "Calling Request constructor without 'new' must throw" + ); +}); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from the init request"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var headers = new Headers([]); + var request = new Request(initialRequest, {"headers" : headers}); + assert_false(request.headers.has("Content-Type")); +}, "Request should not get its content-type from the init request if init headers are provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type-Extra", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "text/plain;charset=UTF-8"); +}, "Request should get its content-type from the body if none is provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from init headers if one is provided"); + +test(function() { + var options = {"cache": "only-if-cached", "mode": "same-origin"}; + new Request("test", options); +}, "Request with cache mode: only-if-cached and fetch mode: same-origin"); diff --git a/test/fixtures/wpt/fetch/api/request/request-error.js b/test/fixtures/wpt/fetch/api/request/request-error.js new file mode 100644 index 0000000..cf77313 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-error.js @@ -0,0 +1,57 @@ +const badRequestArgTests = [ + { + args: ["", { "window": "http://test.url" }], + testName: "RequestInit's window is not null" + }, + { + args: ["http://:not a valid URL"], + testName: "Input URL is not valid" + }, + { + args: ["http://user:pass@test.url"], + testName: "Input URL has credentials" + }, + { + args: ["", { "mode": "navigate" }], + testName: "RequestInit's mode is navigate" + }, + { + args: ["", { "referrer": "http://:not a valid URL" }], + testName: "RequestInit's referrer is invalid" + }, + { + args: ["", { "method": "IN VALID" }], + testName: "RequestInit's method is invalid" + }, + { + args: ["", { "method": "TRACE" }], + testName: "RequestInit's method is forbidden" + }, + { + args: ["", { "mode": "no-cors", "method": "PUT" }], + testName: "RequestInit's mode is no-cors and method is not simple" + }, + { + args: ["", { "mode": "cors", "cache": "only-if-cached" }], + testName: "RequestInit's cache mode is only-if-cached and mode is not same-origin" + }, + { + args: ["test", { "cache": "only-if-cached", "mode": "cors" }], + testName: "Request with cache mode: only-if-cached and fetch mode cors" + }, + { + args: ["test", { "cache": "only-if-cached", "mode": "no-cors" }], + testName: "Request with cache mode: only-if-cached and fetch mode no-cors" + } +]; + +badRequestArgTests.push( + ...["referrerPolicy", "mode", "credentials", "cache", "redirect"].map(optionProp => { + const options = {}; + options[optionProp] = "BAD"; + return { + args: ["", options], + testName: `Bad ${optionProp} init parameter value` + }; + }) +); diff --git a/test/fixtures/wpt/fetch/api/request/request-headers.any.js b/test/fixtures/wpt/fetch/api/request/request-headers.any.js new file mode 100644 index 0000000..a766bcb --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-headers.any.js @@ -0,0 +1,177 @@ +// META: global=window,worker +// META: title=Request Headers + +var validRequestHeaders = [ + ["Content-Type", "OK"], + ["Potato", "OK"], + ["proxy", "OK"], + ["proxya", "OK"], + ["sec", "OK"], + ["secb", "OK"], + ["Set-Cookie2", "OK"], + ["User-Agent", "OK"], +]; +var invalidRequestHeaders = [ + ["Accept-Charset", "KO"], + ["accept-charset", "KO"], + ["ACCEPT-ENCODING", "KO"], + ["Accept-Encoding", "KO"], + ["Access-Control-Request-Headers", "KO"], + ["Access-Control-Request-Method", "KO"], + ["Connection", "KO"], + ["Content-Length", "KO"], + ["Cookie", "KO"], + ["Cookie2", "KO"], + ["Date", "KO"], + ["DNT", "KO"], + ["Expect", "KO"], + ["Host", "KO"], + ["Keep-Alive", "KO"], + ["Origin", "KO"], + ["Referer", "KO"], + ["Set-Cookie", "KO"], + ["TE", "KO"], + ["Trailer", "KO"], + ["Transfer-Encoding", "KO"], + ["Upgrade", "KO"], + ["Via", "KO"], + ["Proxy-", "KO"], + ["proxy-a", "KO"], + ["Sec-", "KO"], + ["sec-b", "KO"], +]; + +var validRequestNoCorsHeaders = [ + ["Accept", "OK"], + ["Accept-Language", "OK"], + ["content-language", "OK"], + ["content-type", "application/x-www-form-urlencoded"], + ["content-type", "application/x-www-form-urlencoded;charset=UTF-8"], + ["content-type", "multipart/form-data"], + ["content-type", "multipart/form-data;charset=UTF-8"], + ["content-TYPE", "text/plain"], + ["CONTENT-type", "text/plain;charset=UTF-8"], +]; +var invalidRequestNoCorsHeaders = [ + ["Content-Type", "KO"], + ["Potato", "KO"], + ["proxy", "KO"], + ["proxya", "KO"], + ["sec", "KO"], + ["secb", "KO"], + ["Empty-Value", ""], +]; + +validRequestHeaders.forEach(function(header) { + test(function() { + var request = new Request(""); + request.headers.set(header[0], header[1]); + assert_equals(request.headers.get(header[0]), header[1]); + }, "Adding valid request header \"" + header[0] + ": " + header[1] + "\""); +}); +invalidRequestHeaders.forEach(function(header) { + test(function() { + var request = new Request(""); + request.headers.set(header[0], header[1]); + assert_equals(request.headers.get(header[0]), null); + }, "Adding invalid request header \"" + header[0] + ": " + header[1] + "\""); +}); + +validRequestNoCorsHeaders.forEach(function(header) { + test(function() { + var requestNoCors = new Request("", {"mode": "no-cors"}); + requestNoCors.headers.set(header[0], header[1]); + assert_equals(requestNoCors.headers.get(header[0]), header[1]); + }, "Adding valid no-cors request header \"" + header[0] + ": " + header[1] + "\""); +}); +invalidRequestNoCorsHeaders.forEach(function(header) { + test(function() { + var requestNoCors = new Request("", {"mode": "no-cors"}); + requestNoCors.headers.set(header[0], header[1]); + assert_equals(requestNoCors.headers.get(header[0]), null); + }, "Adding invalid no-cors request header \"" + header[0] + ": " + header[1] + "\""); +}); + +test(function() { + var headers = new Headers([["Cookie2", "potato"]]); + var request = new Request("", {"headers": headers}); + assert_equals(request.headers.get("Cookie2"), null); +}, "Check that request constructor is filtering headers provided as init parameter"); + +test(function() { + var headers = new Headers([["Content-Type", "potato"]]); + var request = new Request("", {"headers": headers, "mode": "no-cors"}); + assert_equals(request.headers.get("Content-Type"), null); +}, "Check that no-cors request constructor is filtering headers provided as init parameter"); + +test(function() { + var headers = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers": headers}); + var request = new Request(initialRequest, {"mode": "no-cors"}); + assert_equals(request.headers.get("Content-Type"), null); +}, "Check that no-cors request constructor is filtering headers provided as part of request parameter"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from the init request"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders}); + var headers = new Headers([]); + var request = new Request(initialRequest, {"headers" : headers}); + assert_false(request.headers.has("Content-Type")); +}, "Request should not get its content-type from the init request if init headers are provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type-Extra", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "text/plain;charset=UTF-8"); +}, "Request should get its content-type from the body if none is provided"); + +test(function() { + var initialHeaders = new Headers([["Content-Type", "potato"]]); + var initialRequest = new Request("", {"headers" : initialHeaders, "body" : "this is my plate", "method" : "POST"}); + var request = new Request(initialRequest); + assert_equals(request.headers.get("Content-Type"), "potato"); +}, "Request should get its content-type from init headers if one is provided"); + +test(function() { + var array = [["hello", "worldAHH"]]; + var object = {"hello": 'worldOOH'}; + var headers = new Headers(array); + + assert_equals(headers.get("hello"), "worldAHH"); + + var request1 = new Request("", {"headers": headers}); + var request2 = new Request("", {"headers": array}); + var request3 = new Request("", {"headers": object}); + + assert_equals(request1.headers.get("hello"), "worldAHH"); + assert_equals(request2.headers.get("hello"), "worldAHH"); + assert_equals(request3.headers.get("hello"), "worldOOH"); +}, "Testing request header creations with various objects"); + +promise_test(function(test) { + var request = new Request("", {"headers" : [["Content-Type", ""]], "body" : "this is my plate", "method" : "POST"}); + return request.blob().then(function(blob) { + assert_equals(blob.type, "", "Blob type should be the empty string"); + }); +}, "Testing empty Request Content-Type header"); + +test(function() { + const request1 = new Request(""); + assert_equals(request1.headers, request1.headers); + + const request2 = new Request("", {"headers": {"X-Foo": "bar"}}); + assert_equals(request2.headers, request2.headers); + const headers = request2.headers; + request2.headers.set("X-Foo", "quux"); + assert_equals(headers, request2.headers); + headers.set("X-Other-Header", "baz"); + assert_equals(headers, request2.headers); +}, "Test that Request.headers has the [SameObject] extended attribute"); diff --git a/test/fixtures/wpt/fetch/api/request/request-init-001.sub.html b/test/fixtures/wpt/fetch/api/request/request-init-001.sub.html new file mode 100644 index 0000000..cc495a6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-init-001.sub.html @@ -0,0 +1,112 @@ + + + + + Request init: simple cases + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/request-init-002.any.js b/test/fixtures/wpt/fetch/api/request/request-init-002.any.js new file mode 100644 index 0000000..abb6689 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-init-002.any.js @@ -0,0 +1,60 @@ +// META: global=window,worker +// META: title=Request init: headers and body + +test(function() { + var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3" + }; + var headers = new Headers(headerDict); + var request = new Request("", { "headers" : headers }) + for (var name in headerDict) { + assert_equals(request.headers.get(name), headerDict[name], + "request's headers has " + name + " : " + headerDict[name]); + } +}, "Initialize Request with headers values"); + +function makeRequestInit(body, method) { + return {"method": method, "body": body}; +} + +function checkRequestInit(body, bodyType, expectedTextBody) { + promise_test(function(test) { + var request = new Request("", makeRequestInit(body, "POST")); + if (body) { + assert_throws_js(TypeError, function() { new Request("", makeRequestInit(body, "GET")); }); + assert_throws_js(TypeError, function() { new Request("", makeRequestInit(body, "HEAD")); }); + } else { + new Request("", makeRequestInit(body, "GET")); // should not throw + } + var reqHeaders = request.headers; + var mime = reqHeaders.get("Content-Type"); + assert_true(!body || (mime && mime.search(bodyType) > -1), "Content-Type header should be \"" + bodyType + "\", not \"" + mime + "\""); + return request.text().then(function(bodyAsText) { + //not equals: cannot guess formData exact value + assert_true( bodyAsText.search(expectedTextBody) > -1, "Retrieve and verify request body"); + }); + }, `Initialize Request's body with "${body}", ${bodyType}`); +} + +var blob = new Blob(["This is a blob"], {type: "application/octet-binary"}); +var formaData = new FormData(); +formaData.append("name", "value"); +var usvString = "This is a USVString" + +checkRequestInit(undefined, undefined, ""); +checkRequestInit(null, null, ""); +checkRequestInit(blob, "application/octet-binary", "This is a blob"); +checkRequestInit(formaData, "multipart/form-data", "name=\"name\"\r\n\r\nvalue"); +checkRequestInit(usvString, "text/plain;charset=UTF-8", "This is a USVString"); +checkRequestInit({toString: () => "hi!"}, "text/plain;charset=UTF-8", "hi!"); + +// Ensure test does not time out in case of missing URLSearchParams support. +if (self.URLSearchParams) { + var urlSearchParams = new URLSearchParams("name=value"); + checkRequestInit(urlSearchParams, "application/x-www-form-urlencoded;charset=UTF-8", "name=value"); +} else { + promise_test(function(test) { + return Promise.reject("URLSearchParams not supported"); + }, "Initialize Request's body with application/x-www-form-urlencoded;charset=UTF-8"); +} diff --git a/test/fixtures/wpt/fetch/api/request/request-init-003.sub.html b/test/fixtures/wpt/fetch/api/request/request-init-003.sub.html new file mode 100644 index 0000000..79c91cd --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-init-003.sub.html @@ -0,0 +1,84 @@ + + + + + Request: init with request or url + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/request-init-contenttype.any.js b/test/fixtures/wpt/fetch/api/request/request-init-contenttype.any.js new file mode 100644 index 0000000..18a6969 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-init-contenttype.any.js @@ -0,0 +1,141 @@ +function requestFromBody(body) { + return new Request( + "https://example.com", + { + method: "POST", + body, + duplex: "half", + }, + ); +} + +test(() => { + const request = requestFromBody(undefined); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with empty body"); + +test(() => { + const blob = new Blob([]); + const request = requestFromBody(blob); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const request = requestFromBody(blob); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const request = requestFromBody(blob); + assert_equals(request.headers.get("Content-Type"), "a/b; c=d"); +}, "Default Content-Type for Request with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const request = requestFromBody(buffer); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with buffer source body"); + +promise_test(async () => { + const formData = new FormData(); + formData.append("a", "b"); + const request = requestFromBody(formData); + const boundary = (await request.text()).split("\r\n")[0].slice(2); + assert_equals( + request.headers.get("Content-Type"), + `multipart/form-data; boundary=${boundary}`, + ); +}, "Default Content-Type for Request with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const request = requestFromBody(usp); + assert_equals( + request.headers.get("Content-Type"), + "application/x-www-form-urlencoded;charset=UTF-8", + ); +}, "Default Content-Type for Request with URLSearchParams body"); + +test(() => { + const request = requestFromBody(""); + assert_equals( + request.headers.get("Content-Type"), + "text/plain;charset=UTF-8", + ); +}, "Default Content-Type for Request with string body"); + +test(() => { + const stream = new ReadableStream(); + const request = requestFromBody(stream); + assert_equals(request.headers.get("Content-Type"), null); +}, "Default Content-Type for Request with ReadableStream body"); + +// ----------------------------------------------------------------------------- + +const OVERRIDE_MIME = "test/only; mime=type"; + +function requestFromBodyWithOverrideMime(body) { + return new Request( + "https://example.com", + { + method: "POST", + body, + headers: { "Content-Type": OVERRIDE_MIME }, + duplex: "half", + }, + ); +} + +test(() => { + const request = requestFromBodyWithOverrideMime(undefined); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with empty body"); + +test(() => { + const blob = new Blob([]); + const request = requestFromBodyWithOverrideMime(blob); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const request = requestFromBodyWithOverrideMime(blob); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const request = requestFromBodyWithOverrideMime(blob); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const request = requestFromBodyWithOverrideMime(buffer); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with buffer source body"); + +test(() => { + const formData = new FormData(); + const request = requestFromBodyWithOverrideMime(formData); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const request = requestFromBodyWithOverrideMime(usp); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with URLSearchParams body"); + +test(() => { + const request = requestFromBodyWithOverrideMime(""); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with string body"); + +test(() => { + const stream = new ReadableStream(); + const request = requestFromBodyWithOverrideMime(stream); + assert_equals(request.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Request with ReadableStream body"); diff --git a/test/fixtures/wpt/fetch/api/request/request-init-priority.any.js b/test/fixtures/wpt/fetch/api/request/request-init-priority.any.js new file mode 100644 index 0000000..eb5073c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-init-priority.any.js @@ -0,0 +1,26 @@ +var priorities = ["high", + "low", + "auto" + ]; + +for (idx in priorities) { + test(() => { + new Request("", {priority: priorities[idx]}); + }, "new Request() with a '" + priorities[idx] + "' priority does not throw an error"); +} + +test(() => { + assert_throws_js(TypeError, () => { + new Request("", {priority: 'invalid'}); + }, "a new Request() must throw a TypeError if RequestInit's priority is an invalid value"); +}, "new Request() throws a TypeError if any of RequestInit's members' values are invalid"); + +for (idx in priorities) { + promise_test(function(t) { + return fetch('hello.txt', { priority: priorities[idx] }); + }, "fetch() with a '" + priorities[idx] + "' priority completes successfully"); +} + +promise_test(function(t) { + return promise_rejects_js(t, TypeError, fetch('hello.txt', { priority: 'invalid' })); +}, "fetch() with an invalid priority returns a rejected promise with a TypeError"); diff --git a/test/fixtures/wpt/fetch/api/request/request-init-stream.any.js b/test/fixtures/wpt/fetch/api/request/request-init-stream.any.js new file mode 100644 index 0000000..f0ae441 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-init-stream.any.js @@ -0,0 +1,147 @@ +// META: global=window,worker + +"use strict"; + +const duplex = "half"; +const method = "POST"; + +test(() => { + const body = new ReadableStream(); + const request = new Request("...", { method, body, duplex }); + assert_equals(request.body, body); +}, "Constructing a Request with a stream holds the original object."); + +test((t) => { + const body = new ReadableStream(); + body.getReader(); + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "Constructing a Request with a stream on which getReader() is called"); + +test((t) => { + const body = new ReadableStream(); + body.getReader().read(); + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "Constructing a Request with a stream on which read() is called"); + +promise_test(async (t) => { + const body = new ReadableStream({ pull: c => c.enqueue(new Uint8Array()) }); + const reader = body.getReader(); + await reader.read(); + reader.releaseLock(); + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "Constructing a Request with a stream on which read() and releaseLock() are called"); + +test((t) => { + const request = new Request("...", { method: "POST", body: "..." }); + request.body.getReader(); + assert_throws_js(TypeError, () => new Request(request)); + // This doesn't throw. + new Request(request, { body: "..." }); +}, "Constructing a Request with a Request on which body.getReader() is called"); + +test((t) => { + const request = new Request("...", { method: "POST", body: "..." }); + request.body.getReader().read(); + assert_throws_js(TypeError, () => new Request(request)); + // This doesn't throw. + new Request(request, { body: "..." }); +}, "Constructing a Request with a Request on which body.getReader().read() is called"); + +promise_test(async (t) => { + const request = new Request("...", { method: "POST", body: "..." }); + const reader = request.body.getReader(); + await reader.read(); + reader.releaseLock(); + assert_throws_js(TypeError, () => new Request(request)); + // This doesn't throw. + new Request(request, { body: "..." }); +}, "Constructing a Request with a Request on which read() and releaseLock() are called"); + +test((t) => { + new Request("...", { method, body: null }); +}, "It is OK to omit .duplex when the body is null."); + +test((t) => { + new Request("...", { method, body: "..." }); +}, "It is OK to omit .duplex when the body is a string."); + +test((t) => { + new Request("...", { method, body: new Uint8Array(3) }); +}, "It is OK to omit .duplex when the body is a Uint8Array."); + +test((t) => { + new Request("...", { method, body: new Blob([]) }); +}, "It is OK to omit .duplex when the body is a Blob."); + +test((t) => { + const body = new ReadableStream(); + assert_throws_js(TypeError, + () => new Request("...", { method, body })); +}, "It is error to omit .duplex when the body is a ReadableStream."); + +test((t) => { + new Request("...", { method, body: null, duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is null."); + +test((t) => { + new Request("...", { method, body: "...", duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a string."); + +test((t) => { + new Request("...", { method, body: new Uint8Array(3), duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a Uint8Array."); + +test((t) => { + new Request("...", { method, body: new Blob([]), duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a Blob."); + +test((t) => { + const body = new ReadableStream(); + new Request("...", { method, body, duplex: "half" }); +}, "It is OK to set .duplex = 'half' when the body is a ReadableStream."); + +test((t) => { + const body = null; + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is null."); + +test((t) => { + const body = "..."; + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a string."); + +test((t) => { + const body = new Uint8Array(3); + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a Uint8Array."); + +test((t) => { + const body = new Blob([]); + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a Blob."); + +test((t) => { + const body = new ReadableStream(); + const duplex = "full"; + assert_throws_js(TypeError, + () => new Request("...", { method, body, duplex })); +}, "It is error to set .duplex = 'full' when the body is a ReadableStream."); + +test((t) => { + const body = new ReadableStream(); + const duplex = "half"; + const req1 = new Request("...", { method, body, duplex }); + const req2 = new Request(req1); +}, "It is OK to omit duplex when init.body is not given and input.body is given."); + diff --git a/test/fixtures/wpt/fetch/api/request/request-keepalive-quota.html b/test/fixtures/wpt/fetch/api/request/request-keepalive-quota.html new file mode 100644 index 0000000..548ab38 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-keepalive-quota.html @@ -0,0 +1,97 @@ + + + + + Request Keepalive Quota Tests + + + + + + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/request-keepalive.any.js b/test/fixtures/wpt/fetch/api/request/request-keepalive.any.js new file mode 100644 index 0000000..cb4506d --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-keepalive.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker +// META: title=Request keepalive +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js + +test(() => { + assert_false(new Request('/').keepalive, 'default'); + assert_true(new Request('/', {keepalive: true}).keepalive, 'true'); + assert_false(new Request('/', {keepalive: false}).keepalive, 'false'); + assert_true(new Request('/', {keepalive: 1}).keepalive, 'truish'); + assert_false(new Request('/', {keepalive: 0}).keepalive, 'falsy'); +}, 'keepalive flag'); + +test(() => { + const init = {method: 'POST', keepalive: true, body: new ReadableStream()}; + assert_throws_js(TypeError, () => {new Request('/', init)}); +}, 'keepalive flag with stream body'); diff --git a/test/fixtures/wpt/fetch/api/request/request-reset-attributes.https.html b/test/fixtures/wpt/fetch/api/request/request-reset-attributes.https.html new file mode 100644 index 0000000..7be3608 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-reset-attributes.https.html @@ -0,0 +1,96 @@ + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/request/request-structure.any.js b/test/fixtures/wpt/fetch/api/request/request-structure.any.js new file mode 100644 index 0000000..5e78553 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/request-structure.any.js @@ -0,0 +1,143 @@ +// META: global=window,worker +// META: title=Request structure + +var request = new Request(""); +var methods = ["clone", + //Request implements Body + "arrayBuffer", + "blob", + "formData", + "json", + "text" + ]; +var attributes = ["method", + "url", + "headers", + "destination", + "referrer", + "referrerPolicy", + "mode", + "credentials", + "cache", + "redirect", + "integrity", + "isReloadNavigation", + "isHistoryNavigation", + "duplex", + //Request implements Body + "bodyUsed" + ]; +var internalAttributes = ["priority", + "internalpriority", + "blocking" + ]; + +function isReadOnly(request, attributeToCheck) { + var defaultValue = undefined; + var newValue = undefined; + switch (attributeToCheck) { + case "method": + defaultValue = "GET"; + newValue = "POST"; + break; + + case "url": + //default value is base url + //i.e http://example.com/fetch/api/request-structure.html + newValue = "http://url.test"; + break; + + case "headers": + request.headers = new Headers ({"name":"value"}); + assert_false(request.headers.has("name"), "Headers attribute is read only"); + return; + + case "destination": + defaultValue = ""; + newValue = "worker"; + break; + + case "referrer": + defaultValue = "about:client"; + newValue = "http://url.test"; + break; + + case "referrerPolicy": + defaultValue = ""; + newValue = "unsafe-url"; + break; + + case "mode": + defaultValue = "cors"; + newValue = "navigate"; + break; + + case "credentials": + defaultValue = "same-origin"; + newValue = "cors"; + break; + + case "cache": + defaultValue = "default"; + newValue = "reload"; + break; + + case "redirect": + defaultValue = "follow"; + newValue = "manual"; + break; + + case "integrity": + newValue = "CannotWriteIntegrity"; + break; + + case "bodyUsed": + defaultValue = false; + newValue = true; + break; + + case "isReloadNavigation": + defaultValue = false; + newValue = true; + break; + + case "isHistoryNavigation": + defaultValue = false; + newValue = true; + break; + + case "duplex": + defaultValue = "half"; + newValue = "full"; + break; + + default: + return; + } + + request[attributeToCheck] = newValue; + if (defaultValue === undefined) + assert_not_equals(request[attributeToCheck], newValue, "Attribute " + attributeToCheck + " is read only"); + else + assert_equals(request[attributeToCheck], defaultValue, + "Attribute " + attributeToCheck + " is read only. Default value is " + defaultValue); +} + +for (var idx in methods) { + test(function() { + assert_true(methods[idx] in request, "request has " + methods[idx] + " method"); + }, "Request has " + methods[idx] + " method"); +} + +for (var idx in attributes) { + test(function() { + assert_true(attributes[idx] in request, "request has " + attributes[idx] + " attribute"); + isReadOnly(request, attributes[idx]); + }, "Check " + attributes[idx] + " attribute"); +} + +for (var idx in internalAttributes) { + test(function() { + assert_false(internalAttributes[idx] in request, "request does not expose " + internalAttributes[idx] + " attribute"); + }, "Request does not expose " + internalAttributes[idx] + " attribute"); +} \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/request/resources/cache.py b/test/fixtures/wpt/fetch/api/request/resources/cache.py new file mode 100644 index 0000000..ca0bd64 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/resources/cache.py @@ -0,0 +1,67 @@ +from wptserve.utils import isomorphic_decode + +def main(request, response): + token = request.GET.first(b"token", None) + if b"querystate" in request.GET: + from json import JSONEncoder + response.headers.set(b"Content-Type", b"text/plain") + return JSONEncoder().encode(request.server.stash.take(token)) + content = request.GET.first(b"content", None) + tag = request.GET.first(b"tag", None) + date = request.GET.first(b"date", None) + expires = request.GET.first(b"expires", None) + vary = request.GET.first(b"vary", None) + cc = request.GET.first(b"cache_control", None) + redirect = request.GET.first(b"redirect", None) + inm = request.headers.get(b"If-None-Match", None) + ims = request.headers.get(b"If-Modified-Since", None) + pragma = request.headers.get(b"Pragma", None) + cache_control = request.headers.get(b"Cache-Control", None) + ignore = b"ignore" in request.GET + + if tag: + tag = b'"%s"' % tag + + server_state = request.server.stash.take(token) + if not server_state: + server_state = [] + state = dict() + if not ignore: + if inm: + state[u"If-None-Match"] = isomorphic_decode(inm) + if ims: + state[u"If-Modified-Since"] = isomorphic_decode(ims) + if pragma: + state[u"Pragma"] = isomorphic_decode(pragma) + if cache_control: + state[u"Cache-Control"] = isomorphic_decode(cache_control) + server_state.append(state) + request.server.stash.put(token, server_state) + + if tag: + response.headers.set(b"ETag", b'%s' % tag) + elif date: + response.headers.set(b"Last-Modified", date) + if expires: + response.headers.set(b"Expires", expires) + if vary: + response.headers.set(b"Vary", vary) + if cc: + response.headers.set(b"Cache-Control", cc) + + # The only-if-cached redirect tests wants CORS to be okay, the other tests + # are all same-origin anyways and don't care. + response.headers.set(b"Access-Control-Allow-Origin", b"*") + + if redirect: + response.headers.set(b"Location", redirect) + response.status = (302, b"Redirect") + return b"" + elif ((inm is not None and inm == tag) or + (ims is not None and ims == date)): + response.status = (304, b"Not Modified") + return b"" + else: + response.status = (200, b"OK") + response.headers.set(b"Content-Type", b"text/plain") + return content diff --git a/test/fixtures/wpt/fetch/api/request/resources/hello.txt b/test/fixtures/wpt/fetch/api/request/resources/hello.txt new file mode 100644 index 0000000..ce01362 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/resources/hello.txt @@ -0,0 +1 @@ +hello diff --git a/test/fixtures/wpt/fetch/api/request/resources/request-reset-attributes-worker.js b/test/fixtures/wpt/fetch/api/request/resources/request-reset-attributes-worker.js new file mode 100644 index 0000000..4b264ca --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/resources/request-reset-attributes-worker.js @@ -0,0 +1,19 @@ +self.addEventListener('fetch', (event) => { + const params = new URL(event.request.url).searchParams; + if (params.has('ignore')) { + return; + } + if (!params.has('name')) { + event.respondWith(Promise.reject(TypeError('No name is provided.'))); + return; + } + + const name = params.get('name'); + const old_attribute = event.request[name]; + // If any of |init|'s member is present... + const init = {cache: 'no-store'} + const new_attribute = (new Request(event.request, init))[name]; + + event.respondWith( + new Response(`old: ${old_attribute}, new: ${new_attribute}`)); + }); diff --git a/test/fixtures/wpt/fetch/api/request/url-encoding.html b/test/fixtures/wpt/fetch/api/request/url-encoding.html new file mode 100644 index 0000000..31c1ed3 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/request/url-encoding.html @@ -0,0 +1,25 @@ + + +Fetch: URL encoding + + + diff --git a/test/fixtures/wpt/fetch/api/resources/authentication.py b/test/fixtures/wpt/fetch/api/resources/authentication.py new file mode 100644 index 0000000..8b6b00b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/authentication.py @@ -0,0 +1,14 @@ +def main(request, response): + user = request.auth.username + password = request.auth.password + + if user == b"user" and password == b"password": + return b"Authentication done" + + realm = b"test" + if b"realm" in request.GET: + realm = request.GET.first(b"realm") + + return ((401, b"Unauthorized"), + [(b"WWW-Authenticate", b'Basic realm="' + realm + b'"')], + b"Please login with credentials 'user' and 'password'") diff --git a/test/fixtures/wpt/fetch/api/resources/bad-chunk-encoding.py b/test/fixtures/wpt/fetch/api/resources/bad-chunk-encoding.py new file mode 100644 index 0000000..94a77ad --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/bad-chunk-encoding.py @@ -0,0 +1,13 @@ +import time + +def main(request, response): + delay = float(request.GET.first(b"ms", 1000)) / 1E3 + count = int(request.GET.first(b"count", 50)) + time.sleep(delay) + response.headers.set(b"Transfer-Encoding", b"chunked") + response.write_status_headers() + time.sleep(delay) + for i in range(count): + response.writer.write_content(b"a\r\nTEST_CHUNK\r\n") + time.sleep(delay) + response.writer.write_content(b"garbage") diff --git a/test/fixtures/wpt/fetch/api/resources/basic.html b/test/fixtures/wpt/fetch/api/resources/basic.html new file mode 100644 index 0000000..e23afd4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/basic.html @@ -0,0 +1,5 @@ + + diff --git a/test/fixtures/wpt/fetch/api/resources/cache.py b/test/fixtures/wpt/fetch/api/resources/cache.py new file mode 100644 index 0000000..4de751e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/cache.py @@ -0,0 +1,18 @@ +ETAG = b'"123abc"' +CONTENT_TYPE = b"text/plain" +CONTENT = b"lorem ipsum dolor sit amet" + + +def main(request, response): + # let caching kick in if possible (conditional GET) + etag = request.headers.get(b"If-None-Match", None) + if etag == ETAG: + response.headers.set(b"X-HTTP-STATUS", 304) + response.status = (304, b"Not Modified") + return b"" + + # cache miss, so respond with the actual content + response.status = (200, b"OK") + response.headers.set(b"ETag", ETAG) + response.headers.set(b"Content-Type", CONTENT_TYPE) + return CONTENT diff --git a/test/fixtures/wpt/fetch/api/resources/clean-stash.py b/test/fixtures/wpt/fetch/api/resources/clean-stash.py new file mode 100644 index 0000000..ee8c69a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/clean-stash.py @@ -0,0 +1,6 @@ +def main(request, response): + token = request.GET.first(b"token") + if request.server.stash.take(token) is not None: + return b"1" + else: + return b"0" diff --git a/test/fixtures/wpt/fetch/api/resources/cors-top.txt b/test/fixtures/wpt/fetch/api/resources/cors-top.txt new file mode 100644 index 0000000..83a3157 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/cors-top.txt @@ -0,0 +1 @@ +top \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/resources/cors-top.txt.headers b/test/fixtures/wpt/fetch/api/resources/cors-top.txt.headers new file mode 100644 index 0000000..cb762ef --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/cors-top.txt.headers @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/test/fixtures/wpt/fetch/api/resources/data.json b/test/fixtures/wpt/fetch/api/resources/data.json new file mode 100644 index 0000000..76519fa --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/data.json @@ -0,0 +1 @@ +{"key": "value"} diff --git a/test/fixtures/wpt/fetch/api/resources/dump-authorization-header.py b/test/fixtures/wpt/fetch/api/resources/dump-authorization-header.py new file mode 100644 index 0000000..0d82809 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/dump-authorization-header.py @@ -0,0 +1,19 @@ +def main(request, response): + headers = [(b"Content-Type", "text/html"), + (b"Cache-Control", b"no-cache")] + + if (request.GET.first(b"strip_auth_header", False) and request.method == "OPTIONS" and + b"authorization" in request.headers.get(b"Access-Control-Request-Headers", b"").lower()): + # Auth header should not be sent for preflight after cross-origin redirect. + return 500, headers, "fail" + + if b"Origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers.get(b"Origin", b""))) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + headers.append((b"Access-Control-Allow-Headers", b'Authorization')) + + if b"authorization" in request.headers: + return 200, headers, request.headers.get(b"Authorization") + return 200, headers, "none" diff --git a/test/fixtures/wpt/fetch/api/resources/echo-content.h2.py b/test/fixtures/wpt/fetch/api/resources/echo-content.h2.py new file mode 100644 index 0000000..0be3ece --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/echo-content.h2.py @@ -0,0 +1,7 @@ +def handle_headers(frame, request, response): + response.status = 200 + response.headers.update([('Content-Type', 'text/plain')]) + response.write_status_headers() + +def handle_data(frame, request, response): + response.writer.write_data(frame.data) diff --git a/test/fixtures/wpt/fetch/api/resources/echo-content.py b/test/fixtures/wpt/fetch/api/resources/echo-content.py new file mode 100644 index 0000000..5e137e1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/echo-content.py @@ -0,0 +1,12 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + + headers = [(b"X-Request-Method", isomorphic_encode(request.method)), + (b"X-Request-Content-Length", request.headers.get(b"Content-Length", b"NO")), + (b"X-Request-Content-Type", request.headers.get(b"Content-Type", b"NO")), + # Avoid any kind of content sniffing on the response. + (b"Content-Type", b"text/plain")] + content = request.body + + return headers, content diff --git a/test/fixtures/wpt/fetch/api/resources/empty.txt b/test/fixtures/wpt/fetch/api/resources/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/api/resources/huge-response.py b/test/fixtures/wpt/fetch/api/resources/huge-response.py new file mode 100644 index 0000000..16a6007 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/huge-response.py @@ -0,0 +1,22 @@ +# A Python script that generates a huge response. Implemented as a script to +# avoid needing to add a huge file to the repository. + +TOTAL_SIZE = 8 * 1024 * 1024 * 1024 # 8 GB +CHUNK_SIZE = 1024 * 1024 # 1 MB + +assert TOTAL_SIZE % CHUNK_SIZE == 0 + + +def main(request, response): + response.headers.set(b"Content-type", b"text/plain") + response.headers.set(b"Content-Length", str(TOTAL_SIZE).encode()) + response.headers.set(b"Cache-Control", b"max-age=86400") + response.write_status_headers() + + chunk = bytes(CHUNK_SIZE) + total_sent = 0 + + while total_sent < TOTAL_SIZE: + if not response.writer.write(chunk): + break + total_sent += CHUNK_SIZE diff --git a/test/fixtures/wpt/fetch/api/resources/infinite-slow-response.py b/test/fixtures/wpt/fetch/api/resources/infinite-slow-response.py new file mode 100644 index 0000000..a26cd80 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/infinite-slow-response.py @@ -0,0 +1,35 @@ +import time + + +def url_dir(request): + return u'/'.join(request.url_parts.path.split(u'/')[:-1]) + u'/' + + +def stash_write(request, key, value): + """Write to the stash, overwriting any previous value""" + request.server.stash.take(key, url_dir(request)) + request.server.stash.put(key, value, url_dir(request)) + + +def main(request, response): + stateKey = request.GET.first(b"stateKey", b"") + abortKey = request.GET.first(b"abortKey", b"") + + if stateKey: + stash_write(request, stateKey, 'open') + + response.headers.set(b"Content-type", b"text/plain") + response.write_status_headers() + + # Writing an initial 2k so browsers realise it's there. *shrug* + response.writer.write(b"." * 2048) + + while True: + if not response.writer.write(b"."): + break + if abortKey and request.server.stash.take(abortKey, url_dir(request)): + break + time.sleep(0.01) + + if stateKey: + stash_write(request, stateKey, 'closed') diff --git a/test/fixtures/wpt/fetch/api/resources/inspect-headers.py b/test/fixtures/wpt/fetch/api/resources/inspect-headers.py new file mode 100644 index 0000000..9ed566e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/inspect-headers.py @@ -0,0 +1,24 @@ +def main(request, response): + headers = [] + if b"headers" in request.GET: + checked_headers = request.GET.first(b"headers").split(b"|") + for header in checked_headers: + if header in request.headers: + headers.append((b"x-request-" + header, request.headers.get(header, b""))) + + if b"cors" in request.GET: + if b"Origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers.get(b"Origin", b""))) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + headers.append((b"Access-Control-Allow-Methods", b"GET, POST, HEAD")) + exposed_headers = [b"x-request-" + header for header in checked_headers] + headers.append((b"Access-Control-Expose-Headers", b", ".join(exposed_headers))) + if b"allow_headers" in request.GET: + headers.append((b"Access-Control-Allow-Headers", request.GET[b'allow_headers'])) + else: + headers.append((b"Access-Control-Allow-Headers", b", ".join(request.headers))) + + headers.append((b"content-type", b"text/plain")) + return headers, b"" diff --git a/test/fixtures/wpt/fetch/api/resources/keepalive-helper.js b/test/fixtures/wpt/fetch/api/resources/keepalive-helper.js new file mode 100644 index 0000000..1e75c06 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/keepalive-helper.js @@ -0,0 +1,199 @@ +// Utility functions to help testing keepalive requests. + +// Returns a URL to an iframe that loads a keepalive URL on iframe loaded. +// +// The keepalive URL points to a target that stores `token`. The token will then +// be posted back on iframe loaded to the parent document. +// `method` defaults to GET. +// `frameOrigin` to specify the origin of the iframe to load. If not set, +// default to a different site origin. +// `requestOrigin` to specify the origin of the fetch request target. +// `sendOn` to specify the name of the event when the keepalive request should +// be sent instead of the default 'load'. +// `mode` to specify the fetch request's CORS mode. +// `disallowCrossOrigin` to ask the iframe to set up a server that disallows +// cross origin requests. +function getKeepAliveIframeUrl(token, method, { + frameOrigin = 'DEFAULT', + requestOrigin = '', + sendOn = 'load', + mode = 'cors', + disallowCrossOrigin = false +} = {}) { + const https = location.protocol.startsWith('https'); + frameOrigin = frameOrigin === 'DEFAULT' ? + get_host_info()[https ? 'HTTPS_NOTSAMESITE_ORIGIN' : 'HTTP_NOTSAMESITE_ORIGIN'] : + frameOrigin; + return `${frameOrigin}/fetch/api/resources/keepalive-iframe.html?` + + `token=${token}&` + + `method=${method}&` + + `sendOn=${sendOn}&` + + `mode=${mode}&` + (disallowCrossOrigin ? `disallowCrossOrigin=1&` : ``) + + `origin=${requestOrigin}`; +} + +// Returns a different-site URL to an iframe that loads a keepalive URL. +// +// By default, the keepalive URL points to a target that redirects to another +// same-origin destination storing `token`. The token will then be posted back +// to parent document. +// +// The URL redirects can be customized from `origin1` to `origin2` if provided. +// Sets `withPreflight` to true to get URL enabling preflight. +function getKeepAliveAndRedirectIframeUrl( + token, origin1, origin2, withPreflight) { + const https = location.protocol.startsWith('https'); + const frameOrigin = + get_host_info()[https ? 'HTTPS_NOTSAMESITE_ORIGIN' : 'HTTP_NOTSAMESITE_ORIGIN']; + return `${frameOrigin}/fetch/api/resources/keepalive-redirect-iframe.html?` + + `token=${token}&` + + `origin1=${origin1}&` + + `origin2=${origin2}&` + (withPreflight ? `with-headers` : ``); +} + +async function iframeLoaded(iframe) { + return new Promise((resolve) => iframe.addEventListener('load', resolve)); +} + +// Obtains the token from the message posted by iframe after loading +// `getKeepAliveAndRedirectIframeUrl()`. +async function getTokenFromMessage() { + return new Promise((resolve) => { + window.addEventListener('message', (event) => { + resolve(event.data); + }, {once: true}); + }); +} + +// Tells if `token` has been stored in the server. +async function queryToken(token) { + const response = await fetch(`../resources/stash-take.py?key=${token}`); + const json = await response.json(); + return json; +} + +// A helper to assert the existence of `token` that should have been stored in +// the server by fetching ../resources/stash-put.py. +// +// This function simply wait for a custom amount of time before trying to +// retrieve `token` from the server. +// `expectTokenExist` tells if `token` should be present or not. +// +// NOTE: +// In order to parallelize the work, we are going to have an async_test +// for the rest of the work. Note that we want the serialized behavior +// for the steps so far, so we don't want to make the entire test case +// an async_test. +function assertStashedTokenAsync( + testName, token, {expectTokenExist = true} = {}) { + async_test(test => { + new Promise(resolve => test.step_timeout(resolve, 3000 /*ms*/)) + .then(test.step_func(() => { + return queryToken(token); + })) + .then(test.step_func(result => { + if (expectTokenExist) { + assert_equals(result, 'on', `token should be on (stashed).`); + test.done(); + } else { + assert_not_equals( + result, 'on', `token should not be on (stashed).`); + return Promise.reject(`Failed to retrieve token from server`); + } + })) + .catch(test.step_func(e => { + if (expectTokenExist) { + test.unreached_func(e); + } else { + test.done(); + } + })); + }, testName); +} + +/** + * In an iframe, and in `load` event handler, test to fetch a keepalive URL that + * involves in redirect to another URL. + * + * `unloadIframe` to unload the iframe before verifying stashed token to + * simulate the situation that unloads after fetching. Note that this test is + * different from `keepaliveRedirectInUnloadTest()` in that the the latter + * performs fetch() call directly in `unload` event handler, while this test + * does it in `load`. + */ +function keepaliveRedirectTest(desc, { + origin1 = '', + origin2 = '', + withPreflight = false, + unloadIframe = false, + expectFetchSucceed = true, +} = {}) { + desc = `[keepalive][iframe][load] ${desc}` + + (unloadIframe ? ' [unload at end]' : ''); + promise_test(async (test) => { + const tokenToStash = token(); + const iframe = document.createElement('iframe'); + iframe.src = getKeepAliveAndRedirectIframeUrl( + tokenToStash, origin1, origin2, withPreflight); + document.body.appendChild(iframe); + await iframeLoaded(iframe); + assert_equals(await getTokenFromMessage(), tokenToStash); + if (unloadIframe) { + iframe.remove(); + } + + assertStashedTokenAsync( + desc, tokenToStash, {expectTokenExist: expectFetchSucceed}); + }, `${desc}; setting up`); +} + +/** + * Opens a different site window, and in `unload` event handler, test to fetch + * a keepalive URL that involves in redirect to another URL. + */ +function keepaliveRedirectInUnloadTest(desc, { + origin1 = '', + origin2 = '', + url2 = '', + withPreflight = false, + expectFetchSucceed = true +} = {}) { + desc = `[keepalive][new window][unload] ${desc}`; + + promise_test(async (test) => { + const targetUrl = + `${HTTP_NOTSAMESITE_ORIGIN}/fetch/api/resources/keepalive-redirect-window.html?` + + `origin1=${origin1}&` + + `origin2=${origin2}&` + + `url2=${url2}&` + (withPreflight ? `with-headers` : ``); + const w = window.open(targetUrl); + const token = await getTokenFromMessage(); + w.close(); + + assertStashedTokenAsync( + desc, token, {expectTokenExist: expectFetchSucceed}); + }, `${desc}; setting up`); +} + +/** +* utility to create pending keepalive fetch requests +* The pending request state is achieved by ensuring the server (trickle.py) does not +* immediately respond to the fetch requests. +* The response delay is set as a url parameter. +*/ + +function createPendingKeepAliveRequest(delay, remote = false) { + // trickle.py is a script that can make a delayed response to the client request + const trickleRemoteURL = get_host_info().HTTPS_REMOTE_ORIGIN + '/fetch/api/resources/trickle.py?count=1&ms='; + const trickleLocalURL = get_host_info().HTTP_ORIGIN + '/fetch/api/resources/trickle.py?count=1&ms='; + url = remote ? trickleRemoteURL : trickleLocalURL; + + const body = '*'.repeat(10); + return fetch(url + delay, { keepalive: true, body, method: 'POST' }).then(res => { + return res.text(); + }).then(() => { + return new Promise(resolve => step_timeout(resolve, 1)); + }).catch((error) => { + return Promise.reject(error);; + }) +} diff --git a/test/fixtures/wpt/fetch/api/resources/keepalive-iframe.html b/test/fixtures/wpt/fetch/api/resources/keepalive-iframe.html new file mode 100644 index 0000000..f9dae5a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/keepalive-iframe.html @@ -0,0 +1,22 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/api/resources/keepalive-redirect-iframe.html b/test/fixtures/wpt/fetch/api/resources/keepalive-redirect-iframe.html new file mode 100644 index 0000000..fdee00f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/keepalive-redirect-iframe.html @@ -0,0 +1,23 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/api/resources/keepalive-redirect-window.html b/test/fixtures/wpt/fetch/api/resources/keepalive-redirect-window.html new file mode 100644 index 0000000..c186507 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/keepalive-redirect-window.html @@ -0,0 +1,42 @@ + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/resources/keepalive-worker.js b/test/fixtures/wpt/fetch/api/resources/keepalive-worker.js new file mode 100644 index 0000000..0808601 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/keepalive-worker.js @@ -0,0 +1,15 @@ +/** +* Script that sends keepalive +* fetch request and terminates immediately. +* The request URL is passed as a parameter to this worker +*/ +function sendFetchRequest() { + // Parse the query parameter from the worker's script URL + const urlString = self.location.search.replace("?param=", ""); + postMessage('started'); + fetch(`${urlString}`, { keepalive: true }); +} + +sendFetchRequest(); +// Terminate the worker +self.close(); diff --git a/test/fixtures/wpt/fetch/api/resources/method.py b/test/fixtures/wpt/fetch/api/resources/method.py new file mode 100644 index 0000000..c1a111b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/method.py @@ -0,0 +1,18 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + headers = [] + if b"cors" in request.GET: + headers.append((b"Access-Control-Allow-Origin", b"*")) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + headers.append((b"Access-Control-Allow-Methods", b"GET, POST, PUT, FOO")) + headers.append((b"Access-Control-Allow-Headers", b"x-test, x-foo")) + headers.append((b"Access-Control-Expose-Headers", b"x-request-method")) + + headers.append((b"x-request-method", isomorphic_encode(request.method))) + headers.append((b"x-request-content-type", request.headers.get(b"Content-Type", b"NO"))) + headers.append((b"x-request-content-length", request.headers.get(b"Content-Length", b"NO"))) + headers.append((b"x-request-content-encoding", request.headers.get(b"Content-Encoding", b"NO"))) + headers.append((b"x-request-content-language", request.headers.get(b"Content-Language", b"NO"))) + headers.append((b"x-request-content-location", request.headers.get(b"Content-Location", b"NO"))) + return headers, request.body diff --git a/test/fixtures/wpt/fetch/api/resources/preflight.py b/test/fixtures/wpt/fetch/api/resources/preflight.py new file mode 100644 index 0000000..f983ef9 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/preflight.py @@ -0,0 +1,78 @@ +def main(request, response): + headers = [(b"Content-Type", b"text/plain")] + stashed_data = {b'control_request_headers': b"", b'preflight': b"0", b'preflight_referrer': b""} + + token = None + if b"token" in request.GET: + token = request.GET.first(b"token") + + if b"origin" in request.GET: + for origin in request.GET[b'origin'].split(b", "): + headers.append((b"Access-Control-Allow-Origin", origin)) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + + if b"clear-stash" in request.GET: + if request.server.stash.take(token) is not None: + return headers, b"1" + else: + return headers, b"0" + + if b"credentials" in request.GET: + headers.append((b"Access-Control-Allow-Credentials", b"true")) + + if request.method == u"OPTIONS": + if not b"Access-Control-Request-Method" in request.headers: + response.set_error(400, u"No Access-Control-Request-Method header") + return b"ERROR: No access-control-request-method in preflight!" + + if request.headers.get(b"Accept", b"") != b"*/*": + response.set_error(400, u"Request does not have 'Accept: */*' header") + return b"ERROR: Invalid access in preflight!" + + if b"control_request_headers" in request.GET: + stashed_data[b'control_request_headers'] = request.headers.get(b"Access-Control-Request-Headers", None) + + if b"max_age" in request.GET: + headers.append((b"Access-Control-Max-Age", request.GET[b'max_age'])) + + if b"allow_headers" in request.GET: + headers.append((b"Access-Control-Allow-Headers", request.GET[b'allow_headers'])) + + if b"allow_methods" in request.GET: + headers.append((b"Access-Control-Allow-Methods", request.GET[b'allow_methods'])) + + preflight_status = 200 + if b"preflight_status" in request.GET: + preflight_status = int(request.GET.first(b"preflight_status")) + + stashed_data[b'preflight'] = b"1" + stashed_data[b'preflight_referrer'] = request.headers.get(b"Referer", b"") + stashed_data[b'preflight_user_agent'] = request.headers.get(b"User-Agent", b"") + if token: + request.server.stash.put(token, stashed_data) + + return preflight_status, headers, b"" + + + if token: + data = request.server.stash.take(token) + if data: + stashed_data = data + + if b"checkUserAgentHeaderInPreflight" in request.GET and request.headers.get(b"User-Agent") != stashed_data[b'preflight_user_agent']: + return 400, headers, b"ERROR: No user-agent header in preflight" + + #use x-* headers for returning value to bodyless responses + headers.append((b"Access-Control-Expose-Headers", b"x-did-preflight, x-control-request-headers, x-referrer, x-preflight-referrer, x-origin")) + headers.append((b"x-did-preflight", stashed_data[b'preflight'])) + if stashed_data[b'control_request_headers'] != None: + headers.append((b"x-control-request-headers", stashed_data[b'control_request_headers'])) + headers.append((b"x-preflight-referrer", stashed_data[b'preflight_referrer'])) + headers.append((b"x-referrer", request.headers.get(b"Referer", b""))) + headers.append((b"x-origin", request.headers.get(b"Origin", b""))) + + if token: + request.server.stash.put(token, stashed_data) + + return headers, b"" diff --git a/test/fixtures/wpt/fetch/api/resources/redirect-empty-location.py b/test/fixtures/wpt/fetch/api/resources/redirect-empty-location.py new file mode 100644 index 0000000..1a5f7fe --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/redirect-empty-location.py @@ -0,0 +1,3 @@ +def main(request, response): + headers = [(b"Location", b"")] + return 302, headers, b"" diff --git a/test/fixtures/wpt/fetch/api/resources/redirect.h2.py b/test/fixtures/wpt/fetch/api/resources/redirect.h2.py new file mode 100644 index 0000000..6937014 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/redirect.h2.py @@ -0,0 +1,14 @@ +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def handle_headers(frame, request, response): + status = 302 + if b'redirect_status' in request.GET: + status = int(request.GET[b'redirect_status']) + response.status = status + + if b'location' in request.GET: + url = isomorphic_decode(request.GET[b'location']) + response.headers[b'Location'] = isomorphic_encode(url) + + response.headers.update([('Content-Type', 'text/plain')]) + response.write_status_headers() diff --git a/test/fixtures/wpt/fetch/api/resources/redirect.py b/test/fixtures/wpt/fetch/api/resources/redirect.py new file mode 100644 index 0000000..d52ab5f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/redirect.py @@ -0,0 +1,73 @@ +import time + +from urllib.parse import urlencode, urlparse + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def main(request, response): + stashed_data = {b'count': 0, b'preflight': b"0"} + status = 302 + headers = [(b"Content-Type", b"text/plain"), + (b"Cache-Control", b"no-cache"), + (b"Pragma", b"no-cache")] + if b"Origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers.get(b"Origin", b""))) + headers.append((b"Access-Control-Allow-Credentials", b"true")) + else: + headers.append((b"Access-Control-Allow-Origin", b"*")) + + token = None + if b"token" in request.GET: + token = request.GET.first(b"token") + data = request.server.stash.take(token) + if data: + stashed_data = data + + if request.method == u"OPTIONS": + if b"allow_headers" in request.GET: + headers.append((b"Access-Control-Allow-Headers", request.GET[b'allow_headers'])) + stashed_data[b'preflight'] = b"1" + #Preflight is not redirected: return 200 + if not b"redirect_preflight" in request.GET: + if token: + request.server.stash.put(request.GET.first(b"token"), stashed_data) + return 200, headers, u"" + + if b"redirect_status" in request.GET: + status = int(request.GET[b'redirect_status']) + elif b"redirect_status" in request.POST: + status = int(request.POST[b'redirect_status']) + + stashed_data[b'count'] += 1 + + if b"location" in request.GET: + url = isomorphic_decode(request.GET[b'location']) + if b"simple" not in request.GET: + scheme = urlparse(url).scheme + if scheme == u"" or scheme == u"http" or scheme == u"https": + url += u"&" if u'?' in url else u"?" + #keep url parameters in location + url_parameters = {} + for item in request.GET.items(): + url_parameters[isomorphic_decode(item[0])] = isomorphic_decode(item[1][0]) + url += urlencode(url_parameters) + #make sure location changes during redirection loop + url += u"&count=" + str(stashed_data[b'count']) + headers.append((b"Location", isomorphic_encode(url))) + + if b"redirect_referrerpolicy" in request.GET: + headers.append((b"Referrer-Policy", request.GET[b'redirect_referrerpolicy'])) + + if b"delay" in request.GET: + time.sleep(float(request.GET.first(b"delay", 0)) / 1E3) + + if token: + request.server.stash.put(request.GET.first(b"token"), stashed_data) + if b"max_count" in request.GET: + max_count = int(request.GET[b'max_count']) + #stop redirecting and return count + if stashed_data[b'count'] > max_count: + # -1 because the last is not a redirection + return str(stashed_data[b'count'] - 1) + + return status, headers, u"" diff --git a/test/fixtures/wpt/fetch/api/resources/sandboxed-iframe.html b/test/fixtures/wpt/fetch/api/resources/sandboxed-iframe.html new file mode 100644 index 0000000..6e5d506 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/sandboxed-iframe.html @@ -0,0 +1,34 @@ + + + + diff --git a/test/fixtures/wpt/fetch/api/resources/script-with-header.py b/test/fixtures/wpt/fetch/api/resources/script-with-header.py new file mode 100644 index 0000000..9a9c70e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/script-with-header.py @@ -0,0 +1,7 @@ +def main(request, response): + headers = [(b"Content-type", request.GET.first(b"mime"))] + if b"content" in request.GET and request.GET.first(b"content") == b"empty": + content = b'' + else: + content = b"console.log('Script loaded')" + return 200, headers, content diff --git a/test/fixtures/wpt/fetch/api/resources/stash-put.py b/test/fixtures/wpt/fetch/api/resources/stash-put.py new file mode 100644 index 0000000..91c198a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/stash-put.py @@ -0,0 +1,41 @@ +from wptserve.utils import isomorphic_decode + +def should_be_treated_as_same_origin_request(request): + """Tells whether request should be treated as same-origin request.""" + # In both of the following cases, allow to proceed with handling to simulate + # 'no-cors' mode: response is sent, but browser will make it opaque. + if request.GET.first(b'mode') == b'no-cors': + return True + + # We can't rely on the Origin header field of a fetch request, as it is only + # present for 'cors' mode or methods other than 'GET'/'HEAD' (i.e. present for + # 'POST'). See https://fetch.spec.whatwg.org/#http-origin + assert 'frame_origin ' in request.GET + frame_origin = request.GET.first(b'frame_origin').decode('utf-8') + host_origin = request.url_parts.scheme + '://' + request.url_parts.netloc + return frame_origin == host_origin + +def main(request, response): + if request.method == u'OPTIONS': + # CORS preflight + response.headers.set(b'Access-Control-Allow-Origin', b'*') + response.headers.set(b'Access-Control-Allow-Methods', b'*') + response.headers.set(b'Access-Control-Allow-Headers', b'*') + return 'done' + + if b'disallow_cross_origin' not in request.GET: + response.headers.set(b'Access-Control-Allow-Origin', b'*') + elif not should_be_treated_as_same_origin_request(request): + # As simple requests will not trigger preflight, we have to manually block + # cors requests before making any changes to storage. + # https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests + # https://fetch.spec.whatwg.org/#cors-preflight-fetch + return 'not stashing for cors request' + + url_dir = u'/'.join(request.url_parts.path.split(u'/')[:-1]) + u'/' + key = request.GET.first(b'key') + value = request.GET.first(b'value') + # value here must be a text string. It will be json.dump()'ed in stash-take.py. + request.server.stash.put(key, isomorphic_decode(value), url_dir) + + return 'done' diff --git a/test/fixtures/wpt/fetch/api/resources/stash-take.py b/test/fixtures/wpt/fetch/api/resources/stash-take.py new file mode 100644 index 0000000..e6db80d --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/stash-take.py @@ -0,0 +1,9 @@ +from wptserve.handlers import json_handler + + +@json_handler +def main(request, response): + dir = u'/'.join(request.url_parts.path.split(u'/')[:-1]) + u'/' + key = request.GET.first(b"key") + response.headers.set(b'Access-Control-Allow-Origin', b'*') + return request.server.stash.take(key, dir) diff --git a/test/fixtures/wpt/fetch/api/resources/status.py b/test/fixtures/wpt/fetch/api/resources/status.py new file mode 100644 index 0000000..05a59d5 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/status.py @@ -0,0 +1,11 @@ +from wptserve.utils import isomorphic_encode + +def main(request, response): + code = int(request.GET.first(b"code", 200)) + text = request.GET.first(b"text", b"OMG") + content = request.GET.first(b"content", b"") + type = request.GET.first(b"type", b"") + status = (code, text) + headers = [(b"Content-Type", type), + (b"X-Request-Method", isomorphic_encode(request.method))] + return status, headers, content diff --git a/test/fixtures/wpt/fetch/api/resources/sw-intercept-abort.js b/test/fixtures/wpt/fetch/api/resources/sw-intercept-abort.js new file mode 100644 index 0000000..19d4b18 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/sw-intercept-abort.js @@ -0,0 +1,19 @@ +async function messageClient(clientId, message) { + const client = await clients.get(clientId); + client.postMessage(message); +} + +addEventListener('fetch', event => { + let resolve; + const promise = new Promise(r => resolve = r); + + function onAborted() { + messageClient(event.clientId, event.request.signal.reason); + resolve(); + } + + messageClient(event.clientId, 'fetch event has arrived'); + + event.respondWith(promise.then(() => new Response('hello'))); + event.request.signal.addEventListener('abort', onAborted); +}); diff --git a/test/fixtures/wpt/fetch/api/resources/sw-intercept.js b/test/fixtures/wpt/fetch/api/resources/sw-intercept.js new file mode 100644 index 0000000..b8166b6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/sw-intercept.js @@ -0,0 +1,10 @@ +async function broadcast(msg) { + for (const client of await clients.matchAll()) { + client.postMessage(msg); + } +} + +addEventListener('fetch', event => { + event.waitUntil(broadcast(event.request.url)); + event.respondWith(fetch(event.request)); +}); diff --git a/test/fixtures/wpt/fetch/api/resources/top.txt b/test/fixtures/wpt/fetch/api/resources/top.txt new file mode 100644 index 0000000..83a3157 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/top.txt @@ -0,0 +1 @@ +top \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/api/resources/trickle.py b/test/fixtures/wpt/fetch/api/resources/trickle.py new file mode 100644 index 0000000..99833f1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/trickle.py @@ -0,0 +1,15 @@ +import time + +def main(request, response): + delay = float(request.GET.first(b"ms", 500)) / 1E3 + count = int(request.GET.first(b"count", 50)) + # Read request body + request.body + time.sleep(delay) + if not b"notype" in request.GET: + response.headers.set(b"Content-type", b"text/plain") + response.write_status_headers() + time.sleep(delay) + for i in range(count): + response.writer.write_content(b"TEST_TRICKLE\n") + time.sleep(delay) diff --git a/test/fixtures/wpt/fetch/api/resources/utils.js b/test/fixtures/wpt/fetch/api/resources/utils.js new file mode 100644 index 0000000..3721d9b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/resources/utils.js @@ -0,0 +1,120 @@ +var RESOURCES_DIR = "../resources/"; + +function dirname(path) { + return path.replace(/\/[^\/]*$/, '/') +} + +function checkRequest(request, ExpectedValuesDict) { + for (var attribute in ExpectedValuesDict) { + switch(attribute) { + case "headers": + for (var key in ExpectedValuesDict["headers"].keys()) { + assert_equals(request["headers"].get(key), ExpectedValuesDict["headers"].get(key), + "Check headers attribute has " + key + ":" + ExpectedValuesDict["headers"].get(key)); + } + break; + + case "body": + //for checking body's content, a dedicated asyncronous/promise test should be used + assert_true(request["headers"].has("Content-Type") , "Check request has body using Content-Type header") + break; + + case "method": + case "referrer": + case "referrerPolicy": + case "credentials": + case "cache": + case "redirect": + case "integrity": + case "url": + case "destination": + assert_equals(request[attribute], ExpectedValuesDict[attribute], "Check " + attribute + " attribute") + break; + + default: + break; + } + } +} + +function stringToArray(str) { + var array = new Uint8Array(str.length); + for (var i=0, strLen = str.length; i < strLen; i++) + array[i] = str.charCodeAt(i); + return array; +} + +function encode_utf8(str) +{ + if (self.TextEncoder) + return (new TextEncoder).encode(str); + return stringToArray(unescape(encodeURIComponent(str))); +} + +function validateBufferFromString(buffer, expectedValue, message) +{ + return assert_array_equals(new Uint8Array(buffer !== undefined ? buffer : []), stringToArray(expectedValue), message); +} + +function validateStreamFromString(reader, expectedValue, retrievedArrayBuffer) { + // Passing Uint8Array for byte streams; non-byte streams will simply ignore it + return reader.read(new Uint8Array(64)).then(function(data) { + if (!data.done) { + assert_true(data.value instanceof Uint8Array, "Fetch ReadableStream chunks should be Uint8Array"); + var newBuffer; + if (retrievedArrayBuffer) { + newBuffer = new Uint8Array(data.value.length + retrievedArrayBuffer.length); + newBuffer.set(retrievedArrayBuffer, 0); + newBuffer.set(data.value, retrievedArrayBuffer.length); + } else { + newBuffer = data.value; + } + return validateStreamFromString(reader, expectedValue, newBuffer); + } + validateBufferFromString(retrievedArrayBuffer, expectedValue, "Retrieve and verify stream"); + }); +} + +function validateStreamFromPartialString(reader, expectedValue, retrievedArrayBuffer) { + // Passing Uint8Array for byte streams; non-byte streams will simply ignore it + return reader.read(new Uint8Array(64)).then(function(data) { + if (!data.done) { + assert_true(data.value instanceof Uint8Array, "Fetch ReadableStream chunks should be Uint8Array"); + var newBuffer; + if (retrievedArrayBuffer) { + newBuffer = new Uint8Array(data.value.length + retrievedArrayBuffer.length); + newBuffer.set(retrievedArrayBuffer, 0); + newBuffer.set(data.value, retrievedArrayBuffer.length); + } else { + newBuffer = data.value; + } + return validateStreamFromPartialString(reader, expectedValue, newBuffer); + } + + var string = new TextDecoder("utf-8").decode(retrievedArrayBuffer); + return assert_true(string.search(expectedValue) != -1, "Retrieve and verify stream"); + }); +} + +// From streams tests +function delay(milliseconds) +{ + return new Promise(function(resolve) { + step_timeout(resolve, milliseconds); + }); +} + +function requestForbiddenHeaders(desc, forbiddenHeaders) { + var url = RESOURCES_DIR + "inspect-headers.py"; + var requestInit = {"headers": forbiddenHeaders} + var urlParameters = "?headers=" + Object.keys(forbiddenHeaders).join("|"); + + promise_test(function(test){ + return fetch(url + urlParameters, requestInit).then(function(resp) { + assert_equals(resp.status, 200, "HTTP status is 200"); + assert_equals(resp.type , "basic", "Response's type is basic"); + for (var header in forbiddenHeaders) + assert_not_equals(resp.headers.get("x-request-" + header), forbiddenHeaders[header], header + " does not have the value we defined"); + }); + }, desc); +} diff --git a/test/fixtures/wpt/fetch/api/response/json.any.js b/test/fixtures/wpt/fetch/api/response/json.any.js new file mode 100644 index 0000000..15f050e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/json.any.js @@ -0,0 +1,14 @@ +// See also /xhr/json.any.js + +promise_test(async t => { + const response = await fetch(`data:,\uFEFF{ "b": 1, "a": 2, "b": 3 }`); + const json = await response.json(); + assert_array_equals(Object.keys(json), ["b", "a"]); + assert_equals(json.a, 2); + assert_equals(json.b, 3); +}, "Ensure the correct JSON parser is used"); + +promise_test(async t => { + const response = await fetch("/xhr/resources/utf16-bom.json"); + return promise_rejects_js(t, SyntaxError, response.json()); +}, "Ensure UTF-16 results in an error"); diff --git a/test/fixtures/wpt/fetch/api/response/many-empty-chunks-crash.html b/test/fixtures/wpt/fetch/api/response/many-empty-chunks-crash.html new file mode 100644 index 0000000..fe5e7d4 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/many-empty-chunks-crash.html @@ -0,0 +1,14 @@ + + + diff --git a/test/fixtures/wpt/fetch/api/response/multi-globals/current/current.html b/test/fixtures/wpt/fetch/api/response/multi-globals/current/current.html new file mode 100644 index 0000000..9bb6e0b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/multi-globals/current/current.html @@ -0,0 +1,3 @@ + +Current page used as a test helper + diff --git a/test/fixtures/wpt/fetch/api/response/multi-globals/incumbent/incumbent.html b/test/fixtures/wpt/fetch/api/response/multi-globals/incumbent/incumbent.html new file mode 100644 index 0000000..f63372e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/multi-globals/incumbent/incumbent.html @@ -0,0 +1,16 @@ + +Incumbent page used as a test helper + + + + + diff --git a/test/fixtures/wpt/fetch/api/response/multi-globals/relevant/relevant.html b/test/fixtures/wpt/fetch/api/response/multi-globals/relevant/relevant.html new file mode 100644 index 0000000..44f42ed --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/multi-globals/relevant/relevant.html @@ -0,0 +1,2 @@ + +Relevant page used as a test helper diff --git a/test/fixtures/wpt/fetch/api/response/multi-globals/url-parsing.html b/test/fixtures/wpt/fetch/api/response/multi-globals/url-parsing.html new file mode 100644 index 0000000..5f2f42a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/multi-globals/url-parsing.html @@ -0,0 +1,27 @@ + +Response.redirect URL parsing, with multiple globals in play + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/response/response-arraybuffer-realm.window.js b/test/fixtures/wpt/fetch/api/response/response-arraybuffer-realm.window.js new file mode 100644 index 0000000..19a5dfa --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-arraybuffer-realm.window.js @@ -0,0 +1,23 @@ +// META: title=realm of Response arrayBuffer() + +'use strict'; + +promise_test(async () => { + await new Promise(resolve => { + onload = resolve; + }); + + let iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.srcdoc = ''; + await new Promise(resolve => { + iframe.onload = resolve; + }); + + let otherRealm = iframe.contentWindow; + + let ab = await window.Response.prototype.arrayBuffer.call(new otherRealm.Response('')); + + assert_true(ab instanceof otherRealm.ArrayBuffer, "ArrayBuffer should be created in receiver's realm"); + assert_false(ab instanceof ArrayBuffer, "ArrayBuffer should not be created in the arrayBuffer() methods's realm"); +}, 'realm of the ArrayBuffer from Response arrayBuffer()'); diff --git a/test/fixtures/wpt/fetch/api/response/response-blob-realm.any.js b/test/fixtures/wpt/fetch/api/response/response-blob-realm.any.js new file mode 100644 index 0000000..1cc51fc --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-blob-realm.any.js @@ -0,0 +1,24 @@ +// META: global=window +// META: title=realm of Response bytes() + +"use strict"; + +promise_test(async () => { + await new Promise(resolve => { + onload = resolve; + }); + + let iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + iframe.srcdoc = ""; + await new Promise(resolve => { + iframe.onload = resolve; + }); + + let otherRealm = iframe.contentWindow; + + let ab = await window.Response.prototype.bytes.call(new otherRealm.Response("")); + + assert_true(ab instanceof otherRealm.Uint8Array, "Uint8Array should be created in receiver's realm"); + assert_false(ab instanceof Uint8Array, "Uint8Array should not be created in the bytes() methods's realm"); +}, "realm of the Uint8Array from Response bytes()"); diff --git a/test/fixtures/wpt/fetch/api/response/response-body-read-task-handling.html b/test/fixtures/wpt/fetch/api/response/response-body-read-task-handling.html new file mode 100644 index 0000000..64b0755 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-body-read-task-handling.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/response/response-cancel-stream.any.js b/test/fixtures/wpt/fetch/api/response/response-cancel-stream.any.js new file mode 100644 index 0000000..91140d1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-cancel-stream.any.js @@ -0,0 +1,64 @@ +// META: global=window,worker +// META: title=Response consume blob and http bodies +// META: script=../resources/utils.js + +promise_test(function(test) { + return new Response(new Blob([], { "type" : "text/plain" })).body.cancel(); +}, "Cancelling a starting blob Response stream"); + +promise_test(function(test) { + var response = new Response(new Blob(["This is data"], { "type" : "text/plain" })); + var reader = response.body.getReader(); + reader.read(); + return reader.cancel(); +}, "Cancelling a loading blob Response stream"); + +promise_test(function(test) { + var response = new Response(new Blob(["T"], { "type" : "text/plain" })); + var reader = response.body.getReader(); + + var closedPromise = reader.closed.then(function() { + return reader.cancel(); + }); + reader.read().then(function readMore({done, value}) { + if (!done) return reader.read().then(readMore); + }); + return closedPromise; +}, "Cancelling a closed blob Response stream"); + +promise_test(function(test) { + return fetch(RESOURCES_DIR + "trickle.py?ms=30&count=100").then(function(response) { + return response.body.cancel(); + }); +}, "Cancelling a starting Response stream"); + +promise_test(function() { + return fetch(RESOURCES_DIR + "trickle.py?ms=30&count=100").then(function(response) { + var reader = response.body.getReader(); + return reader.read().then(function() { + return reader.cancel(); + }); + }); +}, "Cancelling a loading Response stream"); + +promise_test(function() { + async function readAll(reader) { + while (true) { + const {value, done} = await reader.read(); + if (done) + return; + } + } + + return fetch(RESOURCES_DIR + "top.txt").then(function(response) { + var reader = response.body.getReader(); + return readAll(reader).then(() => reader.cancel()); + }); +}, "Cancelling a closed Response stream"); + +promise_test(async () => { + const response = await fetch(RESOURCES_DIR + "top.txt"); + const { body } = response; + await body.cancel(); + assert_equals(body, response.body, ".body should not change after cancellation"); +}, "Accessing .body after canceling it"); diff --git a/test/fixtures/wpt/fetch/api/response/response-clone-iframe.window.js b/test/fixtures/wpt/fetch/api/response/response-clone-iframe.window.js new file mode 100644 index 0000000..da54616 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-clone-iframe.window.js @@ -0,0 +1,32 @@ +// Verify that calling Response clone() in a detached iframe doesn't crash. +// Regression test for https://crbug.com/1082688. + +'use strict'; + +promise_test(async () => { + // Wait for the document body to be available. + await new Promise(resolve => { + onload = resolve; + }); + + window.iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.srcdoc = ` + +`; + + await new Promise(resolve => { + onmessage = evt => { + if (evt.data === 'okay') { + resolve(); + } + }; + }); + + // If it got here without crashing, the test passed. +}, 'clone within removed iframe should not crash'); diff --git a/test/fixtures/wpt/fetch/api/response/response-clone.any.js b/test/fixtures/wpt/fetch/api/response/response-clone.any.js new file mode 100644 index 0000000..c0c8449 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-clone.any.js @@ -0,0 +1,141 @@ +// META: global=window,worker +// META: title=Response clone +// META: script=../resources/utils.js + +var defaultValues = { "type" : "default", + "url" : "", + "ok" : true, + "status" : 200, + "statusText" : "" +}; + +var response = new Response(); +var clonedResponse = response.clone(); +test(function() { + for (var attributeName in defaultValues) { + var expectedValue = defaultValues[attributeName]; + assert_equals(clonedResponse[attributeName], expectedValue, + "Expect default response." + attributeName + " is " + expectedValue); + } +}, "Check Response's clone with default values, without body"); + +var body = "This is response body"; +var headersInit = { "name" : "value" }; +var responseInit = { "status" : 200, + "statusText" : "GOOD", + "headers" : headersInit +}; +var response = new Response(body, responseInit); +var clonedResponse = response.clone(); +test(function() { + assert_equals(clonedResponse.status, responseInit["status"], + "Expect response.status is " + responseInit["status"]); + assert_equals(clonedResponse.statusText, responseInit["statusText"], + "Expect response.statusText is " + responseInit["statusText"]); + assert_equals(clonedResponse.headers.get("name"), "value", + "Expect response.headers has name:value header"); +}, "Check Response's clone has the expected attribute values"); + +promise_test(function(test) { + return validateStreamFromString(response.body.getReader(), body); +}, "Check orginal response's body after cloning"); + +promise_test(function(test) { + return validateStreamFromString(clonedResponse.body.getReader(), body); +}, "Check cloned response's body"); + +promise_test(function(test) { + var disturbedResponse = new Response("data"); + return disturbedResponse.text().then(function() { + assert_true(disturbedResponse.bodyUsed, "response is disturbed"); + assert_throws_js(TypeError, function() { disturbedResponse.clone(); }, + "Expect TypeError exception"); + }); +}, "Cannot clone a disturbed response"); + +promise_test(function(t) { + var clone; + var result; + var response; + return fetch('../resources/trickle.py?count=2&delay=100').then(function(res) { + clone = res.clone(); + response = res; + return clone.text(); + }).then(function(r) { + assert_equals(r.length, 26); + result = r; + return response.text(); + }).then(function(r) { + assert_equals(r, result, "cloned responses should provide the same data"); + }); + }, 'Cloned responses should provide the same data'); + +promise_test(function(t) { + var clone; + return fetch('../resources/trickle.py?count=2&delay=100').then(function(res) { + clone = res.clone(); + res.body.cancel(); + assert_true(res.bodyUsed); + assert_false(clone.bodyUsed); + return clone.arrayBuffer(); + }).then(function(r) { + assert_equals(r.byteLength, 26); + assert_true(clone.bodyUsed); + }); +}, 'Cancelling stream should not affect cloned one'); + +function testReadableStreamClone(initialBuffer, bufferType) +{ + promise_test(function(test) { + var response = new Response(new ReadableStream({start : function(controller) { + controller.enqueue(initialBuffer); + controller.close(); + }})); + + var clone = response.clone(); + var stream1 = response.body; + var stream2 = clone.body; + + var buffer; + return stream1.getReader().read().then(function(data) { + assert_false(data.done); + assert_equals(data.value, initialBuffer, "Buffer of being-cloned response stream is the same as the original buffer"); + return stream2.getReader().read(); + }).then(function(data) { + assert_false(data.done); + if (initialBuffer instanceof ArrayBuffer) { + assert_true(data.value instanceof ArrayBuffer, "Cloned buffer is ArrayBufer"); + assert_equals(initialBuffer.byteLength, data.value.byteLength, "Length equal"); + assert_array_equals(new Uint8Array(data.value), new Uint8Array(initialBuffer), "Cloned buffer chunks have the same content"); + } else if (initialBuffer instanceof DataView) { + assert_true(data.value instanceof DataView, "Cloned buffer is DataView"); + assert_equals(initialBuffer.byteLength, data.value.byteLength, "Lengths equal"); + assert_equals(initialBuffer.byteOffset, data.value.byteOffset, "Offsets equal"); + for (let i = 0; i < initialBuffer.byteLength; ++i) { + assert_equals( + data.value.getUint8(i), initialBuffer.getUint8(i), "Mismatch at byte ${i}"); + } + } else { + assert_array_equals(data.value, initialBuffer, "Cloned buffer chunks have the same content"); + } + assert_equals(Object.getPrototypeOf(data.value), Object.getPrototypeOf(initialBuffer), "Cloned buffers have the same type"); + assert_not_equals(data.value, initialBuffer, "Buffer of cloned response stream is a clone of the original buffer"); + }); + }, "Check response clone use structureClone for teed ReadableStreams (" + bufferType + "chunk)"); +} + +var arrayBuffer = new ArrayBuffer(16); +testReadableStreamClone(new Int8Array(arrayBuffer, 1), "Int8Array"); +testReadableStreamClone(new Int16Array(arrayBuffer, 2, 2), "Int16Array"); +testReadableStreamClone(new Int32Array(arrayBuffer), "Int32Array"); +testReadableStreamClone(arrayBuffer, "ArrayBuffer"); +testReadableStreamClone(new Uint8Array(arrayBuffer), "Uint8Array"); +testReadableStreamClone(new Uint8ClampedArray(arrayBuffer), "Uint8ClampedArray"); +testReadableStreamClone(new Uint16Array(arrayBuffer, 2), "Uint16Array"); +testReadableStreamClone(new Uint32Array(arrayBuffer), "Uint32Array"); +testReadableStreamClone(typeof BigInt64Array === "function" ? new BigInt64Array(arrayBuffer) : undefined, "BigInt64Array"); +testReadableStreamClone(typeof BigUint64Array === "function" ? new BigUint64Array(arrayBuffer) : undefined, "BigUint64Array"); +testReadableStreamClone(typeof Float16Array === "function" ? new Float16Array(arrayBuffer) : undefined, "Float16Array"); +testReadableStreamClone(new Float32Array(arrayBuffer), "Float32Array"); +testReadableStreamClone(new Float64Array(arrayBuffer), "Float64Array"); +testReadableStreamClone(new DataView(arrayBuffer, 2, 8), "DataView"); diff --git a/test/fixtures/wpt/fetch/api/response/response-consume-empty.any.js b/test/fixtures/wpt/fetch/api/response/response-consume-empty.any.js new file mode 100644 index 0000000..a5df356 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-consume-empty.any.js @@ -0,0 +1,88 @@ +// META: global=window,worker +// META: title=Response consume empty bodies + +function checkBodyText(test, response) { + return response.text().then(function(bodyAsText) { + assert_equals(bodyAsText, "", "Resolved value should be empty"); + assert_false(response.bodyUsed); + }); +} + +async function checkBodyBlob(test, response) { + const bodyAsBlob = await response.blob(); + const body = await bodyAsBlob.text(); + + assert_equals(body, "", "Resolved value should be empty"); + assert_false(response.bodyUsed); +} + +function checkBodyArrayBuffer(test, response) { + return response.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_false(response.bodyUsed); + }); +} + +function checkBodyJSON(test, response) { + return response.json().then( + function(bodyAsJSON) { + assert_unreached("JSON parsing should fail"); + }, + function() { + assert_false(response.bodyUsed); + }); +} + +function checkBodyFormData(test, response) { + return response.formData().then(function(bodyAsFormData) { + assert_true(bodyAsFormData instanceof FormData, "Should receive a FormData"); + assert_false(response.bodyUsed); + }); +} + +function checkBodyFormDataError(test, response) { + return promise_rejects_js(test, TypeError, response.formData()).then(function() { + assert_false(response.bodyUsed); + }); +} + +function checkResponseWithNoBody(bodyType, checkFunction, headers = []) { + promise_test(function(test) { + var response = new Response(undefined, { "headers": headers }); + assert_false(response.bodyUsed); + return checkFunction(test, response); + }, "Consume response's body as " + bodyType); +} + +checkResponseWithNoBody("text", checkBodyText); +checkResponseWithNoBody("blob", checkBodyBlob); +checkResponseWithNoBody("arrayBuffer", checkBodyArrayBuffer); +checkResponseWithNoBody("json (error case)", checkBodyJSON); +checkResponseWithNoBody("formData with correct multipart type (error case)", checkBodyFormDataError, [["Content-Type", 'multipart/form-data; boundary="boundary"']]); +checkResponseWithNoBody("formData with correct urlencoded type", checkBodyFormData, [["Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"]]); +checkResponseWithNoBody("formData without correct type (error case)", checkBodyFormDataError); + +function checkResponseWithEmptyBody(bodyType, body, asText) { + promise_test(function(test) { + var response = new Response(body); + assert_false(response.bodyUsed, "bodyUsed is false at init"); + if (asText) { + return response.text().then(function(bodyAsString) { + assert_equals(bodyAsString.length, 0, "Resolved value should be empty"); + assert_true(response.bodyUsed, "bodyUsed is true after being consumed"); + }); + } + return response.arrayBuffer().then(function(bodyAsArrayBuffer) { + assert_equals(bodyAsArrayBuffer.byteLength, 0, "Resolved value should be empty"); + assert_true(response.bodyUsed, "bodyUsed is true after being consumed"); + }); + }, "Consume empty " + bodyType + " response body as " + (asText ? "text" : "arrayBuffer")); +} + +checkResponseWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), false); +checkResponseWithEmptyBody("text", "", false); +checkResponseWithEmptyBody("blob", new Blob([], { "type" : "text/plain" }), true); +checkResponseWithEmptyBody("text", "", true); +checkResponseWithEmptyBody("URLSearchParams", new URLSearchParams(""), true); +checkResponseWithEmptyBody("FormData", new FormData(), true); +checkResponseWithEmptyBody("ArrayBuffer", new ArrayBuffer(), true); diff --git a/test/fixtures/wpt/fetch/api/response/response-consume-stream.any.js b/test/fixtures/wpt/fetch/api/response/response-consume-stream.any.js new file mode 100644 index 0000000..f89d734 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-consume-stream.any.js @@ -0,0 +1,80 @@ +// META: global=window,worker +// META: title=Response consume +// META: script=../resources/utils.js + +promise_test(function(test) { + var body = ""; + var response = new Response(""); + return validateStreamFromString(response.body.getReader(), ""); +}, "Read empty text response's body as readableStream"); + +promise_test(function(test) { + var response = new Response(new Blob([], { "type" : "text/plain" })); + return validateStreamFromString(response.body.getReader(), ""); +}, "Read empty blob response's body as readableStream"); + +var formData = new FormData(); +formData.append("name", "value"); +var textData = JSON.stringify("This is response's body"); +var blob = new Blob([textData], { "type" : "text/plain" }); +var urlSearchParamsData = "name=value"; +var urlSearchParams = new URLSearchParams(urlSearchParamsData); + +for (const mode of [undefined, "byob"]) { + promise_test(function(test) { + var response = new Response(blob); + return validateStreamFromString(response.body.getReader({ mode }), textData); + }, `Read blob response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var response = new Response(textData); + return validateStreamFromString(response.body.getReader({ mode }), textData); + }, `Read text response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var response = new Response(urlSearchParams); + return validateStreamFromString(response.body.getReader({ mode }), urlSearchParamsData); + }, `Read URLSearchParams response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var arrayBuffer = new ArrayBuffer(textData.length); + var int8Array = new Int8Array(arrayBuffer); + for (var cptr = 0; cptr < textData.length; cptr++) + int8Array[cptr] = textData.charCodeAt(cptr); + + return validateStreamFromString(new Response(arrayBuffer).body.getReader({ mode }), textData); + }, `Read array buffer response's body as readableStream with mode=${mode}`); + + promise_test(function(test) { + var response = new Response(formData); + return validateStreamFromPartialString(response.body.getReader({ mode }), + "Content-Disposition: form-data; name=\"name\"\r\n\r\nvalue"); + }, `Read form data response's body as readableStream with mode=${mode}`); +} + +test(function() { + assert_equals(Response.error().body, null); +}, "Getting an error Response stream"); + +test(function() { + assert_equals(Response.redirect("/").body, null); +}, "Getting a redirect Response stream"); + +promise_test(async function(test) { + var buffer = new ArrayBuffer(textData.length); + + var body = new Response(textData).body; + const reader = body.getReader( {mode: 'byob'} ); + + let offset = 3; + while (offset < textData.length) { + const {done, value} = await reader.read(new Uint8Array(buffer, offset)); + if (done) { + break; + } + buffer = value.buffer; + offset += value.byteLength; + } + + validateBufferFromString(buffer, `\0\0\0\"This is response's bo`, 'Buffer should be validated'); +}, `Reading with offset from Response stream`); diff --git a/test/fixtures/wpt/fetch/api/response/response-consume.html b/test/fixtures/wpt/fetch/api/response/response-consume.html new file mode 100644 index 0000000..89fc49f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-consume.html @@ -0,0 +1,317 @@ + + + + + Response consume + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/api/response/response-error-from-stream.any.js b/test/fixtures/wpt/fetch/api/response/response-error-from-stream.any.js new file mode 100644 index 0000000..33cad40 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-error-from-stream.any.js @@ -0,0 +1,61 @@ +// META: global=window,worker +// META: title=Response Receives Propagated Error from ReadableStream + +function newStreamWithStartError() { + var err = new Error("Start error"); + return [new ReadableStream({ + start(controller) { + controller.error(err); + } + }), + err] +} + +function newStreamWithPullError() { + var err = new Error("Pull error"); + return [new ReadableStream({ + pull(controller) { + controller.error(err); + } + }), + err] +} + +function runRequestPromiseTest([stream, err], responseReaderMethod, testDescription) { + promise_test(test => { + return promise_rejects_exactly( + test, + err, + new Response(stream)[responseReaderMethod](), + 'CustomTestError should propagate' + ) + }, testDescription) +} + + +promise_test(test => { + var [stream, err] = newStreamWithStartError(); + return promise_rejects_exactly(test, err, stream.getReader().read(), 'CustomTestError should propagate') +}, "ReadableStreamDefaultReader Promise receives ReadableStream start() Error") + +promise_test(test => { + var [stream, err] = newStreamWithPullError(); + return promise_rejects_exactly(test, err, stream.getReader().read(), 'CustomTestError should propagate') +}, "ReadableStreamDefaultReader Promise receives ReadableStream pull() Error") + + +// test start() errors for all Body reader methods +runRequestPromiseTest(newStreamWithStartError(), 'arrayBuffer', 'ReadableStream start() Error propagates to Response.arrayBuffer() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'blob', 'ReadableStream start() Error propagates to Response.blob() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'bytes', 'ReadableStream start() Error propagates to Response.bytes() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'formData', 'ReadableStream start() Error propagates to Response.formData() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'json', 'ReadableStream start() Error propagates to Response.json() Promise'); +runRequestPromiseTest(newStreamWithStartError(), 'text', 'ReadableStream start() Error propagates to Response.text() Promise'); + +// test pull() errors for all Body reader methods +runRequestPromiseTest(newStreamWithPullError(), 'arrayBuffer', 'ReadableStream pull() Error propagates to Response.arrayBuffer() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'blob', 'ReadableStream pull() Error propagates to Response.blob() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'bytes', 'ReadableStream pull() Error propagates to Response.bytes() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'formData', 'ReadableStream pull() Error propagates to Response.formData() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'json', 'ReadableStream pull() Error propagates to Response.json() Promise'); +runRequestPromiseTest(newStreamWithPullError(), 'text', 'ReadableStream pull() Error propagates to Response.text() Promise'); diff --git a/test/fixtures/wpt/fetch/api/response/response-error.any.js b/test/fixtures/wpt/fetch/api/response/response-error.any.js new file mode 100644 index 0000000..a76bc43 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-error.any.js @@ -0,0 +1,27 @@ +// META: global=window,worker +// META: title=Response error + +var invalidStatus = [0, 100, 199, 600, 1000]; +invalidStatus.forEach(function(status) { + test(function() { + assert_throws_js(RangeError, function() { new Response("", { "status" : status }); }, + "Expect RangeError exception when status is " + status); + },"Throws RangeError when responseInit's status is " + status); +}); + +var invalidStatusText = ["\n", "Ā"]; +invalidStatusText.forEach(function(statusText) { + test(function() { + assert_throws_js(TypeError, function() { new Response("", { "statusText" : statusText }); }, + "Expect TypeError exception " + statusText); + },"Throws TypeError when responseInit's statusText is " + statusText); +}); + +var nullBodyStatus = [204, 205, 304]; +nullBodyStatus.forEach(function(status) { + test(function() { + assert_throws_js(TypeError, + function() { new Response("body", {"status" : status }); }, + "Expect TypeError exception "); + },"Throws TypeError when building a response with body and a body status of " + status); +}); diff --git a/test/fixtures/wpt/fetch/api/response/response-from-stream.any.js b/test/fixtures/wpt/fetch/api/response/response-from-stream.any.js new file mode 100644 index 0000000..ea5192b --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-from-stream.any.js @@ -0,0 +1,23 @@ +// META: global=window,worker + +"use strict"; + +test(() => { + const stream = new ReadableStream(); + stream.getReader(); + assert_throws_js(TypeError, () => new Response(stream)); +}, "Constructing a Response with a stream on which getReader() is called"); + +test(() => { + const stream = new ReadableStream(); + stream.getReader().read(); + assert_throws_js(TypeError, () => new Response(stream)); +}, "Constructing a Response with a stream on which read() is called"); + +promise_test(async () => { + const stream = new ReadableStream({ pull: c => c.enqueue(new Uint8Array()) }), + reader = stream.getReader(); + await reader.read(); + reader.releaseLock(); + assert_throws_js(TypeError, () => new Response(stream)); +}, "Constructing a Response with a stream on which read() and releaseLock() are called"); diff --git a/test/fixtures/wpt/fetch/api/response/response-headers-guard.any.js b/test/fixtures/wpt/fetch/api/response/response-headers-guard.any.js new file mode 100644 index 0000000..4a67d06 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-headers-guard.any.js @@ -0,0 +1,8 @@ +// META: global=window,worker +// META: title=Response: error static method + +promise_test (async () => { + const response = await fetch("../resources/data.json"); + assert_throws_js(TypeError, () => { response.headers.append("name", "value"); }); + assert_not_equals(response.headers.get("name"), "value", "response headers should be immutable"); +}, "Ensure response headers are immutable"); diff --git a/test/fixtures/wpt/fetch/api/response/response-init-001.any.js b/test/fixtures/wpt/fetch/api/response/response-init-001.any.js new file mode 100644 index 0000000..559e49a --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-init-001.any.js @@ -0,0 +1,64 @@ +// META: global=window,worker +// META: title=Response init: simple cases + +var defaultValues = { "type" : "default", + "url" : "", + "ok" : true, + "status" : 200, + "statusText" : "", + "body" : null +}; + +var statusCodes = { "givenValues" : [200, 300, 400, 500, 599], + "expectedValues" : [200, 300, 400, 500, 599] +}; +var statusTexts = { "givenValues" : ["", "OK", "with space", String.fromCharCode(0x80)], + "expectedValues" : ["", "OK", "with space", String.fromCharCode(0x80)] +}; +var initValuesDict = { "status" : statusCodes, + "statusText" : statusTexts +}; + +function isOkStatus(status) { + return 200 <= status && 299 >= status; +} + +var response = new Response(); +for (var attributeName in defaultValues) { + test(function() { + var expectedValue = defaultValues[attributeName]; + assert_equals(response[attributeName], expectedValue, + "Expect default response." + attributeName + " is " + expectedValue); + }, "Check default value for " + attributeName + " attribute"); +} + +for (var attributeName in initValuesDict) { + test(function() { + var valuesToTest = initValuesDict[attributeName]; + for (var valueIdx in valuesToTest["givenValues"]) { + var givenValue = valuesToTest["givenValues"][valueIdx]; + var expectedValue = valuesToTest["expectedValues"][valueIdx]; + var responseInit = {}; + responseInit[attributeName] = givenValue; + var response = new Response("", responseInit); + assert_equals(response[attributeName], expectedValue, + "Expect response." + attributeName + " is " + expectedValue + + " when initialized with " + givenValue); + assert_equals(response.ok, isOkStatus(response.status), + "Expect response.ok is " + isOkStatus(response.status)); + } + }, "Check " + attributeName + " init values and associated getter"); +} + +test(function() { + const response1 = new Response(""); + assert_equals(response1.headers, response1.headers); + + const response2 = new Response("", {"headers": {"X-Foo": "bar"}}); + assert_equals(response2.headers, response2.headers); + const headers = response2.headers; + response2.headers.set("X-Foo", "quux"); + assert_equals(headers, response2.headers); + headers.set("X-Other-Header", "baz"); + assert_equals(headers, response2.headers); +}, "Test that Response.headers has the [SameObject] extended attribute"); diff --git a/test/fixtures/wpt/fetch/api/response/response-init-002.any.js b/test/fixtures/wpt/fetch/api/response/response-init-002.any.js new file mode 100644 index 0000000..6c0a46e --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-init-002.any.js @@ -0,0 +1,61 @@ +// META: global=window,worker +// META: title=Response init: body and headers +// META: script=../resources/utils.js + +test(function() { + var headerDict = {"name1": "value1", + "name2": "value2", + "name3": "value3" + }; + var headers = new Headers(headerDict); + var response = new Response("", { "headers" : headers }) + for (var name in headerDict) { + assert_equals(response.headers.get(name), headerDict[name], + "response's headers has " + name + " : " + headerDict[name]); + } +}, "Initialize Response with headers values"); + +function checkResponseInit(body, bodyType, expectedTextBody) { + promise_test(function(test) { + var response = new Response(body); + var resHeaders = response.headers; + var mime = resHeaders.get("Content-Type"); + assert_true(mime && mime.search(bodyType) > -1, "Content-Type header should be \"" + bodyType + "\" "); + return response.text().then(function(bodyAsText) { + //not equals: cannot guess formData exact value + assert_true(bodyAsText.search(expectedTextBody) > -1, "Retrieve and verify response body"); + }); + }, "Initialize Response's body with " + bodyType); +} + +var blob = new Blob(["This is a blob"], {type: "application/octet-binary"}); +var formaData = new FormData(); +formaData.append("name", "value"); +var urlSearchParams = "URLSearchParams are not supported"; +//avoid test timeout if not implemented +if (self.URLSearchParams) + urlSearchParams = new URLSearchParams("name=value"); +var usvString = "This is a USVString" + +checkResponseInit(blob, "application/octet-binary", "This is a blob"); +checkResponseInit(formaData, "multipart/form-data", "name=\"name\"\r\n\r\nvalue"); +checkResponseInit(urlSearchParams, "application/x-www-form-urlencoded;charset=UTF-8", "name=value"); +checkResponseInit(usvString, "text/plain;charset=UTF-8", "This is a USVString"); + +promise_test(function(test) { + var body = "This is response body"; + var response = new Response(body); + return validateStreamFromString(response.body.getReader(), body); +}, "Read Response's body as readableStream"); + +promise_test(function(test) { + var response = new Response("This is my fork", {"headers" : [["Content-Type", ""]]}); + return response.blob().then(function(blob) { + assert_equals(blob.type, "", "Blob type should be the empty string"); + }); +}, "Testing empty Response Content-Type header"); + +test(function() { + var response = new Response(null, {status: 204}); + assert_equals(response.body, null); +}, "Testing null Response body"); diff --git a/test/fixtures/wpt/fetch/api/response/response-init-contenttype.any.js b/test/fixtures/wpt/fetch/api/response/response-init-contenttype.any.js new file mode 100644 index 0000000..3a7744c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-init-contenttype.any.js @@ -0,0 +1,125 @@ +test(() => { + const response = new Response(); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with empty body"); + +test(() => { + const blob = new Blob([]); + const response = new Response(blob); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const response = new Response(blob); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const response = new Response(blob); + assert_equals(response.headers.get("Content-Type"), "a/b; c=d"); +}, "Default Content-Type for Response with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const response = new Response(buffer); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with buffer source body"); + +promise_test(async () => { + const formData = new FormData(); + formData.append("a", "b"); + const response = new Response(formData); + const boundary = (await response.text()).split("\r\n")[0].slice(2); + assert_equals( + response.headers.get("Content-Type"), + `multipart/form-data; boundary=${boundary}`, + ); +}, "Default Content-Type for Response with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const response = new Response(usp); + assert_equals( + response.headers.get("Content-Type"), + "application/x-www-form-urlencoded;charset=UTF-8", + ); +}, "Default Content-Type for Response with URLSearchParams body"); + +test(() => { + const response = new Response(""); + assert_equals( + response.headers.get("Content-Type"), + "text/plain;charset=UTF-8", + ); +}, "Default Content-Type for Response with string body"); + +test(() => { + const stream = new ReadableStream(); + const response = new Response(stream); + assert_equals(response.headers.get("Content-Type"), null); +}, "Default Content-Type for Response with ReadableStream body"); + +// ----------------------------------------------------------------------------- + +const OVERRIDE_MIME = "test/only; mime=type"; + +function responseWithOverrideMime(body) { + return new Response( + body, + { headers: { "Content-Type": OVERRIDE_MIME } }, + ); +} + +test(() => { + const response = responseWithOverrideMime(undefined); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with empty body"); + +test(() => { + const blob = new Blob([]); + const response = responseWithOverrideMime(blob); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with Blob body (no type set)"); + +test(() => { + const blob = new Blob([], { type: "" }); + const response = responseWithOverrideMime(blob); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with Blob body (empty type)"); + +test(() => { + const blob = new Blob([], { type: "a/b; c=d" }); + const response = responseWithOverrideMime(blob); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with Blob body (set type)"); + +test(() => { + const buffer = new Uint8Array(); + const response = responseWithOverrideMime(buffer); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with buffer source body"); + +test(() => { + const formData = new FormData(); + const response = responseWithOverrideMime(formData); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with FormData body"); + +test(() => { + const usp = new URLSearchParams(); + const response = responseWithOverrideMime(usp); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with URLSearchParams body"); + +test(() => { + const response = responseWithOverrideMime(""); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with string body"); + +test(() => { + const stream = new ReadableStream(); + const response = responseWithOverrideMime(stream); + assert_equals(response.headers.get("Content-Type"), OVERRIDE_MIME); +}, "Can override Content-Type for Response with ReadableStream body"); diff --git a/test/fixtures/wpt/fetch/api/response/response-static-error.any.js b/test/fixtures/wpt/fetch/api/response/response-static-error.any.js new file mode 100644 index 0000000..4097eab --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-static-error.any.js @@ -0,0 +1,22 @@ +// META: global=window,worker +// META: title=Response: error static method + +test(function() { + var responseError = Response.error(); + assert_equals(responseError.type, "error", "Network error response's type is error"); + assert_equals(responseError.status, 0, "Network error response's status is 0"); + assert_equals(responseError.statusText, "", "Network error response's statusText is empty"); + assert_equals(responseError.body, null, "Network error response's body is null"); + + assert_true(responseError.headers.entries().next().done, "Headers should be empty"); +}, "Check response returned by static method error()"); + +test(function() { + const headers = Response.error().headers; + + // Avoid false positives if expected API is not available + assert_true(!!headers); + assert_equals(typeof headers.append, 'function'); + + assert_throws_js(TypeError, function () { headers.append('name', 'value'); }); +}, "the 'guard' of the Headers instance should be immutable"); diff --git a/test/fixtures/wpt/fetch/api/response/response-static-json.any.js b/test/fixtures/wpt/fetch/api/response/response-static-json.any.js new file mode 100644 index 0000000..5ec79e6 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-static-json.any.js @@ -0,0 +1,96 @@ +// META: global=window,worker +// META: title=Response: json static method + +const APPLICATION_JSON = "application/json"; +const FOO_BAR = "foo/bar"; + +const INIT_TESTS = [ + [undefined, 200, "", APPLICATION_JSON, {}], + [{ status: 400 }, 400, "", APPLICATION_JSON, {}], + [{ statusText: "foo" }, 200, "foo", APPLICATION_JSON, {}], + [{ headers: {} }, 200, "", APPLICATION_JSON, {}], + [{ headers: { "content-type": FOO_BAR } }, 200, "", FOO_BAR, {}], + [{ headers: { "x-foo": "bar" } }, 200, "", APPLICATION_JSON, { "x-foo": "bar" }], +]; + +for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) { + promise_test(async function () { + const response = Response.json("hello world", init); + assert_equals(response.type, "default", "Response's type is default"); + assert_equals(response.status, expectedStatus, "Response's status is " + expectedStatus); + assert_equals(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText)); + assert_equals(response.headers.get("content-type"), expectedContentType, "Response's content-type is " + expectedContentType); + for (const key in expectedHeaders) { + assert_equals(response.headers.get(key), expectedHeaders[key], "Response's header " + key + " is " + JSON.stringify(expectedHeaders[key])); + } + + const data = await response.json(); + assert_equals(data, "hello world", "Response's body is 'hello world'"); + }, `Check response returned by static json() with init ${JSON.stringify(init)}`); +} + +const nullBodyStatus = [204, 205, 304]; +for (const status of nullBodyStatus) { + test(function () { + assert_throws_js( + TypeError, + function () { + Response.json("hello world", { status: status }); + }, + ); + }, `Throws TypeError when calling static json() with a status of ${status}`); +} + +promise_test(async function () { + const response = Response.json({ foo: "bar" }); + const data = await response.json(); + assert_equals(typeof data, "object", "Response's json body is an object"); + assert_equals(data.foo, "bar", "Response's json body is { foo: 'bar' }"); +}, "Check static json() encodes JSON objects correctly"); + +test(function () { + assert_throws_js( + TypeError, + function () { + Response.json(Symbol("foo")); + }, + ); +}, "Check static json() throws when data is not encodable"); + +test(function () { + const a = { b: 1 }; + a.a = a; + assert_throws_js( + TypeError, + function () { + Response.json(a); + }, + ); +}, "Check static json() throws when data is circular"); + +promise_test(async function () { + class CustomError extends Error { + name = "CustomError"; + } + assert_throws_js( + CustomError, + function () { + Response.json({ get foo() { throw new CustomError("bar") }}); + } + ) +}, "Check static json() propagates JSON serializer errors"); + +const encodingChecks = [ + ["𝌆", [34, 240, 157, 140, 134, 34]], + ["\uDF06\uD834", [34, 92, 117, 100, 102, 48, 54, 92, 117, 100, 56, 51, 52, 34]], + ["\uDEAD", [34, 92, 117, 100, 101, 97, 100, 34]], +]; + +for (const [input, expected] of encodingChecks) { + promise_test(async function () { + const response = Response.json(input); + const buffer = await response.arrayBuffer(); + const data = new Uint8Array(buffer); + assert_array_equals(data, expected); + }, `Check response returned by static json() with input ${input}`); +} diff --git a/test/fixtures/wpt/fetch/api/response/response-static-redirect.any.js b/test/fixtures/wpt/fetch/api/response/response-static-redirect.any.js new file mode 100644 index 0000000..b16c56d --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-static-redirect.any.js @@ -0,0 +1,40 @@ +// META: global=window,worker +// META: title=Response: redirect static method + +var url = "http://test.url:1234/"; +test(function() { + const redirectResponse = Response.redirect(url); + assert_equals(redirectResponse.type, "default"); + assert_false(redirectResponse.redirected); + assert_false(redirectResponse.ok); + assert_equals(redirectResponse.status, 302, "Default redirect status is 302"); + assert_equals(redirectResponse.headers.get("Location"), url, + "redirected response has Location header with the correct url"); + assert_equals(redirectResponse.statusText, ""); +}, "Check default redirect response"); + +[301, 302, 303, 307, 308].forEach(function(status) { + test(function() { + const redirectResponse = Response.redirect(url, status); + assert_equals(redirectResponse.type, "default"); + assert_false(redirectResponse.redirected); + assert_false(redirectResponse.ok); + assert_equals(redirectResponse.status, status, "Redirect status is " + status); + assert_equals(redirectResponse.headers.get("Location"), url); + assert_equals(redirectResponse.statusText, ""); + }, "Check response returned by static method redirect(), status = " + status); +}); + +test(function() { + var invalidUrl = "http://:This is not an url"; + assert_throws_js(TypeError, function() { Response.redirect(invalidUrl); }, + "Expect TypeError exception"); +}, "Check error returned when giving invalid url to redirect()"); + +var invalidRedirectStatus = [200, 309, 400, 500]; +invalidRedirectStatus.forEach(function(invalidStatus) { + test(function() { + assert_throws_js(RangeError, function() { Response.redirect(url, invalidStatus); }, + "Expect RangeError exception"); + }, "Check error returned when giving invalid status to redirect(), status = " + invalidStatus); +}); diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-bad-chunk.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-bad-chunk.any.js new file mode 100644 index 0000000..8e83cd1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-bad-chunk.any.js @@ -0,0 +1,25 @@ +// META: global=window,worker +// META: title=Response causes TypeError from bad chunk type + +function runChunkTest(responseReaderMethod, testDescription) { + promise_test(test => { + let stream = new ReadableStream({ + start(controller) { + controller.enqueue("not Uint8Array"); + controller.close(); + } + }); + + return promise_rejects_js(test, TypeError, + new Response(stream)[responseReaderMethod](), + 'TypeError should propagate' + ) + }, testDescription) +} + +runChunkTest('arrayBuffer', 'ReadableStream with non-Uint8Array chunk passed to Response.arrayBuffer() causes TypeError'); +runChunkTest('blob', 'ReadableStream with non-Uint8Array chunk passed to Response.blob() causes TypeError'); +runChunkTest('bytes', 'ReadableStream with non-Uint8Array chunk passed to Response.bytes() causes TypeError'); +runChunkTest('formData', 'ReadableStream with non-Uint8Array chunk passed to Response.formData() causes TypeError'); +runChunkTest('json', 'ReadableStream with non-Uint8Array chunk passed to Response.json() causes TypeError'); +runChunkTest('text', 'ReadableStream with non-Uint8Array chunk passed to Response.text() causes TypeError'); diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-1.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-1.any.js new file mode 100644 index 0000000..64f65f1 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-1.any.js @@ -0,0 +1,44 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + const reader = response.body.getReader(); + reader.releaseLock(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.blob().then(function(blob) { + assert_true(blob instanceof Blob); + }); + }); + }, `Getting blob after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); + + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.text().then(function(text) { + assert_true(text.length > 0); + }); + }); + }, `Getting text after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); + + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.json().then(function(json) { + assert_equals(typeof json, "object"); + }); + }); + }, `Getting json after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); + + promise_test(function() { + return createResponseWithReadableStream(bodySource, function(response) { + return response.arrayBuffer().then(function(arrayBuffer) { + assert_true(arrayBuffer.byteLength > 0); + }); + }); + }, `Getting arrayBuffer after getting the Response body - not disturbed, not locked (body source: ${bodySource})`); +} diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-2.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-2.any.js new file mode 100644 index 0000000..c46a180 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-2.any.js @@ -0,0 +1,35 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithLockedReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + response.body.getReader(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.blob()); + }); + }, `Getting blob after getting a locked Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.text()); + }); + }, `Getting text after getting a locked Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.json()); + }); + }, `Getting json after getting a locked Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithLockedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.arrayBuffer()); + }); + }, `Getting arrayBuffer after getting a locked Response body (body source: ${bodySource})`); +} diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-3.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-3.any.js new file mode 100644 index 0000000..35fb086 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-3.any.js @@ -0,0 +1,36 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithDisturbedReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + const reader = response.body.getReader(); + reader.read(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.blob()); + }); + }, `Getting blob after reading the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.text()); + }); + }, `Getting text after reading the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.json()); + }); + }, `Getting json after reading the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithDisturbedReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.arrayBuffer()); + }); + }, `Getting arrayBuffer after reading the Response body (body source: ${bodySource})`); +} diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-4.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-4.any.js new file mode 100644 index 0000000..490672f --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-4.any.js @@ -0,0 +1,35 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +async function createResponseWithCancelledReadableStream(bodySource, callback) { + const response = await responseFromBodySource(bodySource); + response.body.cancel(); + return callback(response); +} + +for (const bodySource of ["fetch", "stream", "string"]) { + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.blob()); + }); + }, `Getting blob after cancelling the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.text()); + }); + }, `Getting text after cancelling the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.json()); + }); + }, `Getting json after cancelling the Response body (body source: ${bodySource})`); + + promise_test(function(test) { + return createResponseWithCancelledReadableStream(bodySource, function(response) { + return promise_rejects_js(test, TypeError, response.arrayBuffer()); + }); + }, `Getting arrayBuffer after cancelling the Response body (body source: ${bodySource})`); +} diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-5.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-5.any.js new file mode 100644 index 0000000..348fc39 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-5.any.js @@ -0,0 +1,19 @@ +// META: global=window,worker +// META: title=Consuming Response body after getting a ReadableStream +// META: script=./response-stream-disturbed-util.js + +for (const bodySource of ["fetch", "stream", "string"]) { + for (const consumeAs of ["blob", "text", "json", "arrayBuffer"]) { + promise_test( + async () => { + const response = await responseFromBodySource(bodySource); + response[consumeAs](); + assert_not_equals(response.body, null); + assert_throws_js(TypeError, function () { + response.body.getReader(); + }); + }, + `Getting a body reader after consuming as ${consumeAs} (body source: ${bodySource})`, + ); + } +} diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-6.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-6.any.js new file mode 100644 index 0000000..61d8544 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-6.any.js @@ -0,0 +1,76 @@ +// META: global=window,worker +// META: title=ReadableStream disturbed tests, via Response's bodyUsed property + +"use strict"; + +test(() => { + const stream = new ReadableStream(); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.read(); + assert_true(response.bodyUsed, "After calling stream.read()"); +}, "A non-closed stream on which read() has been called"); + +test(() => { + const stream = new ReadableStream(); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.cancel(); + assert_true(response.bodyUsed, "After calling stream.cancel()"); +}, "A non-closed stream on which cancel() has been called"); + +test(() => { + const stream = new ReadableStream({ + start(c) { + c.close(); + } + }); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.read(); + assert_true(response.bodyUsed, "After calling stream.read()"); +}, "A closed stream on which read() has been called"); + +test(() => { + const stream = new ReadableStream({ + start(c) { + c.error(new Error("some error")); + } + }); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.read().then(() => { }, () => { }); + assert_true(response.bodyUsed, "After calling stream.read()"); +}, "An errored stream on which read() has been called"); + +test(() => { + const stream = new ReadableStream({ + start(c) { + c.error(new Error("some error")); + } + }); + const response = new Response(stream); + assert_false(response.bodyUsed, "On construction"); + + const reader = stream.getReader(); + assert_false(response.bodyUsed, "After getting a reader"); + + reader.cancel().then(() => { }, () => { }); + assert_true(response.bodyUsed, "After calling stream.cancel()"); +}, "An errored stream on which cancel() has been called"); diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-by-pipe.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-by-pipe.any.js new file mode 100644 index 0000000..5341b75 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-by-pipe.any.js @@ -0,0 +1,17 @@ +// META: global=window,worker + +test(() => { + const r = new Response(new ReadableStream()); + // highWaterMark: 0 means that nothing will actually be read from the body. + r.body.pipeTo(new WritableStream({}, {highWaterMark: 0})); + assert_true(r.bodyUsed, 'bodyUsed should be true'); +}, 'using pipeTo on Response body should disturb it synchronously'); + +test(() => { + const r = new Response(new ReadableStream()); + r.body.pipeThrough({ + writable: new WritableStream({}, {highWaterMark: 0}), + readable: new ReadableStream() + }); + assert_true(r.bodyUsed, 'bodyUsed should be true'); +}, 'using pipeThrough on Response body should disturb it synchronously'); diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-util.js b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-util.js new file mode 100644 index 0000000..50bb586 --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-disturbed-util.js @@ -0,0 +1,17 @@ +const BODY = '{"key": "value"}'; + +function responseFromBodySource(bodySource) { + if (bodySource === "fetch") { + return fetch("../resources/data.json"); + } else if (bodySource === "stream") { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(BODY)); + controller.close(); + }, + }); + return new Response(stream); + } else { + return new Response(BODY); + } +} diff --git a/test/fixtures/wpt/fetch/api/response/response-stream-with-broken-then.any.js b/test/fixtures/wpt/fetch/api/response/response-stream-with-broken-then.any.js new file mode 100644 index 0000000..8fef66c --- /dev/null +++ b/test/fixtures/wpt/fetch/api/response/response-stream-with-broken-then.any.js @@ -0,0 +1,117 @@ +// META: global=window,worker +// META: script=../resources/utils.js + +promise_test(async () => { + // t.add_cleanup doesn't work when Object.prototype.then is overwritten, so + // these tests use add_completion_callback for cleanup instead. + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const bye = new TextEncoder().encode('bye'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: bye}); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject {done: false, value: bye} via Object.prototype.then.'); + +promise_test(async (t) => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: undefined}); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject value: undefined via Object.prototype.then.'); + +promise_test(async (t) => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled(undefined); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject undefined via Object.prototype.then.'); + +promise_test(async (t) => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const rs = new ReadableStream({ + start(controller) { + controller.enqueue(hello); + controller.close(); + } + }); + const resp = new Response(rs); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled(8.2); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'Attempt to inject 8.2 via Object.prototype.then.'); + +promise_test(async () => { + add_completion_callback(() => delete Object.prototype.then); + const hello = new TextEncoder().encode('hello'); + const bye = new TextEncoder().encode('bye'); + const resp = new Response(hello); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: bye}); + }; + const text = await resp.text(); + delete Object.prototype.then; + assert_equals(text, 'hello', 'The value should be "hello".'); +}, 'intercepting arraybuffer to text conversion via Object.prototype.then ' + + 'should not be possible'); + +promise_test(async () => { + add_completion_callback(() => delete Object.prototype.then); + const u8a123 = new Uint8Array([1, 2, 3]); + const u8a456 = new Uint8Array([4, 5, 6]); + const resp = new Response(u8a123); + const writtenBytes = []; + const ws = new WritableStream({ + write(chunk) { + writtenBytes.push(...Array.from(chunk)); + } + }); + Object.prototype.then = (onFulfilled) => { + delete Object.prototype.then; + onFulfilled({done: false, value: u8a456}); + }; + await resp.body.pipeTo(ws); + delete Object.prototype.then; + assert_array_equals(writtenBytes, u8a123, 'The value should be [1, 2, 3]'); +}, 'intercepting arraybuffer to body readable stream conversion via ' + + 'Object.prototype.then should not be possible'); diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cache.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cache.tentative.https.html new file mode 100644 index 0000000..c8bcf7f --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cache.tentative.https.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cookies.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cookies.tentative.https.html new file mode 100644 index 0000000..aa1673e --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-cookies.tentative.https.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-storage.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-storage.tentative.https.html new file mode 100644 index 0000000..22747eb --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-clear-site-data-storage.tentative.https.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-decompression.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-decompression.tentative.https.html new file mode 100644 index 0000000..33aeb44 --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-decompression.tentative.https.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-element.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-element.tentative.https.html new file mode 100644 index 0000000..d465ceb --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-element.tentative.https.html @@ -0,0 +1,72 @@ + + + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-header.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-header.tentative.https.html new file mode 100644 index 0000000..007067d --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-fetch-with-link-header.tentative.https.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/dictionary-registration.tentative.https.html b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-registration.tentative.https.html new file mode 100644 index 0000000..f0782af --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/dictionary-registration.tentative.https.html @@ -0,0 +1,61 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/resources/clear-site-data.py b/test/fixtures/wpt/fetch/compression-dictionary/resources/clear-site-data.py new file mode 100644 index 0000000..0db51bf --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/resources/clear-site-data.py @@ -0,0 +1,4 @@ +def main(request, response): + directive = request.GET.first(b"directive") + response.headers.set(b"Clear-Site-Data", b"\"" + directive + b"\"") + return b"OK" diff --git a/test/fixtures/wpt/fetch/compression-dictionary/resources/compressed-data.py b/test/fixtures/wpt/fetch/compression-dictionary/resources/compressed-data.py new file mode 100644 index 0000000..bb9d7fe --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/resources/compressed-data.py @@ -0,0 +1,31 @@ +def main(request, response): + response.headers.set(b"Access-Control-Allow-Origin", b"*") + response.headers.set(b"Content-Type", b"text/plain") + + # `dcb_data` and `dcz_data` are generated using the following commands: + # + # $ echo "This is a test dictionary." > /tmp/dict + # $ echo -n "This is compressed test data using a test dictionary" \ + # > /tmp/data + # + # $ echo -en '\xffDCB' > /tmp/out.dcb + # $ openssl dgst -sha256 -binary /tmp/dict >> /tmp/out.dcb + # $ brotli --stdout -D /tmp/dict /tmp/data >> /tmp/out.dcb + # $ xxd -p /tmp/out.dcb | tr -d '\n' | sed 's/\(..\)/\\x\1/g' + dcb_data = b"\xff\x44\x43\x42\x53\x96\x9b\xcf\x5e\x96\x0e\x0e\xdb\xf0\xa4\xbd\xde\x6b\x0b\x3e\x93\x81\xe1\x56\xde\x7f\x5b\x91\xce\x83\x91\x62\x42\x70\xf4\x16\xa1\x98\x01\x80\x62\xa4\x4c\x1d\xdf\x12\x84\x8c\xae\xc2\xca\x60\x22\x07\x6e\x81\x05\x14\xc9\xb7\xc3\x44\x8e\xbc\x16\xe0\x15\x0e\xec\xc1\xee\x34\x33\x3e\x0d" + # $ echo -en '\x5e\x2a\x4d\x18\x20\x00\x00\x00' > /tmp/out.dcz + # $ openssl dgst -sha256 -binary /tmp/dict >> /tmp/out.dcz + # $ zstd -D /tmp/dict -f -o /tmp/tmp.zstd /tmp/data + # $ cat /tmp/tmp.zstd >> /tmp/out.dcz + # $ xxd -p /tmp/out.dcz | tr -d '\n' | sed 's/\(..\)/\\x\1/g' + dcz_data = b"\x5e\x2a\x4d\x18\x20\x00\x00\x00\x53\x96\x9b\xcf\x5e\x96\x0e\x0e\xdb\xf0\xa4\xbd\xde\x6b\x0b\x3e\x93\x81\xe1\x56\xde\x7f\x5b\x91\xce\x83\x91\x62\x42\x70\xf4\x16\x28\xb5\x2f\xfd\x24\x34\xf5\x00\x00\x98\x63\x6f\x6d\x70\x72\x65\x73\x73\x65\x64\x61\x74\x61\x20\x75\x73\x69\x6e\x67\x03\x00\x59\xf9\x73\x54\x46\x27\x26\x10\x9e\x99\xf2\xbc" + + if b'content_encoding' in request.GET: + content_encoding = request.GET.first(b"content_encoding") + response.headers.set(b"Content-Encoding", content_encoding) + if content_encoding == b"dcb": + # Send the pre compressed file + response.content = dcb_data + if content_encoding == b"dcz": + # Send the pre compressed file + response.content = dcz_data diff --git a/test/fixtures/wpt/fetch/compression-dictionary/resources/compression-dictionary-util.js b/test/fixtures/wpt/fetch/compression-dictionary/resources/compression-dictionary-util.js new file mode 100644 index 0000000..7d86f59 --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/resources/compression-dictionary-util.js @@ -0,0 +1,120 @@ + +const kDefaultDictionaryContent = 'This is a test dictionary.\n'; +const kDefaultDictionaryHashBase64 = + ':U5abz16WDg7b8KS93msLPpOB4Vbef1uRzoORYkJw9BY=:'; +const kRegisterDictionaryPath = './resources/register-dictionary.py'; +const kCompressedDataPath = './resources/compressed-data.py'; +const kExpectedCompressedData = + `This is compressed test data using a test dictionary`; +const kCheckAvailableDictionaryHeaderMaxRetry = 10; +const kCheckAvailableDictionaryHeaderRetryTimeout = 200; +const kCheckPreviousRequestHeadersMaxRetry = 5; +const kCheckPreviousRequestHeadersRetryTimeout = 250; + +// Gets the remote URL corresponding to `relative_path`. +function getRemoteHostUrl(relative_path) { + const remote_origin = new URL(get_host_info().HTTPS_REMOTE_ORIGIN); + let result = new URL(relative_path, location.href); + result.protocol = remote_origin.protocol; + result.hostname = remote_origin.hostname; + result.port = remote_origin.port; + return result.href; +} + +// Calculates the Structured Field Byte Sequence containing the SHA-256 hash of +// the contents of the dictionary text. +async function calculateDictionaryHash(dictionary_text) { + const encoded = (new TextEncoder()).encode(dictionary_text); + const digest = await crypto.subtle.digest('SHA-256', encoded) + return ':' + btoa(String.fromCharCode(...new Uint8Array(digest))) + ':'; +} + +// Checks the HTTP request headers which is sent to the server. +async function checkHeaders(check_remote = false) { + let url = './resources/echo-headers.py'; + if (check_remote) { + url = getRemoteHostUrl(url); + } + return await (await fetch(url)).json(); +} + +// Checks the "available-dictionary" header in the HTTP request headers. +async function checkAvailableDictionaryHeader(check_remote = false) { + return (await checkHeaders(check_remote))['available-dictionary']; +} + +// Waits until the "available-dictionary" header is available in the HTTP +// request headers, and returns the header. If the header is not available after +// the specified number of retries, returns an error message. If the +// `expected_header` is specified, this method waits until the header is +// available and matches the `expected_header`. +async function waitUntilAvailableDictionaryHeader(test, { + max_retry = kCheckAvailableDictionaryHeaderMaxRetry, + expected_header = undefined, + check_remote = false +}) { + for (let retry_count = 0; retry_count <= max_retry; retry_count++) { + const header = await checkAvailableDictionaryHeader(check_remote); + if (header) { + if (expected_header === undefined || header == expected_header) { + return header; + } + } + await new Promise( + (resolve) => test.step_timeout( + resolve, kCheckAvailableDictionaryHeaderRetryTimeout)); + } + return '"available-dictionary" header is not available'; +} + +// Checks the HTTP request headers which was sent to the server with `token` +// to register a dictionary. +async function checkPreviousRequestHeaders(token, check_remote = false) { + let url = `./resources/register-dictionary.py?get_previous_header=${token}`; + if (check_remote) { + url = getRemoteHostUrl(url); + } + return await (await fetch(url)).json(); +} + +// Waits until the HTTP request headers which was sent to the server with +// `token` to register a dictionary is available, and returns the header. If the +// header is not available after the specified number of retries, returns +// `undefined`. +async function waitUntilPreviousRequestHeaders( + test, token, check_remote = false) { + for (let retry_count = 0; retry_count <= kCheckPreviousRequestHeadersMaxRetry; + retry_count++) { + const header = + (await checkPreviousRequestHeaders(token, check_remote))['headers']; + if (header) { + return header; + } + await new Promise( + (resolve) => test.step_timeout( + resolve, kCheckPreviousRequestHeadersRetryTimeout)); + } + return undefined; +} + +// Clears the site data for the specified directive by sending a request to +// `./resources/clear-site-data.py` which returns `Clear-Site-Data` response +// header. +// Note: When `directive` is 'cache' or 'cookies' is specified, registered +// compression dictionaries should be also cleared. +async function clearSiteData(directive = 'cache') { + return await (await fetch( + `./resources/clear-site-data.py?directive=${directive}`)) + .text(); +} + +// A utility test method that adds the `clearSiteData()` method to the +// testharness cleanup function. This is intended to ensure that registered +// dictionaries are cleared in tests and that registered dictionaries do not +// interfere with subsequent tests. +function compression_dictionary_promise_test(func, name, properties) { + promise_test(async (test) => { + test.add_cleanup(clearSiteData); + await func(test); + }, name, properties); +} diff --git a/test/fixtures/wpt/fetch/compression-dictionary/resources/echo-headers.py b/test/fixtures/wpt/fetch/compression-dictionary/resources/echo-headers.py new file mode 100644 index 0000000..aabd99e --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/resources/echo-headers.py @@ -0,0 +1,10 @@ +import json + +def main(request, response): + response.headers.set(b"Access-Control-Allow-Origin", b"*") + headers = {} + for header in request.headers: + key = header.decode('utf-8') + value = request.headers.get(header).decode('utf-8') + headers[key] = value + return json.dumps(headers) diff --git a/test/fixtures/wpt/fetch/compression-dictionary/resources/empty.html b/test/fixtures/wpt/fetch/compression-dictionary/resources/empty.html new file mode 100644 index 0000000..0e76edd --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/resources/empty.html @@ -0,0 +1 @@ + diff --git a/test/fixtures/wpt/fetch/compression-dictionary/resources/register-dictionary.py b/test/fixtures/wpt/fetch/compression-dictionary/resources/register-dictionary.py new file mode 100644 index 0000000..0bd5722 --- /dev/null +++ b/test/fixtures/wpt/fetch/compression-dictionary/resources/register-dictionary.py @@ -0,0 +1,37 @@ +import json + +def main(request, response): + response.headers.set(b"Access-Control-Allow-Origin", b"*") + match = b"/fetch/compression-dictionary/resources/*" + content = b"This is a test dictionary.\n" + if b"match" in request.GET: + match = request.GET.first(b"match") + if b"content" in request.GET: + content = request.GET.first(b"content") + + token = request.GET.first(b"save_header", None) + if token is not None: + headers = {} + for header in request.headers: + key = header.decode('utf-8') + value = request.headers.get(header).decode('utf-8') + headers[key] = value + with request.server.stash.lock: + request.server.stash.put(token, json.dumps(headers)) + + previous_token = request.GET.first(b"get_previous_header", None) + if previous_token is not None: + result = {} + with request.server.stash.lock: + store = request.server.stash.take(previous_token) + if store is not None: + headers = json.loads(store) + result["headers"] = headers + return json.dumps(result) + + options = b"match=\"" + match + b"\"" + if b"id" in request.GET: + options += b", id=\"" + request.GET.first(b"id") + b"\"" + response.headers.set(b"Use-As-Dictionary", options) + response.headers.set(b"Cache-Control", b"max-age=3600") + return content diff --git a/test/fixtures/wpt/fetch/connection-pool/network-partition-key.html b/test/fixtures/wpt/fetch/connection-pool/network-partition-key.html new file mode 100644 index 0000000..60a784c --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/network-partition-key.html @@ -0,0 +1,264 @@ + + + + + Connection partitioning by site + + + + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-about-blank-checker.html b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-about-blank-checker.html new file mode 100644 index 0000000..7a8b613 --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-about-blank-checker.html @@ -0,0 +1,35 @@ + + + + + about:blank Network Partition Checker + + + + + + + diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-checker.html b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-checker.html new file mode 100644 index 0000000..b058f61 --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-checker.html @@ -0,0 +1,30 @@ + + + + + Network Partition Checker + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-iframe-checker.html b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-iframe-checker.html new file mode 100644 index 0000000..f76ed18 --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-iframe-checker.html @@ -0,0 +1,22 @@ + + + + + Iframe Network Partition Checker + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.js b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.js new file mode 100644 index 0000000..bd66109 --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.js @@ -0,0 +1,47 @@ +// Runs multiple fetches that validate connections see only a single partition_id. +// Requests are run in parallel so that they use multiple connections to maximize the +// chance of exercising all matching connections in the connection pool. Only returns +// once all requests have completed to make cleaning up server state non-racy. +function check_partition_ids(location) { + const NUM_FETCHES = 20; + + var base_url = 'SUBRESOURCE_PREFIX:&dispatch=check_partition'; + + // Not a perfect parse of the query string, but good enough for this test. + var include_credentials = base_url.search('include_credentials=true') != -1; + var exclude_credentials = base_url.search('include_credentials=false') != -1; + if (include_credentials != !exclude_credentials) + throw new Exception('Credentials mode not specified'); + + + // Run NUM_FETCHES in parallel. + var fetches = []; + for (i = 0; i < NUM_FETCHES; ++i) { + var fetch_params = { + credentials: 'omit', + mode: 'cors', + headers: { + 'Header-To-Force-CORS': 'cors' + }, + }; + + // Use a unique URL for each request, in case the caching layer serializes multiple + // requests for the same URL. + var url = `${base_url}&${token()}`; + + fetches.push(fetch(url, fetch_params).then( + function (response) { + return response.text().then(function(text) { + assert_equals(text, 'ok', `Socket unexpectedly reused`); + }); + })); + } + + // Wait for all promises to complete. + return Promise.allSettled(fetches).then(function (results) { + results.forEach(function (result) { + if (result.status != 'fulfilled') + throw result.reason; + }); + }); +} diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.py b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.py new file mode 100644 index 0000000..32fe499 --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-key.py @@ -0,0 +1,130 @@ +import mimetypes +import os + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +# Test server that tracks the last partition_id was used with each connection for each uuid, and +# lets consumers query if multiple different partition_ids have been been used for any socket. +# +# Server assumes that ports aren't reused, so a client address and a server port uniquely identify +# a connection. If that constraint is ever violated, the test will be flaky. No sockets being +# closed for the duration of the test is sufficient to ensure that, though even if sockets are +# closed, the OS should generally prefer to use new ports for new connections, if any are +# available. +def main(request, response): + response.headers.set(b"Cache-Control", b"no-store") + dispatch = request.GET.first(b"dispatch", None) + uuid = request.GET.first(b"uuid", None) + partition_id = request.GET.first(b"partition_id", None) + + if not uuid or not dispatch or not partition_id: + return simple_response(request, response, 404, b"Not found", b"Invalid query parameters") + + # Unless nocheck_partition is true, check partition_id against server_state, and update server_state. + stash = request.server.stash + test_failed = False + request_count = 0; + connection_count = 0; + if request.GET.first(b"nocheck_partition", None) != b"True": + # Need to grab the lock to access the Stash, since requests are made in parallel. + with stash.lock: + # Don't use server hostname here, since H2 allows multiple hosts to reuse a connection. + # Server IP is not currently available, unfortunately. + address_key = isomorphic_encode(str(request.client_address) + u"|" + str(request.url_parts.port)) + server_state = stash.take(uuid) or {b"test_failed": False, + b"request_count": 0, b"connection_count": 0} + request_count = server_state[b"request_count"] + request_count += 1 + server_state[b"request_count"] = request_count + if address_key in server_state: + if server_state[address_key] != partition_id: + server_state[b"test_failed"] = True + else: + connection_count = server_state[b"connection_count"] + connection_count += 1 + server_state[b"connection_count"] = connection_count + server_state[address_key] = partition_id + test_failed = server_state[b"test_failed"] + stash.put(uuid, server_state) + + origin = request.headers.get(b"Origin") + if origin: + response.headers.set(b"Access-Control-Allow-Origin", origin) + response.headers.set(b"Access-Control-Allow-Credentials", b"true") + + if request.method == u"OPTIONS": + return handle_preflight(request, response) + + if dispatch == b"fetch_file": + return handle_fetch_file(request, response, partition_id, uuid) + + if dispatch == b"check_partition": + status = request.GET.first(b"status", 200) + if test_failed: + return simple_response(request, response, status, b"OK", b"Multiple partition IDs used on a socket") + body = b"ok" + if request.GET.first(b"addcounter", False): + body += (". Request was sent " + str(request_count) + " times. " + + str(connection_count) + " connections were created.").encode('utf-8') + return simple_response(request, response, status, b"OK", body) + + if dispatch == b"clean_up": + stash.take(uuid) + if test_failed: + return simple_response(request, response, 200, b"OK", b"Test failed, but cleanup completed.") + return simple_response(request, response, 200, b"OK", b"cleanup complete") + + return simple_response(request, response, 404, b"Not Found", b"Unrecognized dispatch parameter: " + dispatch) + +def handle_preflight(request, response): + response.status = (200, b"OK") + response.headers.set(b"Access-Control-Allow-Methods", b"GET") + response.headers.set(b"Access-Control-Allow-Headers", b"header-to-force-cors") + response.headers.set(b"Access-Control-Max-Age", b"86400") + return b"Preflight request" + +def simple_response(request, response, status_code, status_message, body, content_type=b"text/plain"): + response.status = (status_code, status_message) + response.headers.set(b"Content-Type", content_type) + return body + +def handle_fetch_file(request, response, partition_id, uuid): + subresource_origin = request.GET.first(b"subresource_origin", None) + rel_path = request.GET.first(b"path", None) + + # This needs to be passed on to subresources so they all have access to it. + include_credentials = request.GET.first(b"include_credentials", None) + if not subresource_origin or not rel_path or not include_credentials: + return simple_response(request, response, 404, b"Not found", b"Invalid query parameters") + + cur_path = os.path.realpath(isomorphic_decode(__file__)) + base_path = os.path.abspath(os.path.join(os.path.dirname(cur_path), os.pardir, os.pardir, os.pardir)) + path = os.path.abspath(os.path.join(base_path, isomorphic_decode(rel_path))) + + # Basic security check. + if not path.startswith(base_path): + return simple_response(request, response, 404, b"Not found", b"Invalid path") + + sandbox = request.GET.first(b"sandbox", None) + if sandbox == b"true": + response.headers.set(b"Content-Security-Policy", b"sandbox allow-scripts") + + file = open(path, mode="rb") + body = file.read() + file.close() + + subresource_path = b"/" + isomorphic_encode(os.path.relpath(isomorphic_decode(__file__), base_path)).replace(b'\\', b'/') + subresource_params = b"?partition_id=" + partition_id + b"&uuid=" + uuid + b"&subresource_origin=" + subresource_origin + b"&include_credentials=" + include_credentials + body = body.replace(b"SUBRESOURCE_PREFIX:", subresource_origin + subresource_path + subresource_params) + + other_origin = request.GET.first(b"other_origin", None) + if other_origin: + body = body.replace(b"OTHER_PREFIX:", other_origin + subresource_path + subresource_params) + + mimetypes.init() + mimetype_pair = mimetypes.guess_type(path) + mimetype = mimetype_pair[0] + + if mimetype == None or mimetype_pair[1] != None: + return simple_response(request, response, 500, b"Server Error", b"Unknown MIME type") + return simple_response(request, response, 200, b"OK", body, mimetype) diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker-checker.html b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker-checker.html new file mode 100644 index 0000000..e6b7ea7 --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker-checker.html @@ -0,0 +1,24 @@ + + + + + Worker Network Partition Checker + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker.js b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker.js new file mode 100644 index 0000000..1745edf --- /dev/null +++ b/test/fixtures/wpt/fetch/connection-pool/resources/network-partition-worker.js @@ -0,0 +1,15 @@ +// This tests the partition key of fetches to subresouce_origin made by the worker and +// imported scripts from subresource_origin. +importScripts('SUBRESOURCE_PREFIX:&dispatch=fetch_file&path=common/utils.js'); +importScripts('SUBRESOURCE_PREFIX:&dispatch=fetch_file&path=resources/testharness.js'); +importScripts('SUBRESOURCE_PREFIX:&dispatch=fetch_file&path=fetch/connection-pool/resources/network-partition-key.js'); + +async function fetch_and_reply() { + try { + await check_partition_ids(); + self.postMessage({result: 'success'}); + } catch (e) { + self.postMessage({result: 'error', details: e.message}); + } +} +fetch_and_reply(); diff --git a/test/fixtures/wpt/fetch/content-encoding/br/bad-br-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/br/bad-br-body.https.any.js new file mode 100644 index 0000000..43ea90a --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/bad-br-body.https.any.js @@ -0,0 +1,12 @@ +// META: global=window + +[ + "arrayBuffer", +].forEach(method => { + promise_test(t => { + return fetch("resources/bad-br-body.py").then(res => { + assert_equals(res.status, 200); + return promise_rejects_js(t, TypeError, res[method]()); + }); + }, "Consuming the body of a resource with bad br content with " + method + "() should reject"); +}); diff --git a/test/fixtures/wpt/fetch/content-encoding/br/big-br-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/br/big-br-body.https.any.js new file mode 100644 index 0000000..1427dd7 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/big-br-body.https.any.js @@ -0,0 +1,55 @@ +// META: global=window,worker + +const EXPECTED_SIZE = 27000000; +const EXPECTED_SHA256 = [ + 74, 100, 37, 243, 147, 61, 116, 60, 241, 221, 126, + 18, 24, 71, 204, 28, 50, 62, 201, 130, 152, 225, + 217, 183, 10, 201, 143, 214, 102, 155, 212, 248, + ]; + +promise_test(async () => { + const response = await fetch('resources/big.text.br'); + assert_true(response.ok); + const arrayBuffer = await response.arrayBuffer(); + assert_equals(arrayBuffer.byteLength, EXPECTED_SIZE, + 'uncompressed size should match'); + const sha256 = await crypto.subtle.digest('SHA-256', arrayBuffer); + assert_array_equals(new Uint8Array(sha256), EXPECTED_SHA256, + 'digest should match'); +}, 'large br data should be decompressed successfully'); + +promise_test(async () => { + const response = await fetch('resources/big.text.br'); + assert_true(response.ok); + const reader = response.body.getReader({mode: 'byob'}); + let offset = 0; + // Pre-allocate space for the output. The response body will be read + // chunk-by-chunk into this array. + let ab = new ArrayBuffer(EXPECTED_SIZE); + while (offset < EXPECTED_SIZE) { + // To stress the data pipe, we want to use a different size read each + // time. Unfortunately, JavaScript doesn't have a seeded random number + // generator, so this creates the possibility of making this test flaky if + // it doesn't work for some edge cases. + let size = Math.floor(Math.random() * 65535 + 1); + if (size + offset > EXPECTED_SIZE) { + size = EXPECTED_SIZE - offset; + } + const u8 = new Uint8Array(ab, offset, size); + const { value, done } = await reader.read(u8); + ab = value.buffer; + // Check that we got our original array back. + assert_equals(ab.byteLength, EXPECTED_SIZE, + 'backing array should be the same size'); + assert_equals(offset, value.byteOffset, 'offset should match'); + assert_less_than_equal(value.byteLength, size, + 'we should not have got more than we asked for'); + offset = value.byteOffset + value.byteLength; + if (done) break; + } + assert_equals(offset, EXPECTED_SIZE, + 'we should have read the whole thing'); + const sha256 = await crypto.subtle.digest('SHA-256', new Uint8Array(ab)); + assert_array_equals(new Uint8Array(sha256), EXPECTED_SHA256, + 'digest should match'); +}, 'large br data should be decompressed successfully with byte stream'); diff --git a/test/fixtures/wpt/fetch/content-encoding/br/br-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/br/br-body.https.any.js new file mode 100644 index 0000000..2c2dbb5 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/br-body.https.any.js @@ -0,0 +1,15 @@ +// META: global=window,worker + +const expectedDecompressedSize = 10500; +[ + "text", + "octetstream" +].forEach(contentType => { + promise_test(async t => { + let response = await fetch(`resources/foo.${contentType}.br`); + assert_true(response.ok); + let arrayBuffer = await response.arrayBuffer() + let u8 = new Uint8Array(arrayBuffer); + assert_equals(u8.length, expectedDecompressedSize); + }, `fetched br data with content type ${contentType} should be decompressed.`); +}); diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/bad-br-body.py b/test/fixtures/wpt/fetch/content-encoding/br/resources/bad-br-body.py new file mode 100644 index 0000000..0710e7f --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/resources/bad-br-body.py @@ -0,0 +1,3 @@ +def main(request, response): + headers = [(b"Content-Encoding", b"br")] + return headers, b"not actually br" diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br b/test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br new file mode 100644 index 0000000000000000000000000000000000000000..b3a530d757de6b5453ddc38411d65522a9e372de GIT binary patch literal 49 zcmV-10M7r<|NnpZW$~B~#Oj^#VW^PL`+p=BrHyqy#FR^IeayAbeIN3de-pn1KpYkT H=+SQi?%x`6 literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br.headers b/test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br.headers new file mode 100644 index 0000000..aba00bd --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/resources/big.text.br.headers @@ -0,0 +1,3 @@ +Content-type: text/plain +Content-Encoding: br +Cache-Control: no-store diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br b/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br new file mode 100644 index 0000000000000000000000000000000000000000..30cb2f7095e15aca510a4a8cbae00f5d35f0918b GIT binary patch literal 15 WcmaDT;c-wu^_$pyR>s{53~T^0T?Hus literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br.headers b/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br.headers new file mode 100644 index 0000000..c0c19bc --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.octetstream.br.headers @@ -0,0 +1,2 @@ +Content-type: application/octet-stream +Content-Encoding: br diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br b/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br new file mode 100644 index 0000000000000000000000000000000000000000..30cb2f7095e15aca510a4a8cbae00f5d35f0918b GIT binary patch literal 15 WcmaDT;c-wu^_$pyR>s{53~T^0T?Hus literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br.headers b/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br.headers new file mode 100644 index 0000000..8c03b82 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/br/resources/foo.text.br.headers @@ -0,0 +1,2 @@ +Content-type: text/plain +Content-Encoding: br diff --git a/test/fixtures/wpt/fetch/content-encoding/gzip/bad-gzip-body.any.js b/test/fixtures/wpt/fetch/content-encoding/gzip/bad-gzip-body.any.js new file mode 100644 index 0000000..17bc126 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/gzip/bad-gzip-body.any.js @@ -0,0 +1,22 @@ +// META: global=window,worker + +promise_test((test) => { + return fetch("resources/bad-gzip-body.py").then(res => { + assert_equals(res.status, 200); + }); +}, "Fetching a resource with bad gzip content should still resolve"); + +[ + "arrayBuffer", + "blob", + "formData", + "json", + "text" +].forEach(method => { + promise_test(t => { + return fetch("resources/bad-gzip-body.py").then(res => { + assert_equals(res.status, 200); + return promise_rejects_js(t, TypeError, res[method]()); + }); + }, "Consuming the body of a resource with bad gzip content with " + method + "() should reject"); +}); diff --git a/test/fixtures/wpt/fetch/content-encoding/gzip/big-gzip-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/gzip/big-gzip-body.https.any.js new file mode 100644 index 0000000..b5d62c9 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/gzip/big-gzip-body.https.any.js @@ -0,0 +1,55 @@ +// META: global=window,worker + +const EXPECTED_SIZE = 27000000; +const EXPECTED_SHA256 = [ + 74, 100, 37, 243, 147, 61, 116, 60, 241, 221, 126, + 18, 24, 71, 204, 28, 50, 62, 201, 130, 152, 225, + 217, 183, 10, 201, 143, 214, 102, 155, 212, 248, + ]; + +promise_test(async () => { + const response = await fetch('resources/big.text.gz'); + assert_true(response.ok); + const arrayBuffer = await response.arrayBuffer(); + assert_equals(arrayBuffer.byteLength, EXPECTED_SIZE, + 'uncompressed size should match'); + const sha256 = await crypto.subtle.digest('SHA-256', arrayBuffer); + assert_array_equals(new Uint8Array(sha256), EXPECTED_SHA256, + 'digest should match'); +}, 'large gzip data should be decompressed successfully'); + +promise_test(async () => { + const response = await fetch('resources/big.text.gz'); + assert_true(response.ok); + const reader = response.body.getReader({mode: 'byob'}); + let offset = 0; + // Pre-allocate space for the output. The response body will be read + // chunk-by-chunk into this array. + let ab = new ArrayBuffer(EXPECTED_SIZE); + while (offset < EXPECTED_SIZE) { + // To stress the data pipe, we want to use a different size read each + // time. Unfortunately, JavaScript doesn't have a seeded random number + // generator, so this creates the possibility of making this test flaky if + // it doesn't work for some edge cases. + let size = Math.floor(Math.random() * 65535 + 1); + if (size + offset > EXPECTED_SIZE) { + size = EXPECTED_SIZE - offset; + } + const u8 = new Uint8Array(ab, offset, size); + const { value, done } = await reader.read(u8); + ab = value.buffer; + // Check that we got our original array back. + assert_equals(ab.byteLength, EXPECTED_SIZE, + 'backing array should be the same size'); + assert_equals(offset, value.byteOffset, 'offset should match'); + assert_less_than_equal(value.byteLength, size, + 'we should not have got more than we asked for'); + offset = value.byteOffset + value.byteLength; + if (done) break; + } + assert_equals(offset, EXPECTED_SIZE, + 'we should have read the whole thing'); + const sha256 = await crypto.subtle.digest('SHA-256', new Uint8Array(ab)); + assert_array_equals(new Uint8Array(sha256), EXPECTED_SHA256, + 'digest should match'); +}, 'large gzip data should be decompressed successfully with byte stream'); diff --git a/test/fixtures/wpt/fetch/content-encoding/gzip/gzip-body.any.js b/test/fixtures/wpt/fetch/content-encoding/gzip/gzip-body.any.js new file mode 100644 index 0000000..37758b7 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/gzip/gzip-body.any.js @@ -0,0 +1,16 @@ +// META: global=window,worker + +const expectedDecompressedSize = 10500; +[ + "text", + "octetstream" +].forEach(contentType => { + promise_test(async t => { + let response = await fetch(`resources/foo.${contentType}.gz`); + assert_true(response.ok); + let arrayBuffer = await response.arrayBuffer() + let u8 = new Uint8Array(arrayBuffer); + assert_equals(u8.length, expectedDecompressedSize); + }, `fetched gzip data with content type ${contentType} should be decompressed.`); +}); + diff --git a/test/fixtures/wpt/fetch/content-encoding/gzip/resources/bad-gzip-body.py b/test/fixtures/wpt/fetch/content-encoding/gzip/resources/bad-gzip-body.py new file mode 100644 index 0000000..a79b94e --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/gzip/resources/bad-gzip-body.py @@ -0,0 +1,3 @@ +def main(request, response): + headers = [(b"Content-Encoding", b"gzip")] + return headers, b"not actually gzip" diff --git a/test/fixtures/wpt/fetch/content-encoding/gzip/resources/big.text.gz b/test/fixtures/wpt/fetch/content-encoding/gzip/resources/big.text.gz new file mode 100644 index 0000000000000000000000000000000000000000..13441bc3998353922eb25ea5b8ef535321b49b6b GIT binary patch literal 65509 zcmeI)JxfAy6bImI8t5X_76b)F;MCj@u3|$Vwgi2Ehz6(7p8Aqh!HLK2dYgd`*(2}wvo z5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!HLK2dYgd`*(2}wvo5|WUFBqSjT zNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!HLK2ej?<71wTVGp_4g`LVnoGsKt7dOok%2xi z1}GpQ2}wvo5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!HLK2dYgd`*(2}wvo z5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!HLK2dYgd`*(2}wvo5|WUFBqSjT zCn;g)bbdBE1~?eK&Rk6LPu@|wHkyrQqmht=BqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8 zAqh!HLK2dYgd`*(2}wvo5|WUFBqSjTNk~Exl8}TXBq0e&NJ0{lkc1>8Aqh!HLK2dY zgd`*(2}wvo5|WUFBqSjTNk~Exl8}Uvgu9#R)c6=+;^%1pD_#r>`AkERfj%$>C?FvT cNk~Exl8}TXBq0g^S;ES6ZElcz{62~O01=->6P`y32&LqcKTK3{a5S}BVr G0|NjdXcEo< literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.text.gz.headers b/test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.text.gz.headers new file mode 100644 index 0000000..7def3dd --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/gzip/resources/foo.text.gz.headers @@ -0,0 +1,2 @@ +Content-type: text/plain +Content-Encoding: gzip diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/bad-zstd-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/zstd/bad-zstd-body.https.any.js new file mode 100644 index 0000000..3f32e4d --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/bad-zstd-body.https.any.js @@ -0,0 +1,22 @@ +// META: global=window,worker + +promise_test((test) => { + return fetch("resources/bad-zstd-body.py").then(res => { + assert_equals(res.status, 200); + }); +}, "Fetching a resource with bad zstd content should still resolve"); + +[ + "arrayBuffer", + "blob", + "formData", + "json", + "text" +].forEach(method => { + promise_test(t => { + return fetch("resources/bad-zstd-body.py").then(res => { + assert_equals(res.status, 200); + return promise_rejects_js(t, TypeError, res[method]()); + }); + }, "Consuming the body of a resource with bad zstd content with " + method + "() should reject"); +}); diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/big-window-zstd-body.tentative.https.any.js b/test/fixtures/wpt/fetch/content-encoding/zstd/big-window-zstd-body.tentative.https.any.js new file mode 100644 index 0000000..c1dc944 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/big-window-zstd-body.tentative.https.any.js @@ -0,0 +1,9 @@ +// META: global=window,worker +// See https://github.com/facebook/zstd/issues/2713 for discussion about +// standardizing window size limits. + +promise_test(async t => { + const response = await fetch('resources/big.window.zst'); + assert_true(response.ok); + await promise_rejects_js(t, TypeError, response.text()); +}, 'Consuming the body of a resource with too large of a zstd window size should reject'); diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/big-zstd-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/zstd/big-zstd-body.https.any.js new file mode 100644 index 0000000..6835f6e --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/big-zstd-body.https.any.js @@ -0,0 +1,55 @@ +// META: global=window,worker + +const EXPECTED_SIZE = 27000000; +const EXPECTED_SHA256 = [ + 74, 100, 37, 243, 147, 61, 116, 60, 241, 221, 126, + 18, 24, 71, 204, 28, 50, 62, 201, 130, 152, 225, + 217, 183, 10, 201, 143, 214, 102, 155, 212, 248, + ]; + +promise_test(async () => { + const response = await fetch('resources/big.text.zst'); + assert_true(response.ok); + const arrayBuffer = await response.arrayBuffer(); + assert_equals(arrayBuffer.byteLength, EXPECTED_SIZE, + 'uncompressed size should match'); + const sha256 = await crypto.subtle.digest('SHA-256', arrayBuffer); + assert_array_equals(new Uint8Array(sha256), EXPECTED_SHA256, + 'digest should match'); +}, 'large zstd data should be decompressed successfully'); + +promise_test(async () => { + const response = await fetch('resources/big.text.zst'); + assert_true(response.ok); + const reader = response.body.getReader({mode: 'byob'}); + let offset = 0; + // Pre-allocate space for the output. The response body will be read + // chunk-by-chunk into this array. + let ab = new ArrayBuffer(EXPECTED_SIZE); + while (offset < EXPECTED_SIZE) { + // To stress the data pipe, we want to use a different size read each + // time. Unfortunately, JavaScript doesn't have a seeded random number + // generator, so this creates the possibility of making this test flaky if + // it doesn't work for some edge cases. + let size = Math.floor(Math.random() * 65535 + 1); + if (size + offset > EXPECTED_SIZE) { + size = EXPECTED_SIZE - offset; + } + const u8 = new Uint8Array(ab, offset, size); + const { value, done } = await reader.read(u8); + ab = value.buffer; + // Check that we got our original array back. + assert_equals(ab.byteLength, EXPECTED_SIZE, + 'backing array should be the same size'); + assert_equals(offset, value.byteOffset, 'offset should match'); + assert_less_than_equal(value.byteLength, size, + 'we should not have got more than we asked for'); + offset = value.byteOffset + value.byteLength; + if (done) break; + } + assert_equals(offset, EXPECTED_SIZE, + 'we should have read the whole thing'); + const sha256 = await crypto.subtle.digest('SHA-256', new Uint8Array(ab)); + assert_array_equals(new Uint8Array(sha256), EXPECTED_SHA256, + 'digest should match'); +}, 'large zstd data should be decompressed successfully with byte stream'); diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/bad-zstd-body.py b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/bad-zstd-body.py new file mode 100644 index 0000000..496f268 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/bad-zstd-body.py @@ -0,0 +1,3 @@ +def main(request, response): + headers = [(b"Content-Encoding", b"zstd")] + return headers, b"not actually zstd" diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.text.zst b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.text.zst new file mode 100644 index 0000000000000000000000000000000000000000..30eda2443f338240772315f18ca29f607cc6e395 GIT binary patch literal 2509 zcmdPcs{gko;=rHTj53T2HxiSQQ&Q8?GcvQXb8_?Y3kr*hOG?YiD=MqF7#X_%SKA%; zVPN3MXJq*E-%@}H#7;tD7a*~dk=TVu>=Yz+5fVEUiCv7uPD5gsAhFYt*riD93?z0L z5<3%#U5>=gLSk1Sv9pobl}PLyBz6@NI~R!!3@do}2*^B!zyFyT6x1EaH3Xy&VUUAf kBXtZ)pq4=s%oV8PA_HoLQ2@2PRzSW%%)nUVzVRpy0RJy1#Q*>R literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.window.zst.headers b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.window.zst.headers new file mode 100644 index 0000000..c5974e1 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/big.window.zst.headers @@ -0,0 +1,2 @@ +Content-type: text/plain +Content-Encoding: zstd diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst new file mode 100644 index 0000000000000000000000000000000000000000..a73bbdd22458db57635bf88e97a4e47b4886e722 GIT binary patch literal 25 hcmdPcs{c2IMI)AhK_V?bpON98$30%g!~5pX2LNk|2}J+^ literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst.headers b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst.headers new file mode 100644 index 0000000..e397816 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.octetstream.zst.headers @@ -0,0 +1,2 @@ +Content-type: application/octet-stream +Content-Encoding: zstd diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst new file mode 100644 index 0000000000000000000000000000000000000000..a73bbdd22458db57635bf88e97a4e47b4886e722 GIT binary patch literal 25 hcmdPcs{c2IMI)AhK_V?bpON98$30%g!~5pX2LNk|2}J+^ literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst.headers b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst.headers new file mode 100644 index 0000000..c5974e1 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/resources/foo.text.zst.headers @@ -0,0 +1,2 @@ +Content-type: text/plain +Content-Encoding: zstd diff --git a/test/fixtures/wpt/fetch/content-encoding/zstd/zstd-body.https.any.js b/test/fixtures/wpt/fetch/content-encoding/zstd/zstd-body.https.any.js new file mode 100644 index 0000000..8692385 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-encoding/zstd/zstd-body.https.any.js @@ -0,0 +1,15 @@ +// META: global=window,worker + +const expectedDecompressedSize = 10500; +[ + "text", + "octetstream" +].forEach(contentType => { + promise_test(async t => { + let response = await fetch(`resources/foo.${contentType}.zst`); + assert_true(response.ok); + let arrayBuffer = await response.arrayBuffer() + let u8 = new Uint8Array(arrayBuffer); + assert_equals(u8.length, expectedDecompressedSize); + }, `fetched zstd data with content type ${contentType} should be decompressed.`); +}); diff --git a/test/fixtures/wpt/fetch/content-length/api-and-duplicate-headers.any.js b/test/fixtures/wpt/fetch/content-length/api-and-duplicate-headers.any.js new file mode 100644 index 0000000..8015289 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/api-and-duplicate-headers.any.js @@ -0,0 +1,23 @@ +promise_test(async t => { + const response = await fetch("resources/identical-duplicates.asis"); + assert_equals(response.statusText, "BLAH"); + assert_equals(response.headers.get("test"), "x, x"); + assert_equals(response.headers.get("content-type"), "text/plain, text/plain"); + assert_equals(response.headers.get("content-length"), "6, 6"); + const text = await response.text(); + assert_equals(text, "Test.\n"); +}, "fetch() and duplicate Content-Length/Content-Type headers"); + +async_test(t => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", "resources/identical-duplicates.asis"); + xhr.send(); + xhr.onload = t.step_func_done(() => { + assert_equals(xhr.statusText, "BLAH"); + assert_equals(xhr.getResponseHeader("test"), "x, x"); + assert_equals(xhr.getResponseHeader("content-type"), "text/plain, text/plain"); + assert_equals(xhr.getResponseHeader("content-length"), "6, 6"); + assert_equals(xhr.getAllResponseHeaders(), "content-length: 6, 6\r\ncontent-type: text/plain, text/plain\r\ntest: x, x\r\n"); + assert_equals(xhr.responseText, "Test.\n"); + }); +}, "XMLHttpRequest and duplicate Content-Length/Content-Type headers"); diff --git a/test/fixtures/wpt/fetch/content-length/content-length.html b/test/fixtures/wpt/fetch/content-length/content-length.html new file mode 100644 index 0000000..cda9b5b --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/content-length.html @@ -0,0 +1,14 @@ + + +Content-Length Test + + + +PASS +but FAIL if this is in the body. \ No newline at end of file diff --git a/test/fixtures/wpt/fetch/content-length/content-length.html.headers b/test/fixtures/wpt/fetch/content-length/content-length.html.headers new file mode 100644 index 0000000..25389b7 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/content-length.html.headers @@ -0,0 +1 @@ +Content-Length: 403 diff --git a/test/fixtures/wpt/fetch/content-length/parsing.window.js b/test/fixtures/wpt/fetch/content-length/parsing.window.js new file mode 100644 index 0000000..5028ad9 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/parsing.window.js @@ -0,0 +1,18 @@ +promise_test(() => { + return fetch("resources/content-lengths.json").then(res => res.json()).then(runTests); +}, "Loading JSON…"); + +function runTests(testUnits) { + testUnits.forEach(({ input, output }) => { + promise_test(t => { + const result = fetch(`resources/content-length.py?length=${encodeURIComponent(input)}`); + if (output === null) { + return promise_rejects_js(t, TypeError, result); + } else { + return result.then(res => res.text()).then(text => { + assert_equals(text.length, output); + }); + } + }, `Input: ${format_value(input)}. Expected: ${output === null ? "network error" : output}.`); + }); +} diff --git a/test/fixtures/wpt/fetch/content-length/resources/content-length.py b/test/fixtures/wpt/fetch/content-length/resources/content-length.py new file mode 100644 index 0000000..92cfade --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/resources/content-length.py @@ -0,0 +1,10 @@ +def main(request, response): + response.add_required_headers = False + output = b"HTTP/1.1 200 OK\r\n" + output += b"Content-Type: text/plain;charset=UTF-8\r\n" + output += b"Connection: close\r\n" + output += request.GET.first(b"length") + b"\r\n" + output += b"\r\n" + output += b"Fact: this is really forty-two bytes long." + response.writer.write(output) + response.close_connection = True diff --git a/test/fixtures/wpt/fetch/content-length/resources/content-lengths.json b/test/fixtures/wpt/fetch/content-length/resources/content-lengths.json new file mode 100644 index 0000000..ac6f1a2 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/resources/content-lengths.json @@ -0,0 +1,142 @@ +[ + { + "input": "Content-Length: 42", + "output": 42 + }, + { + "input": "Content-Length: 42,42", + "output": 42 + }, + { + "input": "Content-Length: 42\r\nContent-Length: 42", + "output": 42 + }, + { + "input": "Content-Length: 42\r\nContent-Length: 42,42", + "output": 42 + }, + { + "input": "Content-Length: 30", + "output": 30 + }, + { + "input": "Content-Length: 30,30", + "output": 30 + }, + { + "input": "Content-Length: 30\r\nContent-Length: 30", + "output": 30 + }, + { + "input": "Content-Length: 30\r\nContent-Length: 30,30", + "output": 30 + }, + { + "input": "Content-Length: 30,30\r\nContent-Length: 30,30", + "output": 30 + }, + { + "input": "Content-Length: 30,30, 30 \r\nContent-Length: 30 ", + "output": 30 + }, + { + "input": "Content-Length: 30,42\r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: 30,42\r\nContent-Length: 30,42", + "output": null + }, + { + "input": "Content-Length: 42,30", + "output": null + }, + { + "input": "Content-Length: 30,42", + "output": null + }, + { + "input": "Content-Length: 42\r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: 30\r\nContent-Length: 42", + "output": null + }, + { + "input": "Content-Length: 30,", + "output": null + }, + { + "input": "Content-Length: ,30", + "output": null + }, + { + "input": "Content-Length: 30\r\nContent-Length: \t", + "output": null + }, + { + "input": "Content-Length: \r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: aaaah\r\nContent-Length: nah", + "output": null + }, + { + "input": "Content-Length: aaaah, nah", + "output": null + }, + { + "input": "Content-Length: aaaah\r\nContent-Length: aaaah", + "output": 42 + }, + { + "input": "Content-Length: aaaah, aaaah", + "output": 42 + }, + { + "input": "Content-Length: aaaah", + "output": 42 + }, + { + "input": "Content-Length: 42s", + "output": 42 + }, + { + "input": "Content-Length: 30s", + "output": 42 + }, + { + "input": "Content-Length: -1", + "output": 42 + }, + { + "input": "Content-Length: 0x20", + "output": 42 + }, + { + "input": "Content-Length: 030", + "output": 30 + }, + { + "input": "Content-Length: 030\r\nContent-Length: 30", + "output": null + }, + { + "input": "Content-Length: 030, 30", + "output": null + }, + { + "input": "Content-Length: \"30\"", + "output": 42 + }, + { + "input": "Content-Length:30\r\nContent-Length:,\r\nContent-Length:30", + "output": null + }, + { + "input": "Content-Length: ", + "output": 42 + } +] diff --git a/test/fixtures/wpt/fetch/content-length/resources/identical-duplicates.asis b/test/fixtures/wpt/fetch/content-length/resources/identical-duplicates.asis new file mode 100644 index 0000000..f38c9a4 --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/resources/identical-duplicates.asis @@ -0,0 +1,9 @@ +HTTP/1.1 200 BLAH +Test: x +Test: x +Content-Type: text/plain +Content-Type: text/plain +Content-Length: 6 +Content-Length: 6 + +Test. diff --git a/test/fixtures/wpt/fetch/content-length/too-long.window.js b/test/fixtures/wpt/fetch/content-length/too-long.window.js new file mode 100644 index 0000000..f8cefaa --- /dev/null +++ b/test/fixtures/wpt/fetch/content-length/too-long.window.js @@ -0,0 +1,4 @@ +promise_test(async t => { + const result = await fetch(`resources/content-length.py?length=${encodeURIComponent("Content-Length: 50")}`); + await promise_rejects_js(t, TypeError, result.text()); +}, "Content-Length header value of network response exceeds response body"); diff --git a/test/fixtures/wpt/fetch/content-type/README.md b/test/fixtures/wpt/fetch/content-type/README.md new file mode 100644 index 0000000..f553b7e --- /dev/null +++ b/test/fixtures/wpt/fetch/content-type/README.md @@ -0,0 +1,20 @@ +# `resources/content-types.json` + +An array of tests. Each test has these fields: + +* `contentType`: an array of values for the `Content-Type` header. A harness needs to run the test twice if there are multiple values. One time with the values concatenated with `,` followed by a space and one time with multiple `Content-Type` declarations, each on their own line with one of the values, in order. +* `encoding`: the expected encoding, null for the default. +* `mimeType`: the result of extracting a MIME type and serializing it. +* `documentContentType`: the MIME type expected to be exposed in DOM documents. + +(These tests are currently somewhat geared towards browser use, but could be generalized easily enough if someone wanted to contribute tests for MIME types that would cause downloads in the browser or some such.) + +# `resources/script-content-types.json` + +An array of tests, surprise. Each test has these fields: + +* `contentType`: see above. +* `executes`: whether the script is expected to execute. +* `encoding`: how the script is expected to be decoded. + +These tests are expected to be loaded through ` + +
+ diff --git a/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html new file mode 100644 index 0000000..a771ed6 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub-ref.html @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html new file mode 100644 index 0000000..82adc47 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html-nosniff.tentative.sub.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub-ref.html b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub-ref.html new file mode 100644 index 0000000..ebb337d --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub-ref.html @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub.html b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub.html new file mode 100644 index 0000000..1ae4cfc --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-png-mislabeled-as-html.sub.html @@ -0,0 +1,10 @@ + + + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html b/test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html new file mode 100644 index 0000000..3219fed --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-empty.sub.html @@ -0,0 +1,7 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html b/test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html new file mode 100644 index 0000000..efcfaa2 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg-doctype-html-mimetype-svg.sub.html @@ -0,0 +1,11 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg-invalid.sub-ref.html b/test/fixtures/wpt/fetch/corb/img-svg-invalid.sub-ref.html new file mode 100644 index 0000000..484cd0a --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg-invalid.sub-ref.html @@ -0,0 +1,5 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg-labeled-as-dash.sub.html b/test/fixtures/wpt/fetch/corb/img-svg-labeled-as-dash.sub.html new file mode 100644 index 0000000..0578b83 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg-labeled-as-dash.sub.html @@ -0,0 +1,6 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg-labeled-as-svg-xml.sub.html b/test/fixtures/wpt/fetch/corb/img-svg-labeled-as-svg-xml.sub.html new file mode 100644 index 0000000..30a2eb3 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg-labeled-as-svg-xml.sub.html @@ -0,0 +1,6 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg-xml-decl.sub.html b/test/fixtures/wpt/fetch/corb/img-svg-xml-decl.sub.html new file mode 100644 index 0000000..0d3aeaf --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg-xml-decl.sub.html @@ -0,0 +1,6 @@ + + + + + diff --git a/test/fixtures/wpt/fetch/corb/img-svg.sub-ref.html b/test/fixtures/wpt/fetch/corb/img-svg.sub-ref.html new file mode 100644 index 0000000..5462f68 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/img-svg.sub-ref.html @@ -0,0 +1,5 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html b/test/fixtures/wpt/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html new file mode 100644 index 0000000..cea80f2 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/preload-image-png-mislabeled-as-html-nosniff.tentative.sub.html @@ -0,0 +1,24 @@ + + + + + +
+ + + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css new file mode 100644 index 0000000..afd2b92 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css @@ -0,0 +1 @@ +#header { color: red; } diff --git a/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers new file mode 100644 index 0000000..0f228f9 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html-nosniff.css.headers @@ -0,0 +1,2 @@ +Content-Type: text/html +X-Content-Type-Options: nosniff diff --git a/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css new file mode 100644 index 0000000..afd2b92 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css @@ -0,0 +1 @@ +#header { color: red; } diff --git a/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css.headers b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/css-mislabeled-as-html.css.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/corb/resources/css-with-json-parser-breaker.css b/test/fixtures/wpt/fetch/corb/resources/css-with-json-parser-breaker.css new file mode 100644 index 0000000..7db6f5c --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/css-with-json-parser-breaker.css @@ -0,0 +1,3 @@ +)]}' +{} +#header { color: red; } diff --git a/test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png b/test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png.headers b/test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png.headers new file mode 100644 index 0000000..e7be84a --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/empty-labeled-as-png.png.headers @@ -0,0 +1 @@ +Content-Type: image/png diff --git a/test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html b/test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html new file mode 100644 index 0000000..7bad71b --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html @@ -0,0 +1,10 @@ + + + + + Page Title + + +

Page body

+ + diff --git a/test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html.headers b/test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/html-correctly-labeled.html.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js new file mode 100644 index 0000000..db45bb4 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js.headers b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot.js.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js new file mode 100644 index 0000000..faae1b7 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js @@ -0,0 +1,10 @@ + diff --git a/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js.headers b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/html-js-polyglot2.js.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js new file mode 100644 index 0000000..a880a5b --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js @@ -0,0 +1 @@ +window.has_executed_script = true; diff --git a/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers new file mode 100644 index 0000000..0f228f9 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html-nosniff.js.headers @@ -0,0 +1,2 @@ +Content-Type: text/html +X-Content-Type-Options: nosniff diff --git a/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js new file mode 100644 index 0000000..a880a5b --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js @@ -0,0 +1 @@ +window.has_executed_script = true; diff --git a/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js.headers b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/js-mislabeled-as-html.js.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png b/test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png.headers b/test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png.headers new file mode 100644 index 0000000..e7be84a --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/png-correctly-labeled.png.headers @@ -0,0 +1 @@ +Content-Type: image/png diff --git a/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png b/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers b/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers new file mode 100644 index 0000000..0f228f9 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html-nosniff.png.headers @@ -0,0 +1,2 @@ +Content-Type: text/html +X-Content-Type-Options: nosniff diff --git a/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png b/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png.headers b/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/png-mislabeled-as-html.png.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/corb/resources/response_block_probe.js b/test/fixtures/wpt/fetch/corb/resources/response_block_probe.js new file mode 100644 index 0000000..9c3b87b --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/response_block_probe.js @@ -0,0 +1 @@ +alert(1); // Arbitrary JavaScript. Details don't matter for the test. diff --git a/test/fixtures/wpt/fetch/corb/resources/response_block_probe.js.headers b/test/fixtures/wpt/fetch/corb/resources/response_block_probe.js.headers new file mode 100644 index 0000000..0d848b0 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/response_block_probe.js.headers @@ -0,0 +1 @@ +Content-Type: text/csv diff --git a/test/fixtures/wpt/fetch/corb/resources/sniffable-resource.py b/test/fixtures/wpt/fetch/corb/resources/sniffable-resource.py new file mode 100644 index 0000000..f815093 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/sniffable-resource.py @@ -0,0 +1,11 @@ +def main(request, response): + body = request.GET.first(b"body", None) + type = request.GET.first(b"type", None) + + response.add_required_headers = False + response.writer.write_status(200) + response.writer.write_header(b"content-length", len(body)) + response.writer.write_header(b"content-type", type) + response.writer.end_headers() + + response.writer.write(body) diff --git a/test/fixtures/wpt/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html b/test/fixtures/wpt/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html new file mode 100644 index 0000000..67b3ad5 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/subframe-that-posts-html-containing-blob-url-to-parent.html @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg new file mode 100644 index 0000000..fa2d29b --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers new file mode 100644 index 0000000..29515ee --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-empty.svg.headers @@ -0,0 +1 @@ +Content-Type: diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg new file mode 100644 index 0000000..fa2d29b --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers new file mode 100644 index 0000000..070de35 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-doctype-html-mimetype-svg.svg.headers @@ -0,0 +1 @@ +Content-Type: image/svg+xml diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg new file mode 100644 index 0000000..2b7d101 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg.headers b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg.headers new file mode 100644 index 0000000..43ce612 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-dash.svg.headers @@ -0,0 +1 @@ +Content-Type: application/dash+xml diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg new file mode 100644 index 0000000..2b7d101 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers new file mode 100644 index 0000000..070de35 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-labeled-as-svg-xml.svg.headers @@ -0,0 +1 @@ +Content-Type: image/svg+xml diff --git a/test/fixtures/wpt/fetch/corb/resources/svg-xml-decl.svg b/test/fixtures/wpt/fetch/corb/resources/svg-xml-decl.svg new file mode 100644 index 0000000..3b39aff --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg-xml-decl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg.svg b/test/fixtures/wpt/fetch/corb/resources/svg.svg new file mode 100644 index 0000000..2b7d101 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/fixtures/wpt/fetch/corb/resources/svg.svg.headers b/test/fixtures/wpt/fetch/corb/resources/svg.svg.headers new file mode 100644 index 0000000..070de35 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/resources/svg.svg.headers @@ -0,0 +1 @@ +Content-Type: image/svg+xml diff --git a/test/fixtures/wpt/fetch/corb/response_block.tentative.https.html b/test/fixtures/wpt/fetch/corb/response_block.tentative.https.html new file mode 100644 index 0000000..6b11600 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/response_block.tentative.https.html @@ -0,0 +1,50 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html b/test/fixtures/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html new file mode 100644 index 0000000..6d1947c --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-html-correctly-labeled.tentative.sub.html @@ -0,0 +1,32 @@ + + + + + +
+ diff --git a/test/fixtures/wpt/fetch/corb/script-html-js-polyglot.sub.html b/test/fixtures/wpt/fetch/corb/script-html-js-polyglot.sub.html new file mode 100644 index 0000000..9a272d6 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-html-js-polyglot.sub.html @@ -0,0 +1,32 @@ + + + + + +
+ diff --git a/test/fixtures/wpt/fetch/corb/script-html-via-cross-origin-blob-url.sub.html b/test/fixtures/wpt/fetch/corb/script-html-via-cross-origin-blob-url.sub.html new file mode 100644 index 0000000..c8a90c7 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-html-via-cross-origin-blob-url.sub.html @@ -0,0 +1,38 @@ + + + + + +
+ diff --git a/test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html b/test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html new file mode 100644 index 0000000..b6bc909 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html-nosniff.sub.html @@ -0,0 +1,33 @@ + + + + + +
+ + + + + + + diff --git a/test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html.sub.html b/test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html.sub.html new file mode 100644 index 0000000..44cb1f8 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-js-mislabeled-as-html.sub.html @@ -0,0 +1,25 @@ + + + + + +
+ + + + + + + diff --git a/test/fixtures/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html b/test/fixtures/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html new file mode 100644 index 0000000..f0eb1f0 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-resource-with-json-parser-breaker.tentative.sub.html @@ -0,0 +1,85 @@ + + + + + +
+ diff --git a/test/fixtures/wpt/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html b/test/fixtures/wpt/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html new file mode 100644 index 0000000..6d490d5 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/script-resource-with-nonsniffable-types.tentative.sub.html @@ -0,0 +1,84 @@ + + + + + + +
+ diff --git a/test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html b/test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html new file mode 100644 index 0000000..8fef0dc --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html-nosniff.sub.html @@ -0,0 +1,42 @@ + + + +CSS is not applied (because of nosniff + non-text/css headers) + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html.sub.html b/test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html.sub.html new file mode 100644 index 0000000..4f0b4c2 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/style-css-mislabeled-as-html.sub.html @@ -0,0 +1,36 @@ + + + +CSS is not applied (because of strict content-type enforcement for cross-origin stylesheets) + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/fixtures/wpt/fetch/corb/style-css-with-json-parser-breaker.sub.html b/test/fixtures/wpt/fetch/corb/style-css-with-json-parser-breaker.sub.html new file mode 100644 index 0000000..29ed586 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/style-css-with-json-parser-breaker.sub.html @@ -0,0 +1,38 @@ + + + +CORB doesn't block a stylesheet that has a proper Content-Type and begins with a JSON parser breaker + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/fixtures/wpt/fetch/corb/style-html-correctly-labeled.sub.html b/test/fixtures/wpt/fetch/corb/style-html-correctly-labeled.sub.html new file mode 100644 index 0000000..cdefcd2 --- /dev/null +++ b/test/fixtures/wpt/fetch/corb/style-html-correctly-labeled.sub.html @@ -0,0 +1,41 @@ + + + +CSS is not applied (because of mismatched Content-Type header) + + + + + + + + + + + +

Header example

+

Paragraph body

+ + + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch-in-iframe.html b/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch-in-iframe.html new file mode 100644 index 0000000..cc6a3a8 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch-in-iframe.html @@ -0,0 +1,67 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.any.js b/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.any.js new file mode 100644 index 0000000..64a7bfe --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.any.js @@ -0,0 +1,76 @@ +// META: timeout=long +// META: global=window,dedicatedworker,sharedworker +// META: script=/common/get-host-info.sub.js + +const host = get_host_info(); +const path = "/fetch/cross-origin-resource-policy/"; +const localBaseURL = host.HTTP_ORIGIN + path; +const sameSiteBaseURL = "http://" + host.ORIGINAL_HOST + ":" + host.HTTP_PORT2 + path; +const notSameSiteBaseURL = host.HTTP_NOTSAMESITE_ORIGIN + path; +const httpsBaseURL = host.HTTPS_ORIGIN + path; + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode : "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-site"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = httpsBaseURL + "resources/hello.py?corp=same-site"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode: "no-cors" })); +}, "Cross-scheme (HTTP to HTTPS) no-cors fetch to a same-site URL with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = httpsBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode : "no-cors" })); +}, "Cross-origin no-cors fetch to a same-site URL with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async (test) => { + const remoteSameSiteURL = sameSiteBaseURL + "resources/hello.py?corp=same-site"; + + await fetch(remoteSameSiteURL, { mode: "no-cors" }); + + return promise_rejects_js(test, TypeError, fetch(sameSiteBaseURL + "resources/hello.py?corp=same-origin", { mode: "no-cors" })); +}, "Valid cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const finalURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch("resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a redirection."); + +promise_test((test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + return fetch(notSameSiteBaseURL + "resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" }); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a cross-origin redirection."); + +promise_test(async (test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + + await fetch(finalURL, { mode: "no-cors" }); + + return promise_rejects_js(test, TypeError, fetch(notSameSiteBaseURL + "resources/redirect.py?corp=same-origin&redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' redirect response header."); diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.https.any.js b/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.https.any.js new file mode 100644 index 0000000..c9b5b75 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/fetch.https.any.js @@ -0,0 +1,56 @@ +// META: timeout=long +// META: global=window,worker +// META: script=/common/get-host-info.sub.js + +const host = get_host_info(); +const path = "/fetch/cross-origin-resource-policy/"; +const localBaseURL = host.HTTPS_ORIGIN + path; +const notSameSiteBaseURL = host.HTTPS_NOTSAMESITE_ORIGIN + path; + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async () => { + const response = await fetch("./resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Same-origin fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-origin"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test(async (test) => { + const response = await fetch(notSameSiteBaseURL + "resources/hello.py?corp=same-site"); + assert_equals(await response.text(), "hello"); +}, "Cross-origin cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode : "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header."); + +promise_test((test) => { + const remoteURL = notSameSiteBaseURL + "resources/hello.py?corp=same-site"; + return promise_rejects_js(test, TypeError, fetch(remoteURL, { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-site' response header."); + +promise_test((test) => { + const finalURL = notSameSiteBaseURL + "resources/hello.py?corp=same-origin"; + return promise_rejects_js(test, TypeError, fetch("resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a redirection."); + +promise_test((test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + return fetch(notSameSiteBaseURL + "resources/redirect.py?redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" }); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' response header after a cross-origin redirection."); + +promise_test(async (test) => { + const finalURL = localBaseURL + "resources/hello.py?corp=same-origin"; + + await fetch(finalURL, { mode: "no-cors" }); + + return promise_rejects_js(test, TypeError, fetch(notSameSiteBaseURL + "resources/redirect.py?corp=same-origin&redirectTo=" + encodeURIComponent(finalURL), { mode: "no-cors" })); +}, "Cross-origin no-cors fetch with a 'Cross-Origin-Resource-Policy: same-origin' redirect response header."); diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/iframe-loads.html b/test/fixtures/wpt/fetch/cross-origin-resource-policy/iframe-loads.html new file mode 100644 index 0000000..63902c3 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/iframe-loads.html @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/image-loads.html b/test/fixtures/wpt/fetch/cross-origin-resource-policy/image-loads.html new file mode 100644 index 0000000..060b755 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/image-loads.html @@ -0,0 +1,54 @@ + + + + + + + + +
+ + + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/green.png b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/green.png new file mode 100644 index 0000000000000000000000000000000000000000..28a1faab37797ef39454aa1deac1b470712f7be4 GIT binary patch literal 87 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z*$P6SW{C@KnNHGWagt#*NXE2F7umZ^C_jGX# j(GX2ekYHV$kio>jw1

The iframe

" + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/iframeFetch.html b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/iframeFetch.html new file mode 100644 index 0000000..2571858 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/iframeFetch.html @@ -0,0 +1,19 @@ + + + + + + +

The iframe making a same origin fetch call.

+ + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/image.py b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/image.py new file mode 100644 index 0000000..2a779cf --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/image.py @@ -0,0 +1,22 @@ +import os.path + +from wptserve.utils import isomorphic_decode + +def main(request, response): + type = request.GET.first(b"type", None) + + body = open(os.path.join(os.path.dirname(isomorphic_decode(__file__)), u"green.png"), u"rb").read() + + response.add_required_headers = False + response.writer.write_status(200) + + if b'corp' in request.GET: + response.writer.write_header(b"cross-origin-resource-policy", request.GET[b'corp']) + if b'acao' in request.GET: + response.writer.write_header(b"access-control-allow-origin", request.GET[b'acao']) + response.writer.write_header(b"content-length", len(body)) + if(type != None): + response.writer.write_header(b"content-type", type) + response.writer.end_headers() + + response.writer.write(body) diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/redirect.py b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/redirect.py new file mode 100644 index 0000000..0dad4dd --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/redirect.py @@ -0,0 +1,6 @@ +def main(request, response): + headers = [(b"Location", request.GET[b'redirectTo'])] + if b'corp' in request.GET: + headers.append((b'Cross-Origin-Resource-Policy', request.GET[b'corp'])) + + return 302, headers, b"" diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/script.py b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/script.py new file mode 100644 index 0000000..58f8d34 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/resources/script.py @@ -0,0 +1,6 @@ +def main(request, response): + headers = [(b"Cross-Origin-Resource-Policy", request.GET[b'corp'])] + if b'origin' in request.headers: + headers.append((b'Access-Control-Allow-Origin', request.headers[b'origin'])) + + return 200, headers, b"" diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.any.js b/test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.any.js new file mode 100644 index 0000000..8f63381 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.any.js @@ -0,0 +1,7 @@ +// META: script=/common/get-host-info.sub.js + +promise_test(t => { + return promise_rejects_js(t, + TypeError, + fetch(get_host_info().HTTPS_REMOTE_ORIGIN + "/fetch/cross-origin-resource-policy/resources/hello.py?corp=same-site", { mode: "no-cors" })); +}, "Cross-Origin-Resource-Policy: same-site blocks retrieving HTTPS from HTTP"); diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js b/test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js new file mode 100644 index 0000000..4c74571 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/scheme-restriction.https.window.js @@ -0,0 +1,13 @@ +// META: script=/common/get-host-info.sub.js + +promise_test(t => { + const img = new Image(); + img.src = get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/cross-origin-resource-policy/resources/image.py?corp=same-site"; + return new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + document.body.appendChild(img); + }).finally(() => { + img.remove(); + }); +}, "Cross-Origin-Resource-Policy does not block Mixed Content "); diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/script-loads.html b/test/fixtures/wpt/fetch/cross-origin-resource-policy/script-loads.html new file mode 100644 index 0000000..a9690fc --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/script-loads.html @@ -0,0 +1,52 @@ + + + + + + + + +
+ + + diff --git a/test/fixtures/wpt/fetch/cross-origin-resource-policy/syntax.any.js b/test/fixtures/wpt/fetch/cross-origin-resource-policy/syntax.any.js new file mode 100644 index 0000000..dc87497 --- /dev/null +++ b/test/fixtures/wpt/fetch/cross-origin-resource-policy/syntax.any.js @@ -0,0 +1,19 @@ +// META: script=/common/get-host-info.sub.js + +const crossOriginURL = get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/cross-origin-resource-policy/resources/hello.py?corp="; + +[ + "same", + "same, same-origin", + "SAME-ORIGIN", + "Same-Origin", + "same-origin, <>", + "same-origin, same-origin", + "https://www.example.com", // See https://github.com/whatwg/fetch/issues/760 +].forEach(incorrectHeaderValue => { + // Note: an incorrect value results in a successful load, so this test is only meaningful in + // implementations with support for the header. + promise_test(t => { + return fetch(crossOriginURL + encodeURIComponent(incorrectHeaderValue), { mode: "no-cors" }); + }, "Parsing Cross-Origin-Resource-Policy: " + incorrectHeaderValue); +}); diff --git a/test/fixtures/wpt/fetch/data-urls/README.md b/test/fixtures/wpt/fetch/data-urls/README.md new file mode 100644 index 0000000..1ce5b18 --- /dev/null +++ b/test/fixtures/wpt/fetch/data-urls/README.md @@ -0,0 +1,11 @@ +## data: URLs + +`resources/data-urls.json` contains `data:` URL tests. The tests are encoded as a JSON array. Each value in the array is an array of two or three values. The first value describes the input, the second value describes the expected MIME type, null if the input is expected to fail somehow, or the empty string if the expected value is `text/plain;charset=US-ASCII`. The third value, if present, describes the expected body as an array of integers representing bytes. + +These tests are used for `data:` URLs in this directory (see `processing.any.js`). + +## Forgiving-base64 decode + +`resources/base64.json` contains [forgiving-base64 decode](https://infra.spec.whatwg.org/#forgiving-base64-decode) tests. The tests are encoded as a JSON array. Each value in the array is an array of two values. The first value describes the input, the second value describes the output as an array of integers representing bytes or null if the input cannot be decoded. + +These tests are used for `data:` URLs in this directory (see `base64.any.js`) and `window.atob()` in `../../html/webappapis/atob/base64.html`. diff --git a/test/fixtures/wpt/fetch/data-urls/base64.any.js b/test/fixtures/wpt/fetch/data-urls/base64.any.js new file mode 100644 index 0000000..83f34db --- /dev/null +++ b/test/fixtures/wpt/fetch/data-urls/base64.any.js @@ -0,0 +1,18 @@ +// META: global=window,worker + +promise_test(() => fetch("resources/base64.json").then(res => res.json()).then(runBase64Tests), "Setup."); +function runBase64Tests(tests) { + for(let i = 0; i < tests.length; i++) { + const input = tests[i][0], + output = tests[i][1], + dataURL = "data:;base64," + input; + promise_test(t => { + if(output === null) { + return promise_rejects_js(t, TypeError, fetch(dataURL)); + } + return fetch(dataURL).then(res => res.arrayBuffer()).then(body => { + assert_array_equals(new Uint8Array(body), output); + }); + }, "data: URL base64 handling: " + format_value(input)); + } +} diff --git a/test/fixtures/wpt/fetch/data-urls/navigate.window.js b/test/fixtures/wpt/fetch/data-urls/navigate.window.js new file mode 100644 index 0000000..b532a00 --- /dev/null +++ b/test/fixtures/wpt/fetch/data-urls/navigate.window.js @@ -0,0 +1,75 @@ +// META: timeout=long +// +// Test some edge cases around navigation to data: URLs to ensure they use the same code path + +[ + { + input: "data:text/html,", + result: 1, + name: "Nothing fancy", + }, + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoMiwgJyonKTwvc2NyaXB0Pg==", + result: 2, + name: "base64", + }, + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNCwgJyonKTwvc2NyaXB0Pr+/", + result: 4, + name: "base64 with code points that differ from base64url" + }, + { + input: "data:text/html;base64,PHNjcml%09%20%20%0A%0C%0DwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNiwgJyonKTwvc2NyaXB0Pg==", + result: 6, + name: "ASCII whitespace in the input is removed" + } +].forEach(({ input, result, name }) => { + // Use promise_test so they go sequentially + promise_test(async t => { + const event = await new Promise((resolve, reject) => { + self.addEventListener("message", t.step_func(resolve), { once: true }); + const frame = document.body.appendChild(document.createElement("iframe")); + t.add_cleanup(() => frame.remove()); + + // The assumption is that postMessage() is quicker + t.step_timeout(reject, 500); + frame.src = input; + }); + assert_equals(event.data, result); + }, name); +}); + +// Failure cases +[ + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoMywgJyonKTwvc2NyaXB0Pg=", + name: "base64 with incorrect padding", + }, + { + input: "data:text/html;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNSwgJyonKTwvc2NyaXB0Pr-_", + name: "base64url is not supported" + }, + { + input: "data:text/html;base64,%0BPHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoNywgJyonKTwvc2NyaXB0Pg==", + name: "Vertical tab in the input leads to an error" + } +].forEach(({ input, name }) => { + // Continue to use promise_test so they go sequentially + promise_test(async t => { + const event = await new Promise((resolve, reject) => { + self.addEventListener("message", t.step_func(reject), { once: true }); + const frame = document.body.appendChild(document.createElement("iframe")); + t.add_cleanup(() => frame.remove()); + + // The assumption is that postMessage() is quicker + t.step_timeout(resolve, 500); + frame.src = input; + }); + }, name); +}); + +// I found some of the interesting code point cases above through brute force: +// +// for (i = 0; i < 256; i++) { +// w(btoa(" + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-no-referrer.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-no-referrer.tentative.https.html new file mode 100644 index 0000000..75e9ece --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-no-referrer.tentative.https.html @@ -0,0 +1,19 @@ + + + +FetchLater Referrer Header: No Referrer Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin-when-cross-origin.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin-when-cross-origin.tentative.https.html new file mode 100644 index 0000000..b9f1417 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin-when-cross-origin.tentative.https.html @@ -0,0 +1,25 @@ + + + +FetchLater Referrer Header: Origin When Cross Origin Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin.tentative.https.html new file mode 100644 index 0000000..ce7abf9 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-origin.tentative.https.html @@ -0,0 +1,23 @@ + + + +FetchLater Referrer Header: Origin Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-same-origin.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-same-origin.tentative.https.html new file mode 100644 index 0000000..264bedd --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-same-origin.tentative.https.html @@ -0,0 +1,24 @@ + + + +FetchLater Referrer Header: Same Origin Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin-when-cross-origin.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin-when-cross-origin.tentative.https.html new file mode 100644 index 0000000..9133f24 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin-when-cross-origin.tentative.https.html @@ -0,0 +1,24 @@ + + + +FetchLater Referrer Header: Strict Origin Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin.tentative.https.html new file mode 100644 index 0000000..943d70b --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-strict-origin.tentative.https.html @@ -0,0 +1,24 @@ + + + +FetchLater Referrer Header: Strict Origin Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-unsafe-url.tentative.https.html b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-unsafe-url.tentative.https.html new file mode 100644 index 0000000..a602e00 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/headers/header-referrer-unsafe-url.tentative.https.html @@ -0,0 +1,24 @@ + + + +FetchLater Referrer Header: Unsafe Url Policy + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/iframe.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/iframe.tentative.https.window.js new file mode 100644 index 0000000..4f26108 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/iframe.tentative.https.window.js @@ -0,0 +1,55 @@ +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js + +'use strict'; + +const { + HTTPS_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +async function loadElement(el) { + const loaded = new Promise(resolve => el.onload = resolve); + document.body.appendChild(el); + await loaded; +} + +// `host` may be cross-origin +async function loadFetchLaterIframe(host, targetUrl) { + const url = `${host}/fetch/fetch-later/resources/fetch-later.html?url=${ + encodeURIComponent(targetUrl)}`; + const iframe = document.createElement('iframe'); + iframe.src = url; + await loadElement(iframe); + return iframe; +} + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Loads a blank iframe that fires a fetchLater request. + const iframe = document.createElement('iframe'); + iframe.addEventListener('load', () => { + fetchLater(url, {activateAfter: 0}); + }); + await loadElement(iframe); + + // The iframe should have sent the request. + await expectBeacon(uuid, {count: 1}); +}, 'A blank iframe can trigger fetchLater.'); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Loads a same-origin iframe that fires a fetchLater request. + await loadFetchLaterIframe(HTTPS_ORIGIN, url); + + // The iframe should have sent the request. + await expectBeacon(uuid, {count: 1}); +}, 'A same-origin iframe can trigger fetchLater.'); + +// The test to load a cross-origin iframe that fires a fetchLater request is in +// /fetch/fetch-later/permissions-policy/deferred-fetch-default-permissions-policy.tentative.https.window.js diff --git a/test/fixtures/wpt/fetch/fetch-later/new-window.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/new-window.tentative.https.window.js new file mode 100644 index 0000000..27922f4 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/new-window.tentative.https.window.js @@ -0,0 +1,75 @@ +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js + +'use strict'; + +const { + HTTPS_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +function fetchLaterPopupUrl(host, targetUrl) { + return `${host}/fetch/fetch-later/resources/fetch-later.html?url=${ + encodeURIComponent(targetUrl)}`; +} + +for (const target of ['', '_blank']) { + for (const features in ['', 'popup', 'popup,noopener']) { + parallelPromiseTest( + async t => { + const uuid = token(); + const url = + generateSetBeaconURL(uuid, {host: HTTPS_NOTSAMESITE_ORIGIN}); + + // Opens a blank popup window that fires a fetchLater request. + const w = window.open( + `javascript: fetchLater("${url}", {activateAfter: 0})`, target, + features); + await new Promise(resolve => w.addEventListener('load', resolve)); + + // The popup should have sent the request. + await expectBeacon(uuid, {count: 1}); + w.close(); + }, + `A blank window[target='${target}'][features='${ + features}'] can trigger fetchLater.`); + + parallelPromiseTest( + async t => { + const uuid = token(); + const popupUrl = + fetchLaterPopupUrl(HTTPS_ORIGIN, generateSetBeaconURL(uuid)); + + // Opens a same-origin popup that fires a fetchLater request. + const w = window.open(popupUrl, target, features); + await new Promise(resolve => w.addEventListener('load', resolve)); + + // The popup should have sent the request. + await expectBeacon(uuid, {count: 1}); + w.close(); + }, + `A same-origin window[target='${target}'][features='${ + features}'] can trigger fetchLater.`); + + parallelPromiseTest( + async t => { + const uuid = token(); + const popupUrl = fetchLaterPopupUrl( + HTTPS_NOTSAMESITE_ORIGIN, generateSetBeaconURL(uuid)); + + // Opens a cross-origin popup that fires a fetchLater request. + const w = window.open(popupUrl, target, features); + // As events from cross-origin window is not accessible, waiting for + // its message instead. + await new Promise( + resolve => window.addEventListener('message', resolve)); + + // The popup should have sent the request. + await expectBeacon(uuid, {count: 1}); + w.close(); + }, + `A cross-origin window[target='${target}'][features='${ + features}'] can trigger fetchLater.`); + } +} diff --git a/test/fixtures/wpt/fetch/fetch-later/non-secure.window.js b/test/fixtures/wpt/fetch/fetch-later/non-secure.window.js new file mode 100644 index 0000000..c13932e --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/non-secure.window.js @@ -0,0 +1,5 @@ +'use strict'; + +test(() => { + assert_false(window.hasOwnProperty('fetchLater')); +}, `fetchLater() is not supported in non-secure context.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/README.md b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/README.md new file mode 100644 index 0000000..a0a724a --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/README.md @@ -0,0 +1,8 @@ +# Permissions Policy: "deferred-fetch" Tests + +This folder contains tests to cover the permissions policy "deferred-fetch", +which is used to gate the `fetchLater()` API. + +The tests follow the patterns from +permissions-policy/README.md to cover all use cases of permissions policy for a +new feature. diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute-redirect.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute-redirect.tentative.https.window.js new file mode 100644 index 0000000..707d6d1 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute-redirect.tentative.https.window.js @@ -0,0 +1,31 @@ +// META: title=Permissions Policy "deferred-fetch" is allowed to redirect by allow attribute +// META: script=/permissions-policy/resources/permissions-policy.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: script=/fetch/fetch-later/permissions-policy/resources/helper.js +// META: timeout=long +'use strict'; + +const { + HTTPS_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +const baseUrl = '/permissions-policy/resources/redirect-on-load.html#'; +const description = 'Permissions policy allow="deferred-fetch"'; + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl(`${baseUrl}${HTTPS_ORIGIN}`), + expect_feature_available_default, /*feature_name=*/ 'deferred-fetch'); +}, `${description} allows same-origin navigation in an iframe.`); + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl( + `${baseUrl}${HTTPS_NOTSAMESITE_ORIGIN}`), + expect_feature_available_default, /*feature_name=*/ 'deferred-fetch'); +}, `${description} allows cross-origin navigation in an iframe.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute.tentative.https.window.js new file mode 100644 index 0000000..c881609 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy-attribute.tentative.https.window.js @@ -0,0 +1,36 @@ +// META: title=Permissions Policy "deferred-fetch" is allowed by allow attribute +// META: script=/permissions-policy/resources/permissions-policy.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: script=/fetch/fetch-later/permissions-policy/resources/helper.js +// META: timeout=long +'use strict'; + +const { + HTTPS_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +const description = 'Permissions policy "deferred-fetch"'; +const attribute = 'allow="deferred-fetch" attribute'; + +async_test( + t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl(HTTPS_ORIGIN), + expect_feature_available_default, /*feature_name=*/ 'deferred-fetch'); + }, + `${description} can be enabled in the same-origin iframe using ${ + attribute}.`); + +async_test( + t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl(HTTPS_NOTSAMESITE_ORIGIN), + expect_feature_available_default, /*feature_name=*/ 'deferred-fetch'); + }, + `${description} can be enabled in the cross-origin iframe using ${ + attribute}.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js new file mode 100644 index 0000000..24628f0 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js @@ -0,0 +1,45 @@ +// META: title=Permissions Policy "deferred-fetch" is allowed +// META: script=/permissions-policy/resources/permissions-policy.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: script=/fetch/fetch-later/permissions-policy/resources/helper.js +// META: timeout=long +'use strict'; + +const { + HTTPS_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +const description = 'Permissions policy header: "deferred-fetch=*"'; + +parallelPromiseTest(async _ => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Request the browser to fetchLater() immediately. + fetchLater(url, {activateAfter: 0}); + + await expectBeacon(uuid, {count: 1}); +}, `${description} allows fetchLater() in the top-level document.`); + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, getDeferredFetchPolicyInIframeHelperUrl(HTTPS_ORIGIN), + expect_feature_available_default); +}, `${description} allows fetchLater() in the same-origin iframe.`); + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl(HTTPS_NOTSAMESITE_ORIGIN), + expect_feature_available_default); +}, `${description} allows fetchLater() in the cross-origin iframe.`); + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl(HTTPS_NOTSAMESITE_ORIGIN), + expect_feature_available_default, /*feature_name=*/ 'deferred-fetch'); +}, `${description} allow="deferred-fetch" allows fetchLater() in the cross-origin iframe.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js.headers b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js.headers new file mode 100644 index 0000000..cd356bd --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-allowed-by-permissions-policy.tentative.https.window.js.headers @@ -0,0 +1 @@ +Permissions-Policy: deferred-fetch=* diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-default-permissions-policy.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-default-permissions-policy.tentative.https.window.js new file mode 100644 index 0000000..a4cc45e --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-default-permissions-policy.tentative.https.window.js @@ -0,0 +1,38 @@ +// META: title=Permissions Policy "deferred-fetch" default behavior +// META: script=/permissions-policy/resources/permissions-policy.js +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: script=/fetch/fetch-later/permissions-policy/resources/helper.js +// META: timeout=long +'use strict'; + +const { + HTTPS_ORIGIN, + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +const description = 'Default "deferred-fetch" permissions policy ["self"]'; + +parallelPromiseTest(async _ => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Request the browser to fetchLater() immediately. + fetchLater(url, {activateAfter: 0}); + + await expectBeacon(uuid, {count: 1}); +}, `${description} allows fetchLater() in the top-level document.`); + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, getDeferredFetchPolicyInIframeHelperUrl(HTTPS_ORIGIN), + expect_feature_available_default); +}, `${description} allows fetchLater() in the same-origin iframe.`); + +async_test(t => { + test_feature_availability( + 'fetchLater()', t, + getDeferredFetchPolicyInIframeHelperUrl(HTTPS_NOTSAMESITE_ORIGIN), + expect_feature_available_default); +}, `${description} allows fetchLater() in the cross-origin iframe.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-supported-by-permissions-policy.tentative.window.js b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-supported-by-permissions-policy.tentative.window.js new file mode 100644 index 0000000..e89f1ed --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/deferred-fetch-supported-by-permissions-policy.tentative.window.js @@ -0,0 +1,8 @@ +// META: title=The feature list should advertise deferred-fetch +'use strict'; + +// https://w3c.github.io/webappsec-permissions-policy/#dom-permissions-policy-features +// https://wicg.github.io/local-fonts/#permissions-policy +test(() => { + assert_in_array('deferred-fetch', document.featurePolicy.features()); +}, 'document.featurePolicy.features should advertise deferred-fetch.'); diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/helper.js b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/helper.js new file mode 100644 index 0000000..5cbb183 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/helper.js @@ -0,0 +1,13 @@ +'use strict'; + +/** + * Returns an URL to a document that can be used to initialize an iframe to test + * whether the "deferred-fetch"policy is enabled. + */ +function getDeferredFetchPolicyInIframeHelperUrl(iframeOrigin) { + if (!iframeOrigin.endsWith('/')) { + iframeOrigin += '/'; + } + return `${ + iframeOrigin}fetch/fetch-later/permissions-policy/resources/permissions-policy-deferred-fetch.html`; +} diff --git a/test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/permissions-policy-deferred-fetch.html b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/permissions-policy-deferred-fetch.html new file mode 100644 index 0000000..f5ce317 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/permissions-policy/resources/permissions-policy-deferred-fetch.html @@ -0,0 +1,14 @@ + + diff --git a/test/fixtures/wpt/fetch/fetch-later/policies/csp-allowed.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/policies/csp-allowed.tentative.https.window.js new file mode 100644 index 0000000..32a3e10 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/policies/csp-allowed.tentative.https.window.js @@ -0,0 +1,26 @@ +// META: title=FetchLater: allowed by CSP +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +'use strict'; + +const { + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +// FetchLater requests allowed by Content Security Policy. +// https://w3c.github.io/webappsec-csp/#should-block-request + +const meta = document.createElement('meta'); +meta.setAttribute('http-equiv', 'Content-Security-Policy'); +meta.setAttribute('content', `connect-src 'self' ${HTTPS_NOTSAMESITE_ORIGIN}`); +document.head.appendChild(meta); + +promise_test(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid, {host: HTTPS_NOTSAMESITE_ORIGIN}); + fetchLater(url, {activateAfter: 0}); + + await expectBeacon(uuid, {count: 1}); + t.done(); +}, 'FetchLater allowed by CSP should succeed'); diff --git a/test/fixtures/wpt/fetch/fetch-later/policies/csp-blocked.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/policies/csp-blocked.tentative.https.window.js new file mode 100644 index 0000000..ca9d881 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/policies/csp-blocked.tentative.https.window.js @@ -0,0 +1,31 @@ +// META: title=FetchLater: blocked by CSP +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +'use strict'; + +const { + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +// FetchLater requests blocked by Content Security Policy are rejected. +// https://w3c.github.io/webappsec-csp/#should-block-request + +const meta = document.createElement('meta'); +meta.setAttribute('http-equiv', 'Content-Security-Policy'); +meta.setAttribute('content', 'connect-src \'self\''); +document.head.appendChild(meta); + +promise_test(async t => { + const uuid = token(); + const cspViolationUrl = + generateSetBeaconURL(uuid, {host: HTTPS_NOTSAMESITE_ORIGIN}); + fetchLater(cspViolationUrl, {activateAfter: 0}); + + await new Promise( + resolve => window.addEventListener('securitypolicyviolation', e => { + assert_equals(e.violatedDirective, 'connect-src'); + resolve(); + })); + t.done(); +}, 'FetchLater blocked by CSP should reject'); diff --git a/test/fixtures/wpt/fetch/fetch-later/policies/csp-redirect-to-blocked.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/policies/csp-redirect-to-blocked.tentative.https.window.js new file mode 100644 index 0000000..584f476 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/policies/csp-redirect-to-blocked.tentative.https.window.js @@ -0,0 +1,33 @@ +// META: title=FetchLater: redirect blocked by CSP +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: timeout=long + +'use strict'; + +const { + HTTPS_NOTSAMESITE_ORIGIN, +} = get_host_info(); + +// FetchLater requests redirect to URL blocked by Content Security Policy. +// https://w3c.github.io/webappsec-csp/#should-block-request + +const meta = document.createElement('meta'); +meta.setAttribute('http-equiv', 'Content-Security-Policy'); +meta.setAttribute('content', 'connect-src \'self\''); +document.head.appendChild(meta); + +promise_test(async t => { + const uuid = token(); + const cspViolationUrl = + generateSetBeaconURL(uuid, {host: HTTPS_NOTSAMESITE_ORIGIN}); + const url = + `/common/redirect.py?location=${encodeURIComponent(cspViolationUrl)}`; + fetchLater(url, {activateAfter: 0}); + + // TODO(crbug.com/1465781): redirect csp check is handled in browser, of which + // result cannot be populated to renderer at this moment. + await expectBeacon(uuid, {count: 0}); + t.done(); +}, 'FetchLater redirect blocked by CSP should reject'); diff --git a/test/fixtures/wpt/fetch/fetch-later/quota.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/quota.tentative.https.window.js new file mode 100644 index 0000000..400be40 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/quota.tentative.https.window.js @@ -0,0 +1,134 @@ +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js + +'use strict'; + +const QUOTA_PER_ORIGIN = 64 * 1024; // 64 kilobytes per spec. +const {ORIGIN, HTTPS_NOTSAMESITE_ORIGIN} = get_host_info(); +const TEST_ENDPOINT = '/fetch-later'; + +// Runs a test case that cover a single fetchLater() call with `body` in its +// request payload. The call is not expected to throw any errors. +function fetchLaterPostTest(body, description) { + test(() => { + const controller = new AbortController(); + const result = fetchLater( + TEST_ENDPOINT, {method: 'POST', signal: controller.signal, body: body}); + assert_false(result.activated); + // Release quota taken by the pending request for subsequent tests. + controller.abort(); + }, description); +} + +// Test small payload for each supported data types. +for (const [dataType, skipCharset] of Object.entries( + BeaconDataTypeToSkipCharset)) { + fetchLaterPostTest( + makeBeaconData(generateSequentialData(0, 1024, skipCharset), dataType), + `A fetchLater() call accept small data in POST request of ${dataType}.`); +} + +// Test various size of payloads for the same origin. + +// Test max possible size of payload. +// Length of absolute URL to the endpoint. +const POST_TEST_REQUEST_URL_SIZE = (ORIGIN + TEST_ENDPOINT).length; +// Total size of the request header. +const POST_TEST_REQUEST_HEADER_SIZE = 36; +// Runs this test only for String type beacon, as browser adds extra bytes to +// body for some other types (FormData & URLSearchParams), and the request +// header sizes varies for every other types. It is difficult to test a request +// right at the quota limit. +fetchLaterPostTest( + // Generates data that is exactly 64 kilobytes. + makeBeaconData( + generatePayload( + QUOTA_PER_ORIGIN - POST_TEST_REQUEST_URL_SIZE - + POST_TEST_REQUEST_HEADER_SIZE), + BeaconDataType.String), + `A single fetchLater() call takes up the per-origin quota for its ` + + `body of String.`); + +// Test empty payload. +for (const dataType in BeaconDataType) { + test( + () => { + assert_throws_js( + TypeError, () => fetchLater('/', {method: 'POST', body: ''})); + }, + `A single fetchLater() call does not accept empty data in POST request ` + + `of ${dataType}.`); +} + +// Test oversized payload. +for (const dataType in BeaconDataType) { + test( + () => { + assert_throws_dom( + 'QuotaExceededError', + () => fetchLater('/fetch-later', { + method: 'POST', + // Generates data that exceeds 64 kilobytes. + body: makeBeaconData( + generatePayload(QUOTA_PER_ORIGIN + 1), dataType) + })); + }, + `A single fetchLater() call is not allowed to exceed per-origin quota ` + + `for its body of ${dataType}.`); +} + +// Test accumulated oversized request. +for (const dataType in BeaconDataType) { + test( + () => { + const controller = new AbortController(); + // Makes the 1st call that sends only half of allowed quota. + fetchLater('/fetch-later', { + method: 'POST', + signal: controller.signal, + body: makeBeaconData(generatePayload(QUOTA_PER_ORIGIN / 2), dataType) + }); + + // Makes the 2nd call that sends half+1 of allowed quota. + assert_throws_dom('QuotaExceededError', () => { + fetchLater('/fetch-later', { + method: 'POST', + signal: controller.signal, + body: makeBeaconData( + generatePayload(QUOTA_PER_ORIGIN / 2 + 1), dataType) + }); + }); + // Release quota taken by the pending requests for subsequent tests. + controller.abort(); + }, + `The 2nd fetchLater() call is not allowed to exceed per-origin quota ` + + `for its body of ${dataType}.`); +} + +// Test various size of payloads across different origins. +for (const dataType in BeaconDataType) { + test( + () => { + const controller = new AbortController(); + // Makes the 1st call that sends only half of allowed quota. + fetchLater('/fetch-later', { + method: 'POST', + signal: controller.signal, + body: makeBeaconData(generatePayload(QUOTA_PER_ORIGIN / 2), dataType) + }); + + // Makes the 2nd call that sends half+1 of allowed quota, but to a + // different origin. + fetchLater(`${HTTPS_NOTSAMESITE_ORIGIN}/fetch-later`, { + method: 'POST', + signal: controller.signal, + body: makeBeaconData( + generatePayload(QUOTA_PER_ORIGIN / 2 + 1), dataType) + }); + // Release quota taken by the pending requests for subsequent tests. + controller.abort(); + }, + `The 2nd fetchLater() call to another origin does not exceed per-origin` + + ` quota for its body of ${dataType}.`); +} diff --git a/test/fixtures/wpt/fetch/fetch-later/resources/fetch-later-helper.js b/test/fixtures/wpt/fetch/fetch-later/resources/fetch-later-helper.js new file mode 100644 index 0000000..566b3e0 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/resources/fetch-later-helper.js @@ -0,0 +1,206 @@ +'use strict'; + +const ROOT_NAME = 'fetch/fetch-later'; + +function parallelPromiseTest(func, description) { + async_test((t) => { + Promise.resolve(func(t)).then(() => t.done()).catch(t.step_func((e) => { + throw e; + })); + }, description); +} + +/** @enum {string} */ +const BeaconDataType = { + String: 'String', + ArrayBuffer: 'ArrayBuffer', + FormData: 'FormData', + URLSearchParams: 'URLSearchParams', + Blob: 'Blob', + File: 'File', +}; + +/** @enum {string} */ +const BeaconDataTypeToSkipCharset = { + String: '', + ArrayBuffer: '', + FormData: '\n\r', // CRLF characters will be normalized by FormData + URLSearchParams: ';,/?:@&=+$', // reserved URI characters + Blob: '', + File: '', +}; + +const BEACON_PAYLOAD_KEY = 'payload'; + +// Creates beacon data of the given `dataType` from `data`. +// @param {string} data - A string representation of the beacon data. Note that +// it cannot contain UTF-16 surrogates for all `BeaconDataType` except BLOB. +// @param {BeaconDataType} dataType - must be one of `BeaconDataType`. +// @param {string} contentType - Request Content-Type. +function makeBeaconData(data, dataType, contentType) { + switch (dataType) { + case BeaconDataType.String: + return data; + case BeaconDataType.ArrayBuffer: + return new TextEncoder().encode(data).buffer; + case BeaconDataType.FormData: + const formData = new FormData(); + if (data.length > 0) { + formData.append(BEACON_PAYLOAD_KEY, data); + } + return formData; + case BeaconDataType.URLSearchParams: + if (data.length > 0) { + return new URLSearchParams(`${BEACON_PAYLOAD_KEY}=${data}`); + } + return new URLSearchParams(); + case BeaconDataType.Blob: { + const options = {type: contentType || undefined}; + return new Blob([data], options); + } + case BeaconDataType.File: { + const options = {type: contentType || 'text/plain'}; + return new File([data], 'file.txt', options); + } + default: + throw Error(`Unsupported beacon dataType: ${dataType}`); + } +} + +// Create a string of `end`-`begin` characters, with characters starting from +// UTF-16 code unit `begin` to `end`-1. +function generateSequentialData(begin, end, skip) { + const codeUnits = Array(end - begin).fill().map((el, i) => i + begin); + if (skip) { + return String.fromCharCode( + ...codeUnits.filter(c => !skip.includes(String.fromCharCode(c)))); + } + return String.fromCharCode(...codeUnits); +} + +function generatePayload(size) { + if (size == 0) { + return ''; + } + const prefix = String(size) + ':'; + if (size < prefix.length) { + return Array(size).fill('*').join(''); + } + if (size == prefix.length) { + return prefix; + } + + return prefix + Array(size - prefix.length).fill('*').join(''); +} + +function generateSetBeaconURL(uuid, options) { + const host = (options && options.host) || ''; + let url = `${host}/${ROOT_NAME}/resources/set_beacon.py?uuid=${uuid}`; + if (options) { + if (options.expectOrigin !== undefined) { + url = `${url}&expectOrigin=${options.expectOrigin}`; + } + if (options.expectPreflight !== undefined) { + url = `${url}&expectPreflight=${options.expectPreflight}`; + } + if (options.expectCredentials !== undefined) { + url = `${url}&expectCredentials=${options.expectCredentials}`; + } + + if (options.useRedirectHandler) { + const redirect = `${host}/common/redirect.py` + + `?location=${encodeURIComponent(url)}`; + url = redirect; + } + } + return url; +} + +async function poll(asyncFunc, expected) { + const maxRetries = 30; + const waitInterval = 100; // milliseconds. + const delay = ms => new Promise(res => setTimeout(res, ms)); + + let result = {data: []}; + for (let i = 0; i < maxRetries; i++) { + result = await asyncFunc(); + if (!expected(result)) { + await delay(waitInterval); + continue; + } + return result; + } + return result; +} + +// Waits until the `options.count` number of beacon data available from the +// server. Defaults to 1. +// If `options.data` is set, it will be used to compare with the data from the +// response. +async function expectBeacon(uuid, options) { + const expectedCount = + (options && options.count !== undefined) ? options.count : 1; + + const res = await poll( + async () => { + const res = await fetch( + `/${ROOT_NAME}/resources/get_beacon.py?uuid=${uuid}`, + {cache: 'no-store'}); + return await res.json(); + }, + (res) => { + if (expectedCount == 0) { + // If expecting no beacon, we should try to wait as long as possible. + // So always returning false here until `poll()` decides to terminate + // itself. + return false; + } + return res.data.length == expectedCount; + }); + if (!options || !options.data) { + assert_equals( + res.data.length, expectedCount, + 'Number of sent beacons does not match expected count:'); + return; + } + + if (expectedCount == 0) { + assert_equals( + res.data.length, 0, + 'Number of sent beacons does not match expected count:'); + return; + } + + const decoder = options && options.percentDecoded ? (s) => { + // application/x-www-form-urlencoded serializer encodes space as '+' + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + s = s.replace(/\+/g, '%20'); + return decodeURIComponent(s); + } : (s) => s; + + assert_equals( + res.data.length, options.data.length, + `The size of beacon data ${ + res.data.length} from server does not match expected value ${ + options.data.length}.`); + for (let i = 0; i < options.data.length; i++) { + assert_equals( + decoder(res.data[i]), options.data[i], + 'The beacon data does not match expected value.'); + } +} + +function generateHTML(script) { + return ``; +} + +// Loads `script` into an iframe and appends it to the current document. +// Returns the loaded iframe element. +async function loadScriptAsIframe(script) { + const iframe = document.createElement('iframe'); + iframe.srcdoc = generateHTML(script); + const iframeLoaded = new Promise(resolve => iframe.onload = resolve); + document.body.appendChild(iframe); + await iframeLoaded; + return iframe; +} diff --git a/test/fixtures/wpt/fetch/fetch-later/resources/fetch-later.html b/test/fixtures/wpt/fetch/fetch-later/resources/fetch-later.html new file mode 100644 index 0000000..b569e1a --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/resources/fetch-later.html @@ -0,0 +1,14 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/fetch-later/resources/get_beacon.py b/test/fixtures/wpt/fetch/fetch-later/resources/get_beacon.py new file mode 100644 index 0000000..32cb9a9 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/resources/get_beacon.py @@ -0,0 +1,30 @@ +"""An HTTP request handler for WPT that handles /get_beacon.py requests.""" + +import json + +_BEACON_ID_KEY = b"uuid" +_BEACON_DATA_PATH = "beacon_data" + + +def main(request, response): + """Retrieves the beacon data keyed by the given uuid from server storage. + + The response content is a JSON string in one of the following formats: + - "{'data': ['abc', null, '123',...]}" + - "{'data': []}" indicates that no data has been set for this uuid. + """ + if _BEACON_ID_KEY not in request.GET: + response.status = 400 + return "Must provide a UUID to store beacon data" + uuid = request.GET.first(_BEACON_ID_KEY) + + with request.server.stash.lock: + body = {'data': []} + data = request.server.stash.take(key=uuid, path=_BEACON_DATA_PATH) + if data: + body['data'] = data + # The stash is read-once/write-once, so it has to be put back after + # reading if `data` is not None. + request.server.stash.put( + key=uuid, value=data, path=_BEACON_DATA_PATH) + return [(b'Content-Type', b'text/plain')], json.dumps(body) diff --git a/test/fixtures/wpt/fetch/fetch-later/resources/header-referrer-helper.js b/test/fixtures/wpt/fetch/fetch-later/resources/header-referrer-helper.js new file mode 100644 index 0000000..3740976 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/resources/header-referrer-helper.js @@ -0,0 +1,39 @@ +'use strict'; + +// https://w3c.github.io/webappsec-referrer-policy/#determine-requests-referrer +const REFERRER_ORIGIN = self.location.origin + '/'; +const REFERRER_URL = self.location.href; + +function testReferrerHeader(id, host, expectedReferrer) { + const url = `${ + host}/beacon/resources/inspect-header.py?header=referer&cmd=put&id=${id}`; + + promise_test(t => { + fetchLater(url, {activateAfter: 0}); + return pollResult(expectedReferrer, id).then(result => { + assert_equals(result, expectedReferrer, 'Correct referrer header result'); + }); + }, `Test referer header ${host}`); +} + +function pollResult(expectedReferrer, id) { + const checkUrl = + `/beacon/resources/inspect-header.py?header=referer&cmd=get&id=${id}`; + + return new Promise(resolve => { + function checkResult() { + fetch(checkUrl).then(response => { + assert_equals( + response.status, 200, 'Inspect header response\'s status is 200'); + let result = response.headers.get('x-request-referer'); + + if (result != undefined) { + resolve(result); + } else { + step_timeout(checkResult.bind(this), 100); + } + }); + } + checkResult(); + }); +} diff --git a/test/fixtures/wpt/fetch/fetch-later/resources/set_beacon.py b/test/fixtures/wpt/fetch/fetch-later/resources/set_beacon.py new file mode 100644 index 0000000..1c71f23 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/resources/set_beacon.py @@ -0,0 +1,83 @@ +"""An HTTP request handler for WPT that handles /set_beacon.py requests.""" + +_BEACON_ID_KEY = b"uuid" +_BEACON_DATA_PATH = "beacon_data" +_BEACON_FORM_PAYLOAD_KEY = b"payload" +_BEACON_BODY_PAYLOAD_KEY = "payload=" +_BEACON_EXPECT_ORIGIN_KEY = b"expectOrigin" +_BEACON_EXPECT_PREFLIGHT_KEY = b"expectPreflight" +_BEACON_EXPECT_CREDS_KEY = b"expectCredentials" + + +def main(request, response): + """Stores the given beacon's data keyed by uuid in the server. + + For GET request, this handler assumes no data. + For POST request, this handler extracts data from request body: + - Content-Type=multipart/form-data: data keyed by 'payload'. + - the entire request body. + + Multiple data can be added for the same uuid. + + The data is stored as UTF-8 format. + """ + if _BEACON_ID_KEY not in request.GET: + response.status = 400 + return "Must provide a UUID to store beacon data" + uuid = request.GET.first(_BEACON_ID_KEY) + + expected_origin = request.GET.get(_BEACON_EXPECT_ORIGIN_KEY) + if b"origin" in request.headers: + origin = request.headers.get(b"origin") + if expected_origin: + assert origin == expected_origin, f"expected {expected_origin}, got {origin}" + response.headers.set(b"Access-Control-Allow-Origin", origin) + else: + assert expected_origin is None, f"expected None, got {expected_origin}" + + # Handles preflight request first. + if request.method == u"OPTIONS": + assert request.GET.get( + _BEACON_EXPECT_PREFLIGHT_KEY) == b"true", "Preflight not expected." + + # preflight must not have cookies. + assert b"Cookie" not in request.headers + + requested_headers = request.headers.get( + b"Access-Control-Request-Headers") + assert b"content-type" in requested_headers, f"expected content-type, got {requested_headers}" + response.headers.set(b"Access-Control-Allow-Headers", b"content-type") + + requested_method = request.headers.get(b"Access-Control-Request-Method") + assert requested_method == b"POST", f"expected POST, got {requested_method}" + response.headers.set(b"Access-Control-Allow-Methods", b"POST") + + return response + + expect_creds = request.GET.get(_BEACON_EXPECT_CREDS_KEY) == b"true" + if expect_creds: + assert b"Cookie" in request.headers + else: + assert b"Cookie" not in request.headers + + data = None + if request.method == u"POST": + if b"multipart/form-data" in request.headers.get(b"Content-Type", b""): + if _BEACON_FORM_PAYLOAD_KEY in request.POST: + data = request.POST.first(_BEACON_FORM_PAYLOAD_KEY).decode( + 'utf-8') + elif request.body: + data = request.body.decode('utf-8') + if data.startswith(_BEACON_BODY_PAYLOAD_KEY): + data = data.split(_BEACON_BODY_PAYLOAD_KEY)[1] + + with request.server.stash.lock: + saved_data = request.server.stash.take(key=uuid, path=_BEACON_DATA_PATH) + if not saved_data: + saved_data = [data] + else: + saved_data.append(data) + request.server.stash.put( + key=uuid, value=saved_data, path=_BEACON_DATA_PATH) + + response.status = 200 diff --git a/test/fixtures/wpt/fetch/fetch-later/send-on-deactivate-with-background-sync.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/send-on-deactivate-with-background-sync.tentative.https.window.js new file mode 100644 index 0000000..881bdd2 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/send-on-deactivate-with-background-sync.tentative.https.window.js @@ -0,0 +1,128 @@ +// META: script=/resources/testdriver.js +// META: script=/resources/testdriver-vendor.js +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js +// META: script=/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js +// META: script=/html/browsers/browsing-the-web/back-forward-cache/resources/rc-helper.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: timeout=long + +'use strict'; + +async function setBackgroundSyncEnabled(enabled) { + const status = enabled ? 'granted' : 'denied'; + await test_driver.set_permission({name: 'background-sync'}, status); +} + +parallelPromiseTest(async t => { + // Enables BackgroundSync permission such that deferred request won't be + // immediately sent out on entering BFCache. + await setBackgroundSyncEnabled(true); + + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // Sets no option to test the default behavior when a document enters BFCache. + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a fetchLater request with default config in remote, which should + // only be sent on page discarded (not on entering BFCache). + await rc1.executeScript(url => { + fetchLater(url); + // Add a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url]); + // Navigates away to let page enter BFCache. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was BFCached. + assert_true(await rc1.executeScript(() => { + return window.pageshowEvent.persisted; + })); + + // By default, pending requests are all flushed on BFCache no matter + // BackgroundSync is on or not. See http://b/310541607#comment28. + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() does send on page entering BFCache even if BackgroundSync is on.`); + +parallelPromiseTest(async t => { + // Enables BackgroundSync permission such that deferred request won't be + // immediately sent out on entering BFCache. + await setBackgroundSyncEnabled(true); + + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // activateAfter = 0s means the request should be sent out right on + // document becoming deactivated (BFCached or frozen) after navigating away. + const options = {activateAfter: 0}; + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a fetchLater request in remote which should only be sent on + // navigating away. + await rc1.executeScript((url, options) => { + fetchLater(url, options); + + // Add a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url, options]); + // Navigates away to trigger request sending. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was BFCached. + assert_true(await rc1.executeScript(() => { + return window.pageshowEvent.persisted; + })); + + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() with activateAfter=0 sends on page entering BFCache if BackgroundSync is on.`); + +parallelPromiseTest(async t => { + // Enables BackgroundSync permission such that deferred request won't be + // immediately sent out on entering BFCache. + await setBackgroundSyncEnabled(true); + + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // activateAfter = 1m means the request should NOT be sent out on + // document becoming deactivated (BFCached or frozen) until after 1 minute. + const options = {activateAfter: 60000}; + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a fetchLater request in remote which should only be sent on + // navigating away. + await rc1.executeScript((url, options) => { + fetchLater(url, options); + + // Adds a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url, options]); + // Navigates away to trigger request sending. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was BFCached. + assert_true(await rc1.executeScript(() => { + return window.pageshowEvent.persisted; + })); + + // By default, pending requests are all flushed on BFCache no matter + // BackgroundSync is on or not. See http://b/310541607#comment28. + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() with activateAfter=1m does send on page entering BFCache even if BackgroundSync is on.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/send-on-deactivate.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/send-on-deactivate.tentative.https.window.js new file mode 100644 index 0000000..3bcf074 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/send-on-deactivate.tentative.https.window.js @@ -0,0 +1,183 @@ +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js +// META: script=/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js +// META: script=/html/browsers/browsing-the-web/back-forward-cache/resources/rc-helper.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js + +'use strict'; + +// NOTE: Due to the restriction of WPT runner, the following tests are all run +// with BackgroundSync off, which is different from some browsers, +// e.g. Chrome, default behavior, as the testing infra does not support enabling +// it. + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // Sets no option to test the default behavior when a document enters BFCache. + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a fetchLater request with default config in remote, which should + // only be sent on page discarded (not on entering BFCache). + await rc1.executeScript(url => { + fetchLater(url); + // Add a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url]); + // Navigates away to let page enter BFCache. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was BFCached. + assert_true(await rc1.executeScript(() => { + return window.pageshowEvent.persisted; + })); + + // Theoretically, the request should still be pending thus 0 request received. + // However, 1 request is sent, as by default the WPT test runner, e.g. + // content_shell in Chromium, does not enable BackgroundSync permission, + // resulting in forcing request sending on every navigation. + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() sends on page entering BFCache if BackgroundSync is off.`); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // When the remote is put into BFCached, creates a fetchLater request w/ + // activateAfter = 0s. It should be sent out immediately. + await rc1.executeScript(url => { + window.addEventListener('pagehide', e => { + if (e.persisted) { + fetchLater(url, {activateAfter: 0}); + } + }); + // Add a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url]); + // Navigates away to trigger request sending. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was BFCached. + assert_true(await rc1.executeScript(() => { + return window.pageshowEvent.persisted; + })); + + // NOTE: In this case, it does not matter if BackgroundSync is on or off. + await expectBeacon(uuid, {count: 1}); +}, `Call fetchLater() when BFCached with activateAfter=0 sends immediately.`); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // Sets no option to test the default behavior when a document gets discarded + // on navigated away. + const helper = new RemoteContextHelper(); + // Opens a window without BFCache. + const rc1 = await helper.addWindow(); + + // Creates a fetchLater request in remote which should only be sent on + // navigating away. + await rc1.executeScript(url => { + fetchLater(url); + // Add a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url]); + // Navigates away to trigger request sending. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was NOT BFCached. + assert_equals(undefined, await rc1.executeScript(() => { + return window.pageshowEvent; + })); + + // NOTE: In this case, it does not matter if BackgroundSync is on or off. + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() sends on navigating away a page w/o BFCache.`); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // Sets no option to test the default behavior when a document gets discarded + // on navigated away. + const helper = new RemoteContextHelper(); + // Opens a window without BFCache. + const rc1 = await helper.addWindow(); + + // Creates 2 fetchLater requests in remote, and one of them is aborted + // immediately. The other one should only be sent right on navigating away. + await rc1.executeScript(url => { + const controller = new AbortController(); + fetchLater(url, {signal: controller.signal}); + fetchLater(url); + controller.abort(); + // Add a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url]); + // Navigates away to trigger request sending. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was NOT BFCached. + assert_equals(undefined, await rc1.executeScript(() => { + return window.pageshowEvent; + })); + + // NOTE: In this case, it does not matter if BackgroundSync is on or off. + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() does not send aborted request on navigating away a page w/o BFCache.`); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const options = {activateAfter: 60000}; + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a fetchLater request in remote which should only be sent on + // navigating away. + await rc1.executeScript((url) => { + // Sets activateAfter = 1m to indicate the request should NOT be sent out + // immediately. + fetchLater(url, {activateAfter: 60000}); + // Adds a pageshow listener to stash the BFCache event. + window.addEventListener('pageshow', e => { + window.pageshowEvent = e; + }); + }, [url]); + // Navigates away to trigger request sending. + const rc2 = await rc1.navigateToNew(); + // Navigates back. + await rc2.historyBack(); + // Verifies the page was BFCached. + assert_true(await rc1.executeScript(() => { + return window.pageshowEvent.persisted; + })); + + // Theoretically, the request should still be pending thus 0 request received. + // However, 1 request is sent, as by default the WPT test runner, e.g. + // content_shell in Chromium, does not enable BackgroundSync permission, + // resulting in forcing request sending on every navigation, even if page is + // put into BFCache. + await expectBeacon(uuid, {count: 1}); +}, `fetchLater() with activateAfter=1m sends on page entering BFCache if BackgroundSync is off.`); diff --git a/test/fixtures/wpt/fetch/fetch-later/send-on-discard/not-send-after-abort.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/send-on-discard/not-send-after-abort.tentative.https.window.js new file mode 100644 index 0000000..6ddafd7 --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/send-on-discard/not-send-after-abort.tentative.https.window.js @@ -0,0 +1,23 @@ +// META: script=/common/utils.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js + +'use strict'; + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Loads an iframe that creates 2 fetchLater requests. One of them is aborted. + const iframe = await loadScriptAsIframe(` + const url = '${url}'; + const controller = new AbortController(); + fetchLater(url, {signal: controller.signal}); + fetchLater(url, {method: 'POST'}); + controller.abort(); + `); + // Delete the iframe to trigger deferred request sending. + document.body.removeChild(iframe); + + // The iframe should not send the aborted request. + await expectBeacon(uuid, {count: 1}); +}, 'A discarded document does not send an already aborted fetchLater request.'); diff --git a/test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple-with-activate-after.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple-with-activate-after.tentative.https.window.js new file mode 100644 index 0000000..0bbe94c --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple-with-activate-after.tentative.https.window.js @@ -0,0 +1,30 @@ +// META: script=/common/utils.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: timeout=long + +'use strict'; + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const numPerMethod = 20; + const total = numPerMethod * 2; + + // Loads an iframe that creates `numPerMethod` GET & POST fetchLater requests. + const iframe = await loadScriptAsIframe(` + const url = '${url}'; + for (let i = 0; i < ${numPerMethod}; i++) { + // Changing the URL of each request to avoid HTTP Cache issue. + // See crbug.com/1498203#c17. + fetchLater(url + "&method=GET&i=" + i, + {method: 'GET', activateAfter: 10000}); // 10s + fetchLater(url + "&method=POST&i=" + i, + {method: 'POST', activateAfter: 8000}); // 8s + } + `); + // Delete the iframe to trigger deferred request sending. + document.body.removeChild(iframe); + + // The iframe should have sent all requests. + await expectBeacon(uuid, {count: total}); +}, 'A discarded document sends all its fetchLater requests, no matter how much their activateAfter timeout remain.'); diff --git a/test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple.tentative.https.window.js b/test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple.tentative.https.window.js new file mode 100644 index 0000000..05bb2dc --- /dev/null +++ b/test/fixtures/wpt/fetch/fetch-later/send-on-discard/send-multiple.tentative.https.window.js @@ -0,0 +1,28 @@ +// META: script=/common/utils.js +// META: script=/fetch/fetch-later/resources/fetch-later-helper.js +// META: timeout=long + +'use strict'; + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const numPerMethod = 20; + const total = numPerMethod * 2; + + // Loads an iframe that creates `numPerMethod` GET & POST fetchLater requests. + const iframe = await loadScriptAsIframe(` + const url = '${url}'; + for (let i = 0; i < ${numPerMethod}; i++) { + // Changing the URL of each request to avoid HTTP Cache issue. + // See crbug.com/1498203#c17. + fetchLater(url + "&method=GET&i=" + i); + fetchLater(url + "&method=POST&i=" + i, {method: 'POST'}); + } + `); + // Delete the iframe to trigger deferred request sending. + document.body.removeChild(iframe); + + // The iframe should have sent all requests. + await expectBeacon(uuid, {count: total}); +}, 'A discarded document sends all its fetchLater requests.'); diff --git a/test/fixtures/wpt/fetch/h1-parsing/README.md b/test/fixtures/wpt/fetch/h1-parsing/README.md new file mode 100644 index 0000000..487a892 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/README.md @@ -0,0 +1,5 @@ +This directory tries to document "rough consensus" on where HTTP/1 parsing should end up between browsers. + +Any tests that browsers currently fail should have associated bug reports. + +[whatwg/fetch issue #1156](https://github.com/whatwg/fetch/issues/1156) provides context for this effort and pointers to the various issues, pull requests, and bug reports that are associated with it. diff --git a/test/fixtures/wpt/fetch/h1-parsing/lone-cr.window.js b/test/fixtures/wpt/fetch/h1-parsing/lone-cr.window.js new file mode 100644 index 0000000..6b46ed6 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/lone-cr.window.js @@ -0,0 +1,23 @@ +// These tests expect that a network error is returned if there's a CR that is not immediately +// followed by LF before reaching message-body. +// +// No browser does this currently, but Firefox does treat it equivalently to a space which gives +// hope. + +[ + "HTTP/1.1\r200 OK\n\nBODY", + "HTTP/1.1 200\rOK\n\nBODY", + "HTTP/1.1 200 OK\n\rHeader: Value\n\nBODY", + "HTTP/1.1 200 OK\nHeader\r: Value\n\nBODY", + "HTTP/1.1 200 OK\nHeader:\r Value\n\nBODY", + "HTTP/1.1 200 OK\nHeader: Value\r\n\nBody", + "HTTP/1.1 200 OK\nHeader: Value\r\r\nBODY", + "HTTP/1.1 200 OK\nHeader: Value\rHeader2: Value2\n\nBODY", + "HTTP/1.1 200 OK\nHeader: Value\n\rBODY", + "HTTP/1.1 200 OK\nHeader: Value\n\r" +].forEach(input => { + promise_test(t => { + const message = encodeURIComponent(input); + return promise_rejects_js(t, TypeError, fetch(`resources/message.py?message=${message}`)); + }, `Parsing response with a lone CR before message-body (${input})`); +}); diff --git a/test/fixtures/wpt/fetch/h1-parsing/resources-with-0x00-in-header.window.js b/test/fixtures/wpt/fetch/h1-parsing/resources-with-0x00-in-header.window.js new file mode 100644 index 0000000..b617911 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/resources-with-0x00-in-header.window.js @@ -0,0 +1,37 @@ +// META: script=/common/get-host-info.sub.js + +async_test(t => { + const script = document.createElement("script"); + t.add_cleanup(() => script.remove()); + script.src = "resources/script-with-0x00-in-header.py"; + script.onerror = t.step_func_done(); + script.onload = t.unreached_func(); + document.body.append(script); +}, "Expect network error for script with 0x00 in a header"); + +async_test(t => { + const frame = document.createElement("iframe"); + t.add_cleanup(() => frame.remove()); + frame.src = "resources/document-with-0x00-in-header.py"; + // If network errors result in load events for frames per + // https://github.com/whatwg/html/issues/125 and https://github.com/whatwg/html/issues/1230 this + // should be changed to use the load event instead. + t.step_timeout(() => { + assert_equals(window.frameLoaded, undefined); + t.done(); + }, 1000); + document.body.append(frame); +}, "Expect network error for frame navigation to resource with 0x00 in a header"); + +async_test(t => { + const img = document.createElement("img"); + t.add_cleanup(() => img.remove()); + img.src = "resources/blue-with-0x00-in-a-header.asis"; + img.onerror = t.step_func_done(); + img.onload = t.unreached_func(); + document.body.append(img); +}, "Expect network error for image with 0x00 in a header"); + +promise_test(async t => { + return promise_rejects_js(t, TypeError, fetch(get_host_info().HTTP_REMOTE_ORIGIN + "/fetch/h1-parsing/resources/blue-with-0x00-in-a-header.asis", {mode:"no-cors"})); +}, "Expect network error for fetch with 0x00 in a header"); diff --git a/test/fixtures/wpt/fetch/h1-parsing/resources/README.md b/test/fixtures/wpt/fetch/h1-parsing/resources/README.md new file mode 100644 index 0000000..2175d27 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/resources/README.md @@ -0,0 +1,6 @@ +`blue-with-0x00-in-a-header.asis` is a copy from `../../images/blue.png` with the following prepended using Control Pictures to signify actual newlines and 0x00: +``` +HTTP/1.1 200 AN IMAGE␍␊ +Content-Type: image/png␍␊ +Custom: ␀␍␊␍␊ +``` diff --git a/test/fixtures/wpt/fetch/h1-parsing/resources/blue-with-0x00-in-a-header.asis b/test/fixtures/wpt/fetch/h1-parsing/resources/blue-with-0x00-in-a-header.asis new file mode 100644 index 0000000000000000000000000000000000000000..102340a6313feb75c1cad7f15b4d5a31e9c67568 GIT binary patch literal 546 zcmeYW2?@|Q)H75tGB8kZ^i%Nkb#!;-<#Nu?D@n~O(G96ANVQVP%uP&B)i20P2TGI{ zm*nSKDKPMI@p5$r___0PNpUeSFz|YMxC8;|Rv^yeU;>h|t5|J-6k~CayA#8@b22Z1 z9F}xPUq=Rpjs4tz5?O(K&H|6fVg?4j!ywFfJby(BP(zici(^Pd+}q2JybKIH%?Ey& z@4fk;(34fbWBc5xJTER9U075YW*fy8W%6&K`)P;nWAThis is a document." diff --git a/test/fixtures/wpt/fetch/h1-parsing/resources/message.py b/test/fixtures/wpt/fetch/h1-parsing/resources/message.py new file mode 100644 index 0000000..640080c --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/resources/message.py @@ -0,0 +1,3 @@ +def main(request, response): + response.writer.write(request.GET.first(b"message")) + response.close_connection = True diff --git a/test/fixtures/wpt/fetch/h1-parsing/resources/script-with-0x00-in-header.py b/test/fixtures/wpt/fetch/h1-parsing/resources/script-with-0x00-in-header.py new file mode 100644 index 0000000..39f58d8 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/resources/script-with-0x00-in-header.py @@ -0,0 +1,4 @@ +def main(request, response): + response.headers.set(b"Content-Type", b"text/javascript") + response.headers.set(b"Custom", b"\0") + return b"var thisIsJavaScript = 0" diff --git a/test/fixtures/wpt/fetch/h1-parsing/resources/status-code.py b/test/fixtures/wpt/fetch/h1-parsing/resources/status-code.py new file mode 100644 index 0000000..5421893 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/resources/status-code.py @@ -0,0 +1,6 @@ +def main(request, response): + output = b"HTTP/1.1 " + output += request.GET.first(b"input") + output += b"\nheader-parsing: is sad\n" + response.writer.write(output) + response.close_connection = True diff --git a/test/fixtures/wpt/fetch/h1-parsing/status-code.window.js b/test/fixtures/wpt/fetch/h1-parsing/status-code.window.js new file mode 100644 index 0000000..5776cf4 --- /dev/null +++ b/test/fixtures/wpt/fetch/h1-parsing/status-code.window.js @@ -0,0 +1,98 @@ +[ + { + input: "", + expected: null + }, + { + input: "BLAH", + expected: null + }, + { + input: "0 OK", + expected: { + status: 0, + statusText: "OK" + } + }, + { + input: "1 OK", + expected: { + status: 1, + statusText: "OK" + } + }, + { + input: "99 NOT OK", + expected: { + status: 99, + statusText: "NOT OK" + } + }, + { + input: "077 77", + expected: { + status: 77, + statusText: "77" + } + }, + { + input: "099 HELLO", + expected: { + status: 99, + statusText: "HELLO" + } + }, + { + input: "200", + expected: { + status: 200, + statusText: "" + } + }, + { + input: "999 DOES IT MATTER", + expected: { + status: 999, + statusText: "DOES IT MATTER" + } + }, + { + input: "1000 BOO", + expected: null + }, + { + input: "0200 BOO", + expected: null + }, + { + input: "65736 NOT 200 OR SOME SUCH", + expected: null + }, + { + input: "131072 HI", + expected: null + }, + { + input: "-200 TEST", + expected: null + }, + { + input: "0xA", + expected: null + }, + { + input: "C8", + expected: null + } +].forEach(({ description, input, expected }) => { + promise_test(async t => { + if (expected !== null) { + const response = await fetch("resources/status-code.py?input=" + input); + assert_equals(response.status, expected.status); + assert_equals(response.statusText, expected.statusText); + assert_equals(response.headers.get("header-parsing"), "is sad"); + } else { + await promise_rejects_js(t, TypeError, fetch("resources/status-code.py?input=" + input)); + } + }, `HTTP/1.1 ${input} ${expected === null ? "(network error)" : ""}`); +}); diff --git a/test/fixtures/wpt/fetch/http-cache/304-update.any.js b/test/fixtures/wpt/fetch/http-cache/304-update.any.js new file mode 100644 index 0000000..15484f0 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/304-update.any.js @@ -0,0 +1,146 @@ +// META: global=window,worker +// META: title=HTTP Cache - 304 Updates +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache updates returned headers from a Last-Modified 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["Last-Modified", -3000], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", -3000], + ["Last-Modified", -3000], + ["Test-Header", "B"] + ], + expected_type: "lm_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "HTTP cache updates stored headers from a Last-Modified 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["Last-Modified", -3000], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", 3000], + ["Last-Modified", -3000], + ["Test-Header", "B"] + ], + expected_type: "lm_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ], + pause_after: true + }, + { + expected_type: "cached", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "HTTP cache updates returned headers from a ETag 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["ETag", "ABC"], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", -3000], + ["ETag", "ABC"], + ["Test-Header", "B"] + ], + expected_type: "etag_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "HTTP cache updates stored headers from a ETag 304", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["ETag", "DEF"], + ["Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", 3000], + ["ETag", "DEF"], + ["Test-Header", "B"] + ], + expected_type: "etag_validated", + expected_response_headers: [ + ["Test-Header", "B"] + ], + pause_after: true + }, + { + expected_type: "cached", + expected_response_headers: [ + ["Test-Header", "B"] + ] + } + ] + }, + { + name: "Content-* header", + requests: [ + { + response_headers: [ + ["Expires", -5000], + ["ETag", "GHI"], + ["Content-Test-Header", "A"] + ] + }, + { + response_headers: [ + ["Expires", 3000], + ["ETag", "GHI"], + ["Content-Test-Header", "B"] + ], + expected_type: "etag_validated", + expected_response_headers: [ + ["Content-Test-Header", "B"] + ], + pause_after: true + }, + { + expected_type: "cached", + expected_response_headers: [ + ["Content-Test-Header", "B"] + ] + } + ] + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/README.md b/test/fixtures/wpt/fetch/http-cache/README.md new file mode 100644 index 0000000..512c422 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/README.md @@ -0,0 +1,72 @@ +## HTTP Caching Tests + +These tests cover HTTP-specified behaviours for caches, primarily from +[RFC9111](https://www.rfc-editor.org/rfc/rfc9111.html), but as seen through the +lens of Fetch. + +A few notes: + +* By its nature, [caching is entirely optional]( + https://www.rfc-editor.org/rfc/rfc9111.html#section-2-2); + some tests expecting a response to be + cached might fail because the client chose not to cache it, or chose to + race the cache with a network request. + +* Likewise, some tests might fail because there is a separate document-level + cache that's not well defined; see [this + issue](https://github.com/whatwg/fetch/issues/354). + +* [Partial content tests](partial.any.js) (a.k.a. Range requests) are not specified + in Fetch; tests are included here for interest only. + +* Some browser caches will behave differently when reloading / + shift-reloading, despite the `cache mode` staying the same. + +* [cache-tests.fyi](https://cache-tests.fyi/) is another test suite of HTTP caching + which also caters to server/CDN implementations. + +## Test Format + +Each test run gets its own URL and randomized content and operates independently. + +Each test is an an array of objects, with the following members: + +- `name` - The name of the test. +- `requests` - a list of request objects (see below). + +Possible members of a request object: + +- template - A template object for the request, by name. +- request_method - A string containing the HTTP method to be used. +- request_headers - An array of `[header_name_string, header_value_string]` arrays to + emit in the request. +- request_body - A string to use as the request body. +- mode - The mode string to pass to `fetch()`. +- credentials - The credentials string to pass to `fetch()`. +- cache - The cache string to pass to `fetch()`. +- pause_after - Boolean controlling a 3-second pause after the request completes. +- response_status - A `[number, string]` array containing the HTTP status code + and phrase to return. +- response_headers - An array of `[header_name_string, header_value_string]` arrays to + emit in the response. These values will also be checked like + expected_response_headers, unless there is a third value that is + `false`. See below for special handling considerations. +- response_body - String to send as the response body. If not set, it will contain + the test identifier. +- expected_type - One of `["cached", "not_cached", "lm_validate", "etag_validate", "error"]` +- expected_status - A number representing a HTTP status code to check the response for. + If not set, the value of `response_status[0]` will be used; if that + is not set, 200 will be used. +- expected_request_headers - An array of `[header_name_string, header_value_string]` representing + headers to check the request for. +- expected_response_headers - An array of `[header_name_string, header_value_string]` representing + headers to check the response for. See also response_headers. +- expected_response_text - A string to check the response body against. If not present, `response_body` will be checked if present and non-null; otherwise the response body will be checked for the test uuid (unless the status code disallows a body). Set to `null` to disable all response body checking. + +Some headers in `response_headers` are treated specially: + +* For date-carrying headers, if the value is a number, it will be interpreted as a delta to the time of the first request at the server. +* For URL-carrying headers, the value will be appended as a query parameter for `target`. + +See the source for exact details. + diff --git a/test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test-ref.html b/test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test-ref.html new file mode 100644 index 0000000..905facd --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test-ref.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test.html b/test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test.html new file mode 100644 index 0000000..a8979ba --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/basic-auth-cache-test.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/http-cache/cache-mode.any.js b/test/fixtures/wpt/fetch/http-cache/cache-mode.any.js new file mode 100644 index 0000000..8f406d5 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/cache-mode.any.js @@ -0,0 +1,61 @@ +// META: global=window,worker +// META: title=Fetch - Cache Mode +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "Fetch sends Cache-Control: max-age=0 when cache mode is no-cache", + requests: [ + { + cache: "no-cache", + expected_request_headers: [['cache-control', 'max-age=0']] + } + ] + }, + { + name: "Fetch doesn't touch Cache-Control when cache mode is no-cache and Cache-Control is already present", + requests: [ + { + cache: "no-cache", + request_headers: [['cache-control', 'foo']], + expected_request_headers: [['cache-control', 'foo']] + } + ] + }, + { + name: "Fetch sends Cache-Control: no-cache and Pragma: no-cache when cache mode is no-store", + requests: [ + { + cache: "no-store", + expected_request_headers: [ + ['cache-control', 'no-cache'], + ['pragma', 'no-cache'] + ] + } + ] + }, + { + name: "Fetch doesn't touch Cache-Control when cache mode is no-store and Cache-Control is already present", + requests: [ + { + cache: "no-store", + request_headers: [['cache-control', 'foo']], + expected_request_headers: [['cache-control', 'foo']] + } + ] + }, + { + name: "Fetch doesn't touch Pragma when cache mode is no-store and Pragma is already present", + requests: [ + { + cache: "no-store", + request_headers: [['pragma', 'foo']], + expected_request_headers: [['pragma', 'foo']] + } + ] + } +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/cc-request.any.js b/test/fixtures/wpt/fetch/http-cache/cc-request.any.js new file mode 100644 index 0000000..d556566 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/cc-request.any.js @@ -0,0 +1,202 @@ +// META: global=window,worker +// META: title=HTTP Cache - Cache-Control Request Directives +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache doesn't use aged but fresh response when request contains Cache-Control: max-age=0", + requests: [ + { + template: "fresh", + pause_after: true + }, + { + request_headers: [ + ["Cache-Control", "max-age=0"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use aged but fresh response when request contains Cache-Control: max-age=1", + requests: [ + { + template: "fresh", + pause_after: true + }, + { + request_headers: [ + ["Cache-Control", "max-age=1"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use fresh response with Age header when request contains Cache-Control: max-age that is greater than remaining freshness", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Age", "1800"] + ] + }, + { + request_headers: [ + ["Cache-Control", "max-age=600"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does use aged stale response when request contains Cache-Control: max-stale that permits its use", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1"] + ], + pause_after: true + }, + { + request_headers: [ + ["Cache-Control", "max-stale=1000"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does reuse stale response with Age header when request contains Cache-Control: max-stale that permits its use", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1500"], + ["Age", "2000"] + ] + }, + { + request_headers: [ + ["Cache-Control", "max-stale=1000"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response when request contains Cache-Control: min-fresh that wants it fresher", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1500"] + ] + }, + { + request_headers: [ + ["Cache-Control", "min-fresh=2000"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response with Age header when request contains Cache-Control: min-fresh that wants it fresher", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1500"], + ["Age", "1000"] + ] + }, + { + request_headers: [ + ["Cache-Control", "min-fresh=1000"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response when request contains Cache-Control: no-cache", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-cache"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache validates fresh response with Last-Modified when request contains Cache-Control: no-cache", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Last-Modified", -10000] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-cache"] + ], + expected_type: "lm_validate" + } + ] + }, + { + name: "HTTP cache validates fresh response with ETag when request contains Cache-Control: no-cache", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["ETag", http_content("abc")] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-cache"] + ], + expected_type: "etag_validate" + } + ] + }, + { + name: "HTTP cache doesn't reuse fresh response when request contains Cache-Control: no-store", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ] + }, + { + request_headers: [ + ["Cache-Control", "no-store"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache generates 504 status code when nothing is in cache and request contains Cache-Control: only-if-cached", + requests: [ + { + request_headers: [ + ["Cache-Control", "only-if-cached"] + ], + expected_status: 504, + expected_response_text: null + } + ] + } +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/credentials.tentative.any.js b/test/fixtures/wpt/fetch/http-cache/credentials.tentative.any.js new file mode 100644 index 0000000..3177092 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/credentials.tentative.any.js @@ -0,0 +1,62 @@ +// META: global=window,worker +// META: title=HTTP Cache - Content +// META: timeout=long +// META: script=/common/utils.js +// META: script=http-cache.js + +// This is a tentative test. +// Firefox behavior is used as expectations. +// +// whatwg/fetch issue: +// https://github.com/whatwg/fetch/issues/1253 +// +// Chrome design doc: +// https://docs.google.com/document/d/1lvbiy4n-GM5I56Ncw304sgvY5Td32R6KHitjRXvkZ6U/edit# + +const request_cacheable = { + request_headers: [], + response_headers: [ + ['Cache-Control', 'max-age=3600'], + ], + // TODO(arthursonzogni): The behavior is tested only for same-origin requests. + // It must behave similarly for cross-site and cross-origin requests. The + // problems is the http-cache.js infrastructure returns the + // "Server-Request-Count" as HTTP response headers, which aren't readable for + // CORS requests. + base_url: location.href.replace(/\/[^\/]*$/, '/'), +}; + +const request_credentialled = { ...request_cacheable, credentials: 'include', }; +const request_anonymous = { ...request_cacheable, credentials: 'omit', }; + +const responseIndex = count => { + return { + expected_response_headers: [ + ['Server-Request-Count', count.toString()], + ], + } +}; + +var tests = [ + { + name: 'same-origin: 2xAnonymous, 2xCredentialled, 1xAnonymous', + requests: [ + { ...request_anonymous , ...responseIndex(1)} , + { ...request_anonymous , ...responseIndex(1)} , + { ...request_credentialled , ...responseIndex(2)} , + { ...request_credentialled , ...responseIndex(2)} , + { ...request_anonymous , ...responseIndex(1)} , + ] + }, + { + name: 'same-origin: 2xCredentialled, 2xAnonymous, 1xCredentialled', + requests: [ + { ...request_credentialled , ...responseIndex(1)} , + { ...request_credentialled , ...responseIndex(1)} , + { ...request_anonymous , ...responseIndex(2)} , + { ...request_anonymous , ...responseIndex(2)} , + { ...request_credentialled , ...responseIndex(1)} , + ] + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/freshness.any.js b/test/fixtures/wpt/fetch/http-cache/freshness.any.js new file mode 100644 index 0000000..86c2620 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/freshness.any.js @@ -0,0 +1,243 @@ +// META: global=window,worker +// META: title=HTTP Cache - Freshness +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + // response directives + { + name: "HTTP cache reuses a response with a future Expires", + requests: [ + { + response_headers: [ + ["Expires", (30 * 24 * 60 * 60)] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with a past Expires", + requests: [ + { + response_headers: [ + ["Expires", (-30 * 24 * 60 * 60)] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with a present Expires", + requests: [ + { + response_headers: [ + ["Expires", 0] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with an invalid Expires", + requests: [ + { + response_headers: [ + ["Expires", "0"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with an invalid Expires with Last-Modified now", + requests: [ + { + response_headers: [ + ["Expires", "0"], + ['Last-Modified', 0] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with an invalid Expires with past Last-Modified", + requests: [ + { + response_headers: [ + ["Expires", "0"], + ['Last-Modified', -100000] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache reuses a response with positive Cache-Control: max-age", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with Cache-Control: max-age=0", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=0"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache reuses a response with positive Cache-Control: max-age and a past Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Expires", -10000] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache reuses a response with positive Cache-Control: max-age and an invalid Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Expires", "0"] + ] + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response with Cache-Control: max-age=0 and a future Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=0"], + ["Expires", 10000] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not prefer Cache-Control: s-maxage over Cache-Control: max-age", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=1, s-maxage=3600"] + ], + pause_after: true, + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not reuse a response when the Age header is greater than its freshness lifetime", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Age", "12000"] + ], + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not store a response with Cache-Control: no-store", + requests: [ + { + response_headers: [ + ["Cache-Control", "no-store"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache does not store a response with Cache-Control: no-store, even with max-age and Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=10000, no-store"], + ["Expires", 10000] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache stores a response with Cache-Control: no-cache, but revalidates upon use", + requests: [ + { + response_headers: [ + ["Cache-Control", "no-cache"], + ["ETag", "abcd"] + ] + }, + { + expected_type: "etag_validated" + } + ] + }, + { + name: "HTTP cache stores a response with Cache-Control: no-cache, but revalidates upon use, even with max-age and Expires", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=10000, no-cache"], + ["Expires", 10000], + ["ETag", "abcd"] + ] + }, + { + expected_type: "etag_validated" + } + ] + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/heuristic.any.js b/test/fixtures/wpt/fetch/http-cache/heuristic.any.js new file mode 100644 index 0000000..d846131 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/heuristic.any.js @@ -0,0 +1,93 @@ +// META: global=window,worker +// META: title=HTTP Cache - Heuristic Freshness +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache reuses an unknown response with Last-Modified based upon heuristic freshness when Cache-Control: public is present", + requests: [ + { + response_status: [299, "Whatever"], + response_headers: [ + ["Last-Modified", (-3 * 100)], + ["Cache-Control", "public"] + ], + }, + { + expected_type: "cached", + response_status: [299, "Whatever"] + } + ] + }, + { + name: "HTTP cache does not reuse an unknown response with Last-Modified based upon heuristic freshness when Cache-Control: public is not present", + requests: [ + { + response_status: [299, "Whatever"], + response_headers: [ + ["Last-Modified", (-3 * 100)] + ], + }, + { + expected_type: "not_cached" + } + ] + } +]; + +function check_status(status) { + var succeed = status[0]; + var code = status[1]; + var phrase = status[2]; + var body = status[3]; + if (body === undefined) { + body = http_content(code); + } + var expected_type = "not_cached"; + var desired = "does not use" + if (succeed === true) { + expected_type = "cached"; + desired = "reuses"; + } + tests.push( + { + name: "HTTP cache " + desired + " a " + code + " " + phrase + " response with Last-Modified based upon heuristic freshness", + requests: [ + { + response_status: [code, phrase], + response_headers: [ + ["Last-Modified", (-3 * 100)] + ], + response_body: body + }, + { + expected_type: expected_type, + response_status: [code, phrase], + response_body: body + } + ] + } + ) +} +[ + [true, 200, "OK"], + [true, 203, "Non-Authoritative Information"], + [true, 204, "No Content", ""], + [true, 404, "Not Found"], + [true, 405, "Method Not Allowed"], + [true, 410, "Gone"], + [true, 414, "URI Too Long"], + [true, 501, "Not Implemented"] +].forEach(check_status); +[ + [false, 201, "Created"], + [false, 202, "Accepted"], + [false, 403, "Forbidden"], + [false, 502, "Bad Gateway"], + [false, 503, "Service Unavailable"], + [false, 504, "Gateway Timeout"], +].forEach(check_status); +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/http-cache.js b/test/fixtures/wpt/fetch/http-cache/http-cache.js new file mode 100644 index 0000000..19f1ca9 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/http-cache.js @@ -0,0 +1,274 @@ +/* global btoa fetch token promise_test step_timeout */ +/* global assert_equals assert_true assert_own_property assert_throws_js assert_less_than */ + +const templates = { + 'fresh': { + 'response_headers': [ + ['Expires', 100000], + ['Last-Modified', 0] + ] + }, + 'stale': { + 'response_headers': [ + ['Expires', -5000], + ['Last-Modified', -100000] + ] + }, + 'lcl_response': { + 'response_headers': [ + ['Location', 'location_target'], + ['Content-Location', 'content_location_target'] + ] + }, + 'location': { + 'query_arg': 'location_target', + 'response_headers': [ + ['Expires', 100000], + ['Last-Modified', 0] + ] + }, + 'content_location': { + 'query_arg': 'content_location_target', + 'response_headers': [ + ['Expires', 100000], + ['Last-Modified', 0] + ] + } +} + +const noBodyStatus = new Set([204, 304]) + +function makeTest (test) { + return function () { + var uuid = token() + var requests = expandTemplates(test) + var fetchFunctions = makeFetchFunctions(requests, uuid) + return runTest(fetchFunctions, requests, uuid) + } +} + +function makeFetchFunctions(requests, uuid) { + var fetchFunctions = [] + for (let i = 0; i < requests.length; ++i) { + fetchFunctions.push({ + code: function (idx) { + var config = requests[idx] + var url = makeTestUrl(uuid, config) + var init = fetchInit(requests, config) + return fetch(url, init) + .then(makeCheckResponse(idx, config)) + .then(makeCheckResponseBody(config, uuid), function (reason) { + if ('expected_type' in config && config.expected_type === 'error') { + assert_throws_js(TypeError, function () { throw reason }) + } else { + throw reason + } + }) + }, + pauseAfter: 'pause_after' in requests[i] + }) + } + return fetchFunctions +} + +function runTest(fetchFunctions, requests, uuid) { + var idx = 0 + function runNextStep () { + if (fetchFunctions.length) { + var nextFetchFunction = fetchFunctions.shift() + if (nextFetchFunction.pauseAfter === true) { + return nextFetchFunction.code(idx++) + .then(pause) + .then(runNextStep) + } else { + return nextFetchFunction.code(idx++) + .then(runNextStep) + } + } else { + return Promise.resolve() + } + } + + return runNextStep() + .then(function () { + return getServerState(uuid) + }).then(function (testState) { + checkRequests(requests, testState) + return Promise.resolve() + }) +} + +function expandTemplates (test) { + var rawRequests = test.requests + var requests = [] + for (let i = 0; i < rawRequests.length; i++) { + var request = rawRequests[i] + request.name = test.name + if ('template' in request) { + var template = templates[request['template']] + for (let member in template) { + if (!request.hasOwnProperty(member)) { + request[member] = template[member] + } + } + } + requests.push(request) + } + return requests +} + +function fetchInit (requests, config) { + var init = { + 'headers': [] + } + if ('request_method' in config) init.method = config['request_method'] + // Note: init.headers must be a copy of config['request_headers'] array, + // because new elements are added later. + if ('request_headers' in config) init.headers = [...config['request_headers']]; + if ('name' in config) init.headers.push(['Test-Name', config.name]) + if ('request_body' in config) init.body = config['request_body'] + if ('mode' in config) init.mode = config['mode'] + if ('credentials' in config) init.credentials = config['credentials'] + if ('cache' in config) init.cache = config['cache'] + init.headers.push(['Test-Requests', btoa(JSON.stringify(requests))]) + return init +} + +function makeCheckResponse (idx, config) { + return function checkResponse (response) { + var reqNum = idx + 1 + var resNum = parseInt(response.headers.get('Server-Request-Count')) + if ('expected_type' in config) { + if (config.expected_type === 'error') { + assert_true(false, `Request ${reqNum} doesn't throw an error`) + return response.text() + } + if (config.expected_type === 'cached') { + assert_less_than(resNum, reqNum, `Response ${reqNum} does not come from cache`) + } + if (config.expected_type === 'not_cached') { + assert_equals(resNum, reqNum, `Response ${reqNum} comes from cache`) + } + } + if ('expected_status' in config) { + assert_equals(response.status, config.expected_status, + `Response ${reqNum} status is ${response.status}, not ${config.expected_status}`) + } else if ('response_status' in config) { + assert_equals(response.status, config.response_status[0], + `Response ${reqNum} status is ${response.status}, not ${config.response_status[0]}`) + } else { + assert_equals(response.status, 200, `Response ${reqNum} status is ${response.status}, not 200`) + } + if ('response_headers' in config) { + config.response_headers.forEach(function (header) { + if (header.len < 3 || header[2] === true) { + assert_equals(response.headers.get(header[0]), header[1], + `Response ${reqNum} header ${header[0]} is "${response.headers.get(header[0])}", not "${header[1]}"`) + } + }) + } + if ('expected_response_headers' in config) { + config.expected_response_headers.forEach(function (header) { + assert_equals(response.headers.get(header[0]), header[1], + `Response ${reqNum} header ${header[0]} is "${response.headers.get(header[0])}", not "${header[1]}"`) + }) + } + return response.text() + } +} + +function makeCheckResponseBody (config, uuid) { + return function checkResponseBody (resBody) { + var statusCode = 200 + if ('response_status' in config) { + statusCode = config.response_status[0] + } + if ('expected_response_text' in config) { + if (config.expected_response_text !== null) { + assert_equals(resBody, config.expected_response_text, + `Response body is "${resBody}", not expected "${config.expected_response_text}"`) + } + } else if ('response_body' in config && config.response_body !== null) { + assert_equals(resBody, config.response_body, + `Response body is "${resBody}", not sent "${config.response_body}"`) + } else if (!noBodyStatus.has(statusCode)) { + assert_equals(resBody, uuid, `Response body is "${resBody}", not default "${uuid}"`) + } + } +} + +function checkRequests (requests, testState) { + var testIdx = 0 + for (let i = 0; i < requests.length; ++i) { + var expectedValidatingHeaders = [] + var config = requests[i] + var serverRequest = testState[testIdx] + var reqNum = i + 1 + if ('expected_type' in config) { + if (config.expected_type === 'cached') continue // the server will not see the request + if (config.expected_type === 'etag_validated') { + expectedValidatingHeaders.push('if-none-match') + } + if (config.expected_type === 'lm_validated') { + expectedValidatingHeaders.push('if-modified-since') + } + } + testIdx++ + expectedValidatingHeaders.forEach(vhdr => { + assert_own_property(serverRequest.request_headers, vhdr, + `request ${reqNum} doesn't have ${vhdr} header`) + }) + if ('expected_request_headers' in config) { + config.expected_request_headers.forEach(expectedHdr => { + assert_equals(serverRequest.request_headers[expectedHdr[0].toLowerCase()], expectedHdr[1], + `request ${reqNum} header ${expectedHdr[0]} value is "${serverRequest.request_headers[expectedHdr[0].toLowerCase()]}", not "${expectedHdr[1]}"`) + }) + } + } +} + +function pause () { + return new Promise(function (resolve, reject) { + step_timeout(function () { + return resolve() + }, 3000) + }) +} + +function makeTestUrl (uuid, config) { + var arg = '' + var base_url = '' + if ('base_url' in config) { + base_url = config.base_url + } + if ('query_arg' in config) { + arg = `&target=${config.query_arg}` + } + return `${base_url}resources/http-cache.py?dispatch=test&uuid=${uuid}${arg}` +} + +function getServerState (uuid) { + return fetch(`resources/http-cache.py?dispatch=state&uuid=${uuid}`) + .then(function (response) { + return response.text() + }).then(function (text) { + return JSON.parse(text) || [] + }) +} + +function run_tests (tests) { + tests.forEach(function (test) { + promise_test(makeTest(test), test.name) + }) +} + +var contentStore = {} +function http_content (csKey) { + if (csKey in contentStore) { + return contentStore[csKey] + } else { + var content = btoa(Math.random() * Date.now()) + contentStore[csKey] = content + return content + } +} diff --git a/test/fixtures/wpt/fetch/http-cache/invalidate.any.js b/test/fixtures/wpt/fetch/http-cache/invalidate.any.js new file mode 100644 index 0000000..9f8090a --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/invalidate.any.js @@ -0,0 +1,235 @@ +// META: global=window,worker +// META: title=HTTP Cache - Invalidation +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: 'HTTP cache invalidates after a successful response from a POST', + requests: [ + { + template: "fresh" + }, { + request_method: "POST", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache does not invalidate after a failed response from an unsafe request', + requests: [ + { + template: "fresh" + }, { + request_method: "POST", + request_body: "abc", + response_status: [500, "Internal Server Error"] + }, { + expected_type: "cached" + } + ] + }, + { + name: 'HTTP cache invalidates after a successful response from a PUT', + requests: [ + { + template: "fresh" + }, { + template: "fresh", + request_method: "PUT", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates after a successful response from a DELETE', + requests: [ + { + template: "fresh" + }, { + request_method: "DELETE", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates after a successful response from an unknown method', + requests: [ + { + template: "fresh" + }, { + request_method: "FOO", + request_body: "abc" + }, { + expected_type: "not_cached" + } + ] + }, + + + { + name: 'HTTP cache invalidates Location URL after a successful response from a POST', + requests: [ + { + template: "location" + }, { + request_method: "POST", + request_body: "abc", + template: "lcl_response" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache does not invalidate Location URL after a failed response from an unsafe request', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "POST", + request_body: "abc", + response_status: [500, "Internal Server Error"] + }, { + template: "location", + expected_type: "cached" + } + ] + }, + { + name: 'HTTP cache invalidates Location URL after a successful response from a PUT', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "PUT", + request_body: "abc" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Location URL after a successful response from a DELETE', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "DELETE", + request_body: "abc" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Location URL after a successful response from an unknown method', + requests: [ + { + template: "location" + }, { + template: "lcl_response", + request_method: "FOO", + request_body: "abc" + }, { + template: "location", + expected_type: "not_cached" + } + ] + }, + + + + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from a POST', + requests: [ + { + template: "content_location" + }, { + request_method: "POST", + request_body: "abc", + template: "lcl_response" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache does not invalidate Content-Location URL after a failed response from an unsafe request', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "POST", + request_body: "abc", + response_status: [500, "Internal Server Error"] + }, { + template: "content_location", + expected_type: "cached" + } + ] + }, + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from a PUT', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "PUT", + request_body: "abc" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from a DELETE', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "DELETE", + request_body: "abc" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + }, + { + name: 'HTTP cache invalidates Content-Location URL after a successful response from an unknown method', + requests: [ + { + template: "content_location" + }, { + template: "lcl_response", + request_method: "FOO", + request_body: "abc" + }, { + template: "content_location", + expected_type: "not_cached" + } + ] + } + +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/partial.any.js b/test/fixtures/wpt/fetch/http-cache/partial.any.js new file mode 100644 index 0000000..3f23b59 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/partial.any.js @@ -0,0 +1,208 @@ +// META: global=window,worker +// META: title=HTTP Cache - Partial Content +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache stores partial content and reuses it", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234", + expected_request_headers: [ + ["Range", "bytes=-5"] + ] + }, + { + request_headers: [ + ["Range", "bytes=-5"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "01234" + } + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it (byte-range-spec)", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ], + response_body: "01234567890" + }, + { + request_headers: [ + ['Range', "bytes=0-1"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "01" + }, + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it (absent last-byte-pos)", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ], + response_body: "01234567890" + }, + { + request_headers: [ + ['Range', "bytes=1-"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "1234567890" + } + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it (suffix-byte-range-spec)", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"], + ], + response_body: "0123456789A" + }, + { + request_headers: [ + ['Range', "bytes=-1"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "A" + } + ] + }, + { + name: "HTTP cache stores complete response and serves smaller ranges from it with only-if-cached", + requests: [ + { + response_headers: [ + ["Cache-Control", "max-age=3600"] + ], + response_body: "01234567890" + }, + { + request_headers: [ + ['Range', "bytes=0-1"] + ], + mode: "same-origin", + cache: "only-if-cached", + expected_type: "cached", + expected_status: 206, + expected_response_text: "01" + }, + ] + }, + { + name: "HTTP cache stores partial response and serves smaller ranges from it (byte-range-spec)", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234" + }, + { + request_headers: [ + ['Range', "bytes=6-8"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "234" + } + ] + }, + { + name: "HTTP cache stores partial response and serves smaller ranges from it (absent last-byte-pos)", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234" + }, + { + request_headers: [ + ["Range", "bytes=6-"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "234" + } + ] + }, + { + name: "HTTP cache stores partial response and serves smaller ranges from it (suffix-byte-range-spec)", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 4-9/10"] + ], + response_body: "01234" + }, + { + request_headers: [ + ['Range', "bytes=-1"] + ], + expected_type: "cached", + expected_status: 206, + expected_response_text: "4" + } + ] + }, + { + name: "HTTP cache stores partial content and completes it", + requests: [ + { + request_headers: [ + ['Range', "bytes=-5"] + ], + response_status: [206, "Partial Content"], + response_headers: [ + ["Cache-Control", "max-age=3600"], + ["Content-Range", "bytes 0-4/10"] + ], + response_body: "01234" + }, + { + expected_request_headers: [ + ["range", "bytes=5-"] + ] + } + ] + }, +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/post-patch.any.js b/test/fixtures/wpt/fetch/http-cache/post-patch.any.js new file mode 100644 index 0000000..0a69baa --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/post-patch.any.js @@ -0,0 +1,46 @@ +// META: global=window,worker +// META: title=HTTP Cache - Caching POST and PATCH responses +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache uses content after PATCH request with response containing Content-Location and cache-allowing header", + requests: [ + { + request_method: "PATCH", + request_body: "abc", + response_status: [200, "OK"], + response_headers: [ + ['Cache-Control', "private, max-age=1000"], + ['Content-Location', ""] + ], + response_body: "abc" + }, + { + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache uses content after POST request with response containing Content-Location and cache-allowing header", + requests: [ + { + request_method: "POST", + request_body: "abc", + response_status: [200, "OK"], + response_headers: [ + ['Cache-Control', "private, max-age=1000"], + ['Content-Location', ""] + ], + response_body: "abc" + }, + { + expected_type: "cached" + } + ] + } +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/resources/http-cache.py b/test/fixtures/wpt/fetch/http-cache/resources/http-cache.py new file mode 100644 index 0000000..3ab610d --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/resources/http-cache.py @@ -0,0 +1,124 @@ +import datetime +import json +import time +from base64 import b64decode + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +NOTEHDRS = set([u'content-type', u'access-control-allow-origin', u'last-modified', u'etag']) +NOBODYSTATUS = set([204, 304]) +LOCATIONHDRS = set([u'location', u'content-location']) +DATEHDRS = set([u'date', u'expires', u'last-modified']) + +def main(request, response): + dispatch = request.GET.first(b"dispatch", None) + uuid = request.GET.first(b"uuid", None) + response.headers.set(b"Access-Control-Allow-Credentials", b"true") + + if request.method == u"OPTIONS": + return handle_preflight(uuid, request, response) + if not uuid: + response.status = (404, b"Not Found") + response.headers.set(b"Content-Type", b"text/plain") + return b"UUID not found" + if dispatch == b'test': + return handle_test(uuid, request, response) + elif dispatch == b'state': + return handle_state(uuid, request, response) + response.status = (404, b"Not Found") + response.headers.set(b"Content-Type", b"text/plain") + return b"Fallthrough" + +def handle_preflight(uuid, request, response): + response.status = (200, b"OK") + response.headers.set(b"Access-Control-Allow-Origin", request.headers.get(b"origin") or '*') + response.headers.set(b"Access-Control-Allow-Methods", b"GET") + response.headers.set(b"Access-Control-Allow-Headers", request.headers.get(b"Access-Control-Request-Headers") or "*") + response.headers.set(b"Access-Control-Max-Age", b"86400") + return b"Preflight request" + +def handle_state(uuid, request, response): + response.headers.set(b"Content-Type", b"text/plain") + return json.dumps(request.server.stash.take(uuid)) + +def handle_test(uuid, request, response): + server_state = request.server.stash.take(uuid) or [] + try: + requests = json.loads(b64decode(request.headers.get(b'Test-Requests', b""))) + except: + response.status = (400, b"Bad Request") + response.headers.set(b"Content-Type", b"text/plain") + return b"No or bad Test-Requests request header" + config = requests[len(server_state)] + if not config: + response.status = (404, b"Not Found") + response.headers.set(b"Content-Type", b"text/plain") + return b"Config not found" + noted_headers = {} + now = time.time() + for header in config.get(u'response_headers', []): + if header[0].lower() in LOCATIONHDRS: # magic locations + if (len(header[1]) > 0): + header[1] = u"%s&target=%s" % (request.url, header[1]) + else: + header[1] = request.url + if header[0].lower() in DATEHDRS and isinstance(header[1], int): # magic dates + header[1] = http_date(now, header[1]) + response.headers.set(isomorphic_encode(header[0]), isomorphic_encode(header[1])) + if header[0].lower() in NOTEHDRS: + noted_headers[header[0].lower()] = header[1] + state = { + u'now': now, + u'request_method': request.method, + u'request_headers': dict([[isomorphic_decode(h.lower()), isomorphic_decode(request.headers[h])] for h in request.headers]), + u'response_headers': noted_headers + } + server_state.append(state) + request.server.stash.put(uuid, server_state) + + if u"access-control-allow-origin" not in noted_headers: + response.headers.set(b"Access-Control-Allow-Origin", b"*") + if u"content-type" not in noted_headers: + response.headers.set(b"Content-Type", b"text/plain") + response.headers.set(b"Server-Request-Count", len(server_state)) + + code, phrase = config.get(u"response_status", [200, b"OK"]) + if config.get(u"expected_type", u"").endswith(u'validated'): + ref_hdrs = server_state[0][u'response_headers'] + previous_lm = ref_hdrs.get(u'last-modified', False) + if previous_lm and request.headers.get(b"If-Modified-Since", False) == isomorphic_encode(previous_lm): + code, phrase = [304, b"Not Modified"] + previous_etag = ref_hdrs.get(u'etag', False) + if previous_etag and request.headers.get(b"If-None-Match", False) == isomorphic_encode(previous_etag): + code, phrase = [304, b"Not Modified"] + if code != 304: + code, phrase = [999, b'304 Not Generated'] + response.status = (code, phrase) + + content = config.get(u"response_body", uuid) + if code in NOBODYSTATUS: + return b"" + return content + + +def get_header(headers, header_name): + result = None + for header in headers: + if header[0].lower() == header_name.lower(): + result = header[1] + return result + +WEEKDAYS = [u'Mon', u'Tue', u'Wed', u'Thu', u'Fri', u'Sat', u'Sun'] +MONTHS = [None, u'Jan', u'Feb', u'Mar', u'Apr', u'May', u'Jun', u'Jul', + u'Aug', u'Sep', u'Oct', u'Nov', u'Dec'] + +def http_date(now, delta_secs=0): + date = datetime.datetime.utcfromtimestamp(now + delta_secs) + return u"%s, %.2d %s %.4d %.2d:%.2d:%.2d GMT" % ( + WEEKDAYS[date.weekday()], + date.day, + MONTHS[date.month], + date.year, + date.hour, + date.minute, + date.second) diff --git a/test/fixtures/wpt/fetch/http-cache/resources/securedimage.py b/test/fixtures/wpt/fetch/http-cache/resources/securedimage.py new file mode 100644 index 0000000..cac9cfe --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/resources/securedimage.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 - + +from wptserve.utils import isomorphic_decode, isomorphic_encode + +def main(request, response): + image_url = str.replace(request.url, u"fetch/http-cache/resources/securedimage.py", u"images/green.png") + + if b"authorization" not in request.headers: + response.status = 401 + response.headers.set(b"WWW-Authenticate", b"Basic") + return + else: + auth = request.headers.get(b"Authorization") + if auth != b"Basic dGVzdHVzZXI6dGVzdHBhc3M=": + response.set_error(403, u"Invalid username or password - " + isomorphic_decode(auth)) + return + + response.status = 301 + response.headers.set(b"Location", isomorphic_encode(image_url)) diff --git a/test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup-with-iframe.html b/test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup-with-iframe.html new file mode 100644 index 0000000..48b1618 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup-with-iframe.html @@ -0,0 +1,34 @@ + + + + + HTTP Cache - helper + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup.html b/test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup.html new file mode 100644 index 0000000..edb5794 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/resources/split-cache-popup.html @@ -0,0 +1,28 @@ + + + + + HTTP Cache - helper + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/http-cache/split-cache.html b/test/fixtures/wpt/fetch/http-cache/split-cache.html new file mode 100644 index 0000000..fe93d2e --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/split-cache.html @@ -0,0 +1,158 @@ + + + + + HTTP Cache - Partioning by site + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/http-cache/status.any.js b/test/fixtures/wpt/fetch/http-cache/status.any.js new file mode 100644 index 0000000..10c83a2 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/status.any.js @@ -0,0 +1,60 @@ +// META: global=window,worker +// META: title=HTTP Cache - Status Codes +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = []; +function check_status(status) { + var code = status[0]; + var phrase = status[1]; + var body = status[2]; + if (body === undefined) { + body = http_content(code); + } + tests.push({ + name: "HTTP cache goes to the network if it has a stale " + code + " response", + requests: [ + { + template: "stale", + response_status: [code, phrase], + response_body: body + }, { + expected_type: "not_cached", + response_status: [code, phrase], + response_body: body + } + ] + }) + tests.push({ + name: "HTTP cache avoids going to the network if it has a fresh " + code + " response", + requests: [ + { + template: "fresh", + response_status: [code, phrase], + response_body: body + }, { + expected_type: "cached", + response_status: [code, phrase], + response_body: body + } + ] + }) +} +[ + [200, "OK"], + [203, "Non-Authoritative Information"], + [204, "No Content", null], + [299, "Whatever"], + [400, "Bad Request"], + [404, "Not Found"], + [410, "Gone"], + [499, "Whatever"], + [500, "Internal Server Error"], + [502, "Bad Gateway"], + [503, "Service Unavailable"], + [504, "Gateway Timeout"], + [599, "Whatever"] +].forEach(check_status); +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/http-cache/vary.any.js b/test/fixtures/wpt/fetch/http-cache/vary.any.js new file mode 100644 index 0000000..2cfd226 --- /dev/null +++ b/test/fixtures/wpt/fetch/http-cache/vary.any.js @@ -0,0 +1,313 @@ +// META: global=window,worker +// META: title=HTTP Cache - Vary +// META: timeout=long +// META: script=/common/utils.js +// META: script=/common/get-host-info.sub.js +// META: script=http-cache.js + +var tests = [ + { + name: "HTTP cache reuses Vary response when request matches", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ] + }, + { + request_headers: [ + ["Foo", "1"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use Vary response when request doesn't match", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ] + }, + { + request_headers: [ + ["Foo", "2"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use Vary response when request omits variant header", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't invalidate existing Vary response", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ], + response_body: http_content('foo_1') + }, + { + request_headers: [ + ["Foo", "2"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ], + expected_type: "not_cached", + response_body: http_content('foo_2'), + }, + { + request_headers: [ + ["Foo", "1"] + ], + response_body: http_content('foo_1'), + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't pay attention to headers not listed in Vary", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Other", "2"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo"] + ], + }, + { + request_headers: [ + ["Foo", "1"], + ["Other", "3"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache reuses two-way Vary response when request matches", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use two-way Vary response when request doesn't match", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar"] + ] + }, + { + request_headers: [ + ["Foo", "2"], + ["Bar", "abc"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use two-way Vary response when request omits variant header", + requests: [ + { + request_headers: [ + ["Foo", "1"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar"] + ] + }, + { + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache reuses three-way Vary response when request matches", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use three-way Vary response when request doesn't match", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "2"], + ["Bar", "abc"], + ["Baz", "789"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache doesn't use three-way Vary response when request doesn't match, regardless of header order", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc4"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Bar", "abc"], + ["Baz", "789"] + ], + expected_type: "not_cached" + } + ] + }, + { + name: "HTTP cache uses three-way Vary response when both request and the original request omited a variant header", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "Foo, Bar, Baz"] + ] + }, + { + request_headers: [ + ["Foo", "1"], + ["Baz", "789"] + ], + expected_type: "cached" + } + ] + }, + { + name: "HTTP cache doesn't use Vary response with a field value of '*'", + requests: [ + { + request_headers: [ + ["Foo", "1"], + ["Baz", "789"] + ], + response_headers: [ + ["Expires", 5000], + ["Last-Modified", -3000], + ["Vary", "*"] + ] + }, + { + request_headers: [ + ["*", "1"], + ["Baz", "789"] + ], + expected_type: "not_cached" + } + ] + } +]; +run_tests(tests); diff --git a/test/fixtures/wpt/fetch/images/canvas-remote-read-remote-image-redirect.html b/test/fixtures/wpt/fetch/images/canvas-remote-read-remote-image-redirect.html new file mode 100644 index 0000000..4a887f3 --- /dev/null +++ b/test/fixtures/wpt/fetch/images/canvas-remote-read-remote-image-redirect.html @@ -0,0 +1,28 @@ + + +Load a no-cors image from a same-origin URL that redirects to a cross-origin URL that redirects to the initial origin + + + + diff --git a/test/fixtures/wpt/fetch/metadata/META.yml b/test/fixtures/wpt/fetch/metadata/META.yml new file mode 100644 index 0000000..85f0a7d --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/META.yml @@ -0,0 +1,4 @@ +spec: https://w3c.github.io/webappsec-fetch-metadata/ +suggested_reviewers: + - mikewest + - iVanlIsh diff --git a/test/fixtures/wpt/fetch/metadata/README.md b/test/fixtures/wpt/fetch/metadata/README.md new file mode 100644 index 0000000..34864d4 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/README.md @@ -0,0 +1,9 @@ +Fetch Metadata Tests +==================== + +This directory contains tests related to the Fetch Metadata proposal: + +: Explainer +:: +: "Spec" +:: diff --git a/test/fixtures/wpt/fetch/metadata/WEB_FEATURES.yml b/test/fixtures/wpt/fetch/metadata/WEB_FEATURES.yml new file mode 100644 index 0000000..fb48eaa --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: fetch-metadata + files: "**" diff --git a/test/fixtures/wpt/fetch/metadata/audio-worklet.https.html b/test/fixtures/wpt/fetch/metadata/audio-worklet.https.html new file mode 100644 index 0000000..3b768ef --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/audio-worklet.https.html @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/embed.https.sub.tentative.html b/test/fixtures/wpt/fetch/metadata/embed.https.sub.tentative.html new file mode 100644 index 0000000..1900dbd --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/embed.https.sub.tentative.html @@ -0,0 +1,63 @@ + + + + + + + + + +
+ + diff --git a/test/fixtures/wpt/fetch/metadata/fetch-preflight.https.sub.any.js b/test/fixtures/wpt/fetch/metadata/fetch-preflight.https.sub.any.js new file mode 100644 index 0000000..d524743 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/fetch-preflight.https.sub.any.js @@ -0,0 +1,29 @@ +// META: global=window,worker +// META: script=/fetch/metadata/resources/helper.js + +// Site +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", + { + mode: "cors", + headers: { 'x-test': 'testing' } + }, { + "site": "same-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Same-site fetch with preflight"); +}, "Same-site fetch with preflight"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[alt][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", + { + mode: "cors", + headers: { 'x-test': 'testing' } + }, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Cross-site fetch with preflight"); +}, "Cross-site fetch with preflight"); diff --git a/test/fixtures/wpt/fetch/metadata/fetch.https.sub.any.js b/test/fixtures/wpt/fetch/metadata/fetch.https.sub.any.js new file mode 100644 index 0000000..aeec5cd --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/fetch.https.sub.any.js @@ -0,0 +1,58 @@ +// META: global=window,worker +// META: script=/fetch/metadata/resources/helper.js + +// Site +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "same-origin", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Same-origin fetch"); +}, "Same-origin fetch"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "same-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Same-site fetch"); +}, "Same-site fetch"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[alt][www]}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Cross-site fetch"); +}, "Cross-site fetch"); + +// Mode +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {mode: "same-origin"}, { + "site": "same-origin", + "user": "", + "mode": "same-origin", + "dest": "empty" + }, "Same-origin mode"); +}, "Same-origin mode"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {mode: "cors"}, { + "site": "same-origin", + "user": "", + "mode": "cors", + "dest": "empty" + }, "CORS mode"); +}, "CORS mode"); + +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {mode: "no-cors"}, { + "site": "same-origin", + "user": "", + "mode": "no-cors", + "dest": "empty" + }, "no-CORS mode"); +}, "no-CORS mode"); diff --git a/test/fixtures/wpt/fetch/metadata/generated/audioworklet.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/audioworklet.https.sub.html new file mode 100644 index 0000000..5ea1cdc --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/audioworklet.https.sub.html @@ -0,0 +1,297 @@ + + + + + HTTP headers on request for AudioWorklet module + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/css-font-face.https.sub.tentative.html b/test/fixtures/wpt/fetch/metadata/generated/css-font-face.https.sub.tentative.html new file mode 100644 index 0000000..481355c --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/css-font-face.https.sub.tentative.html @@ -0,0 +1,250 @@ + + + + + HTTP headers on request for CSS font-face + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/css-font-face.sub.tentative.html b/test/fixtures/wpt/fetch/metadata/generated/css-font-face.sub.tentative.html new file mode 100644 index 0000000..a0d2a06 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/css-font-face.sub.tentative.html @@ -0,0 +1,226 @@ + + + + + HTTP headers on request for CSS font-face + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/css-images.https.sub.tentative.html b/test/fixtures/wpt/fetch/metadata/generated/css-images.https.sub.tentative.html new file mode 100644 index 0000000..63dcb4b --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/css-images.https.sub.tentative.html @@ -0,0 +1,1529 @@ + + + + + + HTTP headers on request for CSS image-accepting properties + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/css-images.sub.tentative.html b/test/fixtures/wpt/fetch/metadata/generated/css-images.sub.tentative.html new file mode 100644 index 0000000..2f6f2f7 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/css-images.sub.tentative.html @@ -0,0 +1,1309 @@ + + + + + + HTTP headers on request for CSS image-accepting properties + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-a.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-a.https.sub.html new file mode 100644 index 0000000..6bc5069 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-a.https.sub.html @@ -0,0 +1,522 @@ + + + + + + HTTP headers on request for HTML "a" element navigation + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-a.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-a.sub.html new file mode 100644 index 0000000..389d339 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-a.sub.html @@ -0,0 +1,402 @@ + + + + + + HTTP headers on request for HTML "a" element navigation + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-area.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-area.https.sub.html new file mode 100644 index 0000000..2355cce --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-area.https.sub.html @@ -0,0 +1,522 @@ + + + + + + HTTP headers on request for HTML "area" element navigation + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-area.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-area.sub.html new file mode 100644 index 0000000..1932a66 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-area.sub.html @@ -0,0 +1,402 @@ + + + + + + HTTP headers on request for HTML "area" element navigation + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-audio.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-audio.https.sub.html new file mode 100644 index 0000000..f0ce53c --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-audio.https.sub.html @@ -0,0 +1,352 @@ + + + + + HTTP headers on request for HTML "audio" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-audio.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-audio.sub.html new file mode 100644 index 0000000..efe9164 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-audio.sub.html @@ -0,0 +1,268 @@ + + + + + HTTP headers on request for HTML "audio" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-embed.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-embed.https.sub.html new file mode 100644 index 0000000..2aea58d --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-embed.https.sub.html @@ -0,0 +1,245 @@ + + + + + HTTP headers on request for HTML "embed" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-embed.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-embed.sub.html new file mode 100644 index 0000000..e394d4d --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-embed.sub.html @@ -0,0 +1,220 @@ + + + + + HTTP headers on request for HTML "embed" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-frame.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-frame.https.sub.html new file mode 100644 index 0000000..7df86fb --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-frame.https.sub.html @@ -0,0 +1,338 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-frame.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-frame.sub.html new file mode 100644 index 0000000..6a10ca2 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-frame.sub.html @@ -0,0 +1,292 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-iframe.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-iframe.https.sub.html new file mode 100644 index 0000000..10de006 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-iframe.https.sub.html @@ -0,0 +1,338 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-iframe.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-iframe.sub.html new file mode 100644 index 0000000..5d2f096 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-iframe.sub.html @@ -0,0 +1,292 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.https.sub.html new file mode 100644 index 0000000..9e9c372 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.https.sub.html @@ -0,0 +1,386 @@ + + + + + HTTP headers on image request triggered by change to environment + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.sub.html new file mode 100644 index 0000000..0d99cb3 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-img-environment-change.sub.html @@ -0,0 +1,312 @@ + + + + + HTTP headers on image request triggered by change to environment + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-img.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-img.https.sub.html new file mode 100644 index 0000000..5a0f2e4 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-img.https.sub.html @@ -0,0 +1,703 @@ + + + + + HTTP headers on request for HTML "img" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-img.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-img.sub.html new file mode 100644 index 0000000..2300879 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-img.sub.html @@ -0,0 +1,540 @@ + + + + + HTTP headers on request for HTML "img" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-input-image.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-input-image.https.sub.html new file mode 100644 index 0000000..b5537ee --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-input-image.https.sub.html @@ -0,0 +1,250 @@ + + + + + HTTP headers on request for HTML "input" element with type="button" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-input-image.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-input-image.sub.html new file mode 100644 index 0000000..6f87436 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-input-image.sub.html @@ -0,0 +1,214 @@ + + + + + HTTP headers on request for HTML "input" element with type="button" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-link-icon.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-link-icon.https.sub.html new file mode 100644 index 0000000..b41f0af --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-link-icon.https.sub.html @@ -0,0 +1,402 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="icon" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-link-icon.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-link-icon.sub.html new file mode 100644 index 0000000..ea9ecb4 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-link-icon.sub.html @@ -0,0 +1,324 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="icon" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html new file mode 100644 index 0000000..16347ab --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.https.optional.sub.html @@ -0,0 +1,590 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="prefetch" + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.optional.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.optional.sub.html new file mode 100644 index 0000000..c938671 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-link-prefetch.optional.sub.html @@ -0,0 +1,320 @@ + + + + + + HTTP headers on request for HTML "link" element with rel="prefetch" + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html new file mode 100644 index 0000000..7f763d6 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.https.optional.sub.html @@ -0,0 +1,300 @@ + + + + + HTTP headers on request for HTML "meta" element with http-equiv="refresh" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.optional.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.optional.sub.html new file mode 100644 index 0000000..c1f3ecb --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-meta-refresh.optional.sub.html @@ -0,0 +1,261 @@ + + + + + HTTP headers on request for HTML "meta" element with http-equiv="refresh" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-picture.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-picture.https.sub.html new file mode 100644 index 0000000..2de0872 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-picture.https.sub.html @@ -0,0 +1,1090 @@ + + + + + HTTP headers on request for HTML "picture" element source + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-picture.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-picture.sub.html new file mode 100644 index 0000000..f0e0874 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-picture.sub.html @@ -0,0 +1,856 @@ + + + + + HTTP headers on request for HTML "picture" element source + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-script.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-script.https.sub.html new file mode 100644 index 0000000..e52b165 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-script.https.sub.html @@ -0,0 +1,624 @@ + + + + + HTTP headers on request for HTML "script" element source + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-script.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-script.sub.html new file mode 100644 index 0000000..47ed5fa --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-script.sub.html @@ -0,0 +1,578 @@ + + + + + HTTP headers on request for HTML "script" element source + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-video-poster.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-video-poster.https.sub.html new file mode 100644 index 0000000..ddd870a --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-video-poster.https.sub.html @@ -0,0 +1,264 @@ + + + + + HTTP headers on request for HTML "video" element "poster" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-video-poster.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-video-poster.sub.html new file mode 100644 index 0000000..aeb3d4f --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-video-poster.sub.html @@ -0,0 +1,228 @@ + + + + + HTTP headers on request for HTML "video" element "poster" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-video.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-video.https.sub.html new file mode 100644 index 0000000..c900807 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-video.https.sub.html @@ -0,0 +1,352 @@ + + + + + HTTP headers on request for HTML "video" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/element-video.sub.html b/test/fixtures/wpt/fetch/metadata/generated/element-video.sub.html new file mode 100644 index 0000000..bf357e9 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/element-video.sub.html @@ -0,0 +1,268 @@ + + + + + HTTP headers on request for HTML "video" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html new file mode 100644 index 0000000..c3b0599 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/fetch-via-serviceworker.https.sub.html @@ -0,0 +1,745 @@ + + + + + + HTTP headers on request using the "fetch" API and passing through a Serive Worker + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/fetch.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/fetch.https.sub.html new file mode 100644 index 0000000..486f404 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/fetch.https.sub.html @@ -0,0 +1,329 @@ + + + + + HTTP headers on request using the "fetch" API + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/fetch.sub.html b/test/fixtures/wpt/fetch/metadata/generated/fetch.sub.html new file mode 100644 index 0000000..7d9536e --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/fetch.sub.html @@ -0,0 +1,259 @@ + + + + + HTTP headers on request using the "fetch" API + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/form-submission.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/form-submission.https.sub.html new file mode 100644 index 0000000..0935f84 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/form-submission.https.sub.html @@ -0,0 +1,566 @@ + + + + + + HTTP headers on request for HTML form navigation + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/form-submission.sub.html b/test/fixtures/wpt/fetch/metadata/generated/form-submission.sub.html new file mode 100644 index 0000000..b0f56f6 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/form-submission.sub.html @@ -0,0 +1,466 @@ + + + + + + HTTP headers on request for HTML form navigation + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.html new file mode 100644 index 0000000..8e7d029 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.html @@ -0,0 +1,587 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.tentative.html b/test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.tentative.html new file mode 100644 index 0000000..307c37f --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/header-link.https.sub.tentative.html @@ -0,0 +1,51 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/header-link.sub.html b/test/fixtures/wpt/fetch/metadata/generated/header-link.sub.html new file mode 100644 index 0000000..b082307 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/header-link.sub.html @@ -0,0 +1,544 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/header-refresh.https.optional.sub.html b/test/fixtures/wpt/fetch/metadata/generated/header-refresh.https.optional.sub.html new file mode 100644 index 0000000..0f7e9e4 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/header-refresh.https.optional.sub.html @@ -0,0 +1,297 @@ + + + + + + HTTP headers on request for HTTP "Refresh" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/header-refresh.optional.sub.html b/test/fixtures/wpt/fetch/metadata/generated/header-refresh.optional.sub.html new file mode 100644 index 0000000..873013e --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/header-refresh.optional.sub.html @@ -0,0 +1,258 @@ + + + + + + HTTP headers on request for HTTP "Refresh" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.https.sub.html new file mode 100644 index 0000000..55c7014 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.https.sub.html @@ -0,0 +1,373 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.sub.html b/test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.sub.html new file mode 100644 index 0000000..2b554df --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/script-json-module-import-static.sub.html @@ -0,0 +1,378 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.https.sub.html new file mode 100644 index 0000000..94ef41b --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.https.sub.html @@ -0,0 +1,280 @@ + + + + + HTTP headers on request for dynamic ECMAScript module import + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.sub.html b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.sub.html new file mode 100644 index 0000000..16aefa1 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-dynamic.sub.html @@ -0,0 +1,253 @@ + + + + + HTTP headers on request for dynamic ECMAScript module import + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.https.sub.html new file mode 100644 index 0000000..62968f3 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.https.sub.html @@ -0,0 +1,316 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.sub.html b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.sub.html new file mode 100644 index 0000000..c4da157 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/script-module-import-static.sub.html @@ -0,0 +1,288 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/serviceworker.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/serviceworker.https.sub.html new file mode 100644 index 0000000..12e3736 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/serviceworker.https.sub.html @@ -0,0 +1,170 @@ + + + + + + HTTP headers on request for Service Workers + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/svg-image.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/svg-image.https.sub.html new file mode 100644 index 0000000..9dd0da1 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/svg-image.https.sub.html @@ -0,0 +1,396 @@ + + + + + + HTTP headers on request for SVG "image" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/svg-image.sub.html b/test/fixtures/wpt/fetch/metadata/generated/svg-image.sub.html new file mode 100644 index 0000000..56427be --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/svg-image.sub.html @@ -0,0 +1,307 @@ + + + + + + HTTP headers on request for SVG "image" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/window-history.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/window-history.https.sub.html new file mode 100644 index 0000000..64e282e --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/window-history.https.sub.html @@ -0,0 +1,281 @@ + + + + + HTTP headers on request for navigation via the HTML History API + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/window-history.sub.html b/test/fixtures/wpt/fetch/metadata/generated/window-history.sub.html new file mode 100644 index 0000000..8367049 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/window-history.sub.html @@ -0,0 +1,426 @@ + + + + + + HTTP headers on request for navigation via the HTML History API + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/window-location.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/window-location.https.sub.html new file mode 100644 index 0000000..387039b --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/window-location.https.sub.html @@ -0,0 +1,1296 @@ + + + + + + HTTP headers on request for navigation via the HTML Location API + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/window-location.sub.html b/test/fixtures/wpt/fetch/metadata/generated/window-location.sub.html new file mode 100644 index 0000000..e307b90 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/window-location.sub.html @@ -0,0 +1,1062 @@ + + + + + + HTTP headers on request for navigation via the HTML Location API + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html new file mode 100644 index 0000000..86f1760 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.https.sub.html @@ -0,0 +1,118 @@ + + + + + HTTP headers on request for dedicated worker via the "Worker" constructor + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.sub.html b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.sub.html new file mode 100644 index 0000000..e05e12d --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-constructor.sub.html @@ -0,0 +1,99 @@ + + + + + HTTP headers on request for dedicated worker via the "Worker" constructor + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html new file mode 100644 index 0000000..5b48e59 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.https.sub.html @@ -0,0 +1,295 @@ + + + + + HTTP headers on request for dedicated worker via the "importScripts" API + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.sub.html b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.sub.html new file mode 100644 index 0000000..ad4792b --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/generated/worker-dedicated-importscripts.sub.html @@ -0,0 +1,267 @@ + + + + + HTTP headers on request for dedicated worker via the "importScripts" API + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/navigation.https.sub.html b/test/fixtures/wpt/fetch/metadata/navigation.https.sub.html new file mode 100644 index 0000000..32c9cf7 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/navigation.https.sub.html @@ -0,0 +1,23 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/object.https.sub.html b/test/fixtures/wpt/fetch/metadata/object.https.sub.html new file mode 100644 index 0000000..fae5b37 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/object.https.sub.html @@ -0,0 +1,62 @@ + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/paint-worklet.https.html b/test/fixtures/wpt/fetch/metadata/paint-worklet.https.html new file mode 100644 index 0000000..49fc776 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/paint-worklet.https.html @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/preload.https.sub.html b/test/fixtures/wpt/fetch/metadata/preload.https.sub.html new file mode 100644 index 0000000..29042a8 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/preload.https.sub.html @@ -0,0 +1,50 @@ + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html b/test/fixtures/wpt/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html new file mode 100644 index 0000000..0f8f320 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/redirect/multiple-redirect-https-downgrade-upgrade.sub.html @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/redirect/redirect-http-upgrade.sub.html b/test/fixtures/wpt/fetch/metadata/redirect/redirect-http-upgrade.sub.html new file mode 100644 index 0000000..fa765b6 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/redirect/redirect-http-upgrade.sub.html @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/redirect/redirect-https-downgrade.sub.html b/test/fixtures/wpt/fetch/metadata/redirect/redirect-https-downgrade.sub.html new file mode 100644 index 0000000..4e5a48e --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/redirect/redirect-https-downgrade.sub.html @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/report.https.sub.html b/test/fixtures/wpt/fetch/metadata/report.https.sub.html new file mode 100644 index 0000000..b65f7c0 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/report.https.sub.html @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/report.https.sub.html.sub.headers b/test/fixtures/wpt/fetch/metadata/report.https.sub.html.sub.headers new file mode 100644 index 0000000..1ec5df7 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/report.https.sub.html.sub.headers @@ -0,0 +1,3 @@ +Content-Security-Policy: style-src 'self' 'unsafe-inline'; report-uri /fetch/metadata/resources/record-header.py?file=report-same-origin +Content-Security-Policy: style-src 'self' 'unsafe-inline'; report-uri https://{{hosts[][www]}}:{{ports[https][0]}}/fetch/metadata/resources/record-header.py?file=report-same-site +Content-Security-Policy: style-src 'self' 'unsafe-inline'; report-uri https://{{hosts[alt][www]}}:{{ports[https][0]}}/fetch/metadata/resources/record-header.py?file=report-cross-site diff --git a/test/fixtures/wpt/fetch/metadata/resources/appcache-iframe.sub.html b/test/fixtures/wpt/fetch/metadata/resources/appcache-iframe.sub.html new file mode 100644 index 0000000..cea9a4f --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/appcache-iframe.sub.html @@ -0,0 +1,15 @@ + + + + diff --git a/test/fixtures/wpt/fetch/metadata/resources/dedicatedWorker.js b/test/fixtures/wpt/fetch/metadata/resources/dedicatedWorker.js new file mode 100644 index 0000000..18626d3 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/dedicatedWorker.js @@ -0,0 +1 @@ +self.postMessage("Loaded"); diff --git a/test/fixtures/wpt/fetch/metadata/resources/echo-as-json.py b/test/fixtures/wpt/fetch/metadata/resources/echo-as-json.py new file mode 100644 index 0000000..44f68e8 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/echo-as-json.py @@ -0,0 +1,29 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + headers = [(b"Content-Type", b"application/json"), + (b"Access-Control-Allow-Credentials", b"true")] + + if b"origin" in request.headers: + headers.append((b"Access-Control-Allow-Origin", request.headers[b"origin"])) + + body = u"" + + # If we're in a preflight, verify that `Sec-Fetch-Mode` is `cors`. + if request.method == u'OPTIONS': + if request.headers.get(b"sec-fetch-mode") != b"cors": + return (403, b"Failed"), [], body + + headers.append((b"Access-Control-Allow-Methods", b"*")) + headers.append((b"Access-Control-Allow-Headers", b"*")) + else: + body = json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }) + + return headers, body diff --git a/test/fixtures/wpt/fetch/metadata/resources/echo-as-script.py b/test/fixtures/wpt/fetch/metadata/resources/echo-as-script.py new file mode 100644 index 0000000..1e7bc91 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/echo-as-script.py @@ -0,0 +1,14 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + headers = [(b"Content-Type", b"text/javascript")] + body = u"var header = %s;" % json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }) + + return headers, body diff --git a/test/fixtures/wpt/fetch/metadata/resources/es-json-module.sub.js b/test/fixtures/wpt/fetch/metadata/resources/es-json-module.sub.js new file mode 100644 index 0000000..df5d44c --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/es-json-module.sub.js @@ -0,0 +1 @@ +import '{{GET[moduleId]}}' with { type: 'json' }; diff --git a/test/fixtures/wpt/fetch/metadata/resources/es-module.sub.js b/test/fixtures/wpt/fetch/metadata/resources/es-module.sub.js new file mode 100644 index 0000000..f9668a3 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/es-module.sub.js @@ -0,0 +1 @@ +import '{{GET[moduleId]}}'; diff --git a/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js b/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js new file mode 100644 index 0000000..09858b2 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--fallback--sw.js @@ -0,0 +1,3 @@ +self.addEventListener('fetch', function(event) { + // Empty event handler - will fallback to the network. +}); diff --git a/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js b/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js new file mode 100644 index 0000000..8bf8d8f --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker--respondWith--sw.js @@ -0,0 +1,3 @@ +self.addEventListener('fetch', function(event) { + event.respondWith(fetch(event.request)); +}); diff --git a/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker-frame.html b/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker-frame.html new file mode 100644 index 0000000..9879802 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/fetch-via-serviceworker-frame.html @@ -0,0 +1,3 @@ + + +Page Title diff --git a/test/fixtures/wpt/fetch/metadata/resources/header-link.py b/test/fixtures/wpt/fetch/metadata/resources/header-link.py new file mode 100644 index 0000000..de89116 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/header-link.py @@ -0,0 +1,15 @@ +def main(request, response): + """ + Respond with a blank HTML document and a `Link` header which describes + a link relation specified by the requests `location` and `rel` query string + parameters + """ + headers = [ + (b'Content-Type', b'text/html'), + ( + b'Link', + b'<' + request.GET.first(b'location') + b'>; rel=' + request.GET.first(b'rel') + ) + ] + return (200, headers, b'') + diff --git a/test/fixtures/wpt/fetch/metadata/resources/helper.js b/test/fixtures/wpt/fetch/metadata/resources/helper.js new file mode 100644 index 0000000..725f9a7 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/helper.js @@ -0,0 +1,42 @@ +function validate_expectations(key, expected, tag) { + return fetch("/fetch/metadata/resources/record-header.py?retrieve=true&file=" + key) + .then(response => response.text()) + .then(text => { + assert_not_equals(text, "No header has been recorded"); + let value = JSON.parse(text); + test(t => assert_equals(value.dest, expected.dest), `${tag}: sec-fetch-dest`); + test(t => assert_equals(value.mode, expected.mode), `${tag}: sec-fetch-mode`); + test(t => assert_equals(value.site, expected.site), `${tag}: sec-fetch-site`); + test(t => assert_equals(value.user, expected.user), `${tag}: sec-fetch-user`); + }); +} + +function validate_expectations_custom_url(url, header, expected, tag) { + return fetch(url, header) + .then(response => response.text()) + .then(text => { + assert_not_equals(text, "No header has been recorded"); + let value = JSON.parse(text); + test(t => assert_equals(value.dest, expected.dest), `${tag}: sec-fetch-dest`); + test(t => assert_equals(value.mode, expected.mode), `${tag}: sec-fetch-mode`); + test(t => assert_equals(value.site, expected.site), `${tag}: sec-fetch-site`); + test(t => assert_equals(value.user, expected.user), `${tag}: sec-fetch-user`); + }); +} + +/** + * @param {object} value + * @param {object} expected + * @param {string} tag + **/ +function assert_header_equals(value, expected, tag) { + if (typeof(value) === "string"){ + assert_not_equals(value, "No header has been recorded"); + value = JSON.parse(value); + } + + test(t => assert_equals(value.dest, expected.dest), `${tag}: sec-fetch-dest`); + test(t => assert_equals(value.mode, expected.mode), `${tag}: sec-fetch-mode`); + test(t => assert_equals(value.site, expected.site), `${tag}: sec-fetch-site`); + test(t => assert_equals(value.user, expected.user), `${tag}: sec-fetch-user`); +} diff --git a/test/fixtures/wpt/fetch/metadata/resources/helper.sub.js b/test/fixtures/wpt/fetch/metadata/resources/helper.sub.js new file mode 100644 index 0000000..fd179fe --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/helper.sub.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Construct a URL which, when followed, will trigger redirection through zero + * or more specified origins and ultimately resolve in the Python handler + * `record-headers.py`. + * + * @param {string} key - the WPT server "stash" name where the request's + * headers should be stored + * @param {string[]} [origins] - zero or more origin names through which the + * request should pass; see the function + * implementation for a completel list of names + * and corresponding origins; If specified, the + * final origin will be used to access the + * `record-headers.py` hander. + * @param {object} [params] - a collection of key-value pairs to include as + * URL "search" parameters in the final request to + * `record-headers.py` + * + * @returns {string} an absolute URL + */ +function makeRequestURL(key, origins, params) { + const byName = { + httpOrigin: 'http://{{host}}:{{ports[http][0]}}', + httpSameSite: 'http://{{hosts[][www]}}:{{ports[http][0]}}', + httpCrossSite: 'http://{{hosts[alt][]}}:{{ports[http][0]}}', + httpsOrigin: 'https://{{host}}:{{ports[https][0]}}', + httpsSameSite: 'https://{{hosts[][www]}}:{{ports[https][0]}}', + httpsCrossSite: 'https://{{hosts[alt][]}}:{{ports[https][0]}}' + }; + const redirectPath = '/fetch/api/resources/redirect.py?location='; + const path = '/fetch/metadata/resources/record-headers.py?key=' + key; + + let requestUrl = path; + if (params) { + requestUrl += '&' + new URLSearchParams(params).toString(); + } + + if (origins && origins.length) { + requestUrl = byName[origins.pop()] + requestUrl; + + while (origins.length) { + requestUrl = byName[origins.pop()] + redirectPath + + encodeURIComponent(requestUrl); + } + } else { + requestUrl = byName.httpsOrigin + requestUrl; + } + + return requestUrl; +} + +function retrieve(key, options) { + return fetch('/fetch/metadata/resources/record-headers.py?retrieve&key=' + key) + .then((response) => { + if (response.status === 204 && options && options.poll) { + return new Promise((resolve) => setTimeout(resolve, 300)) + .then(() => retrieve(key, options)); + } + + if (response.status !== 200) { + throw new Error('Failed to query for recorded headers.'); + } + + return response.text().then((text) => JSON.parse(text)); + }); +} diff --git a/test/fixtures/wpt/fetch/metadata/resources/message-opener.html b/test/fixtures/wpt/fetch/metadata/resources/message-opener.html new file mode 100644 index 0000000..eb2af7b --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/message-opener.html @@ -0,0 +1,17 @@ + diff --git a/test/fixtures/wpt/fetch/metadata/resources/post-to-owner.py b/test/fixtures/wpt/fetch/metadata/resources/post-to-owner.py new file mode 100644 index 0000000..2d48968 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/post-to-owner.py @@ -0,0 +1,26 @@ +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + headers = [ + (b"Content-Type", b"text/html"), + (b"Cache-Control", b"no-cache, no-store, must-revalidate") + ] + + body = u""" + + + """ % (json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + })) + return headers, body diff --git a/test/fixtures/wpt/fetch/metadata/resources/record-header.py b/test/fixtures/wpt/fetch/metadata/resources/record-header.py new file mode 100644 index 0000000..29ff2ed --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/record-header.py @@ -0,0 +1,145 @@ +import os +import hashlib +import json + +from wptserve.utils import isomorphic_decode + +def main(request, response): + ## Get the query parameter (key) from URL ## + ## Tests will record POST requests (CSP Report) and GET (rest) ## + if request.GET: + key = request.GET[b'file'] + elif request.POST: + key = request.POST[b'file'] + + ## Convert the key from String to UUID valid String ## + testId = hashlib.md5(key).hexdigest() + + ## Handle the header retrieval request ## + if b'retrieve' in request.GET: + response.writer.write_status(200) + response.writer.write_header(b"Connection", b"close") + response.writer.end_headers() + try: + header_value = request.server.stash.take(testId) + response.writer.write(header_value) + except (KeyError, ValueError) as e: + response.writer.write(u"No header has been recorded") + pass + + response.close_connection = True + + ## Record incoming fetch metadata header value + else: + try: + ## Return a serialized JSON object with one member per header. If the ## + ## header isn't present, the member will contain an empty string. ## + header = json.dumps({ + u"dest": isomorphic_decode(request.headers.get(b"sec-fetch-dest", b"")), + u"mode": isomorphic_decode(request.headers.get(b"sec-fetch-mode", b"")), + u"site": isomorphic_decode(request.headers.get(b"sec-fetch-site", b"")), + u"user": isomorphic_decode(request.headers.get(b"sec-fetch-user", b"")), + }) + request.server.stash.put(testId, header) + except KeyError: + ## The header is already recorded or it doesn't exist + pass + + ## Prevent the browser from caching returned responses and allow CORS ## + response.headers.set(b"Access-Control-Allow-Origin", b"*") + response.headers.set(b"Cache-Control", b"no-cache, no-store, must-revalidate") + response.headers.set(b"Pragma", b"no-cache") + response.headers.set(b"Expires", b"0") + + ## Add a valid ServiceWorker Content-Type ## + if key.startswith(b"serviceworker"): + response.headers.set(b"Content-Type", b"application/javascript") + + ## Add a valid image Content-Type ## + if key.startswith(b"image"): + response.headers.set(b"Content-Type", b"image/png") + file = open(os.path.join(request.doc_root, u"media", u"1x1-green.png"), u"rb") + image = file.read() + file.close() + return image + + ## Return a valid .vtt content for the tag ## + if key.startswith(b"track"): + return b"WEBVTT" + + ## Return a valid SharedWorker ## + if key.startswith(b"sharedworker"): + response.headers.set(b"Content-Type", b"application/javascript") + file = open(os.path.join(request.doc_root, u"fetch", u"metadata", + u"resources", u"sharedWorker.js"), u"rb") + shared_worker = file.read() + file.close() + return shared_worker + + ## Return a valid font content and Content-Type ## + if key.startswith(b"font"): + response.headers.set(b"Content-Type", b"application/x-font-ttf") + file = open(os.path.join(request.doc_root, u"fonts", u"Ahem.ttf"), u"rb") + font = file.read() + file.close() + return font + + ## Return a valid audio content and Content-Type ## + if key.startswith(b"audio"): + response.headers.set(b"Content-Type", b"audio/mpeg") + file = open(os.path.join(request.doc_root, u"media", u"sound_5.mp3"), u"rb") + audio = file.read() + file.close() + return audio + + ## Return a valid video content and Content-Type ## + if key.startswith(b"video"): + response.headers.set(b"Content-Type", b"video/mp4") + file = open(os.path.join(request.doc_root, u"media", u"A4.mp4"), u"rb") + video = file.read() + file.close() + return video + + ## Return valid style content and Content-Type ## + if key.startswith(b"style"): + response.headers.set(b"Content-Type", b"text/css") + return b"div { }" + + ## Return a valid embed/object content and Content-Type ## + if key.startswith(b"embed") or key.startswith(b"object"): + response.headers.set(b"Content-Type", b"text/html") + return b"EMBED!" + + ## Return a valid image content and Content-Type for redirect requests ## + if key.startswith(b"redirect"): + response.headers.set(b"Content-Type", b"image/jpeg") + file = open(os.path.join(request.doc_root, u"media", u"1x1-green.png"), u"rb") + image = file.read() + file.close() + return image + + ## Return a valid dedicated worker + if key.startswith(b"worker"): + response.headers.set(b"Content-Type", b"application/javascript") + return b"self.postMessage('loaded');" + + ## Return a valid worklet + if key.startswith(b"worklet"): + response.headers.set(b"Content-Type", b"application/javascript") + return b"" + + ## Return a valid XSLT + if key.startswith(b"xslt"): + response.headers.set(b"Content-Type", b"text/xsl") + return b""" + + + + + + +""" + + if key.startswith(b"script"): + response.headers.set(b"Content-Type", b"application/javascript") + return b"void 0;" diff --git a/test/fixtures/wpt/fetch/metadata/resources/record-headers.py b/test/fixtures/wpt/fetch/metadata/resources/record-headers.py new file mode 100644 index 0000000..0362fe2 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/record-headers.py @@ -0,0 +1,73 @@ +import os +import uuid +import hashlib +import time +import json + + +def bytes_to_strings(d): + # Recursively convert bytes to strings in `d`. + if not isinstance(d, dict): + if isinstance(d, (tuple,list,set)): + v = [bytes_to_strings(x) for x in d] + return v + else: + if isinstance(d, bytes): + d = d.decode() + return d + + result = {} + for k,v in d.items(): + if isinstance(k, bytes): + k = k.decode() + if isinstance(v, dict): + v = bytes_to_strings(v) + elif isinstance(v, (tuple,list,set)): + v = [bytes_to_strings(x) for x in v] + elif isinstance(v, bytes): + v = v.decode() + result[k] = v + return result + + +def main(request, response): + # This condition avoids false positives from CORS preflight checks, where the + # request under test may be followed immediately by a request to the same URL + # using a different HTTP method. + if b'requireOPTIONS' in request.GET and request.method != b'OPTIONS': + return + + if b'key' in request.GET: + key = request.GET[b'key'] + elif b'key' in request.POST: + key = request.POST[b'key'] + + ## Convert the key from String to UUID valid String ## + testId = hashlib.md5(key).hexdigest() + + ## Handle the header retrieval request ## + if b'retrieve' in request.GET: + recorded_headers = request.server.stash.take(testId) + + if recorded_headers is None: + return (204, [], b'') + + return (200, [], recorded_headers) + + ## Record incoming fetch metadata header value + else: + try: + request.server.stash.put(testId, json.dumps(bytes_to_strings(request.headers))) + except KeyError: + ## The header is already recorded or it doesn't exist + pass + + ## Prevent the browser from caching returned responses and allow CORS ## + response.headers.set(b"Access-Control-Allow-Origin", b"*") + response.headers.set(b"Cache-Control", b"no-cache, no-store, must-revalidate") + response.headers.set(b"Pragma", b"no-cache") + response.headers.set(b"Expires", b"0") + if b"mime" in request.GET: + response.headers.set(b"Content-Type", request.GET.first(b"mime")) + + return request.GET.first(b"body", request.POST.first(b"body", b"")) diff --git a/test/fixtures/wpt/fetch/metadata/resources/redirectTestHelper.sub.js b/test/fixtures/wpt/fetch/metadata/resources/redirectTestHelper.sub.js new file mode 100644 index 0000000..1bfbbae --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/redirectTestHelper.sub.js @@ -0,0 +1,167 @@ +function createVideoElement() { + let el = document.createElement('video'); + el.src = '/media/movie_5.mp4'; + el.setAttribute('controls', ''); + el.setAttribute('crossorigin', ''); + return el; +} + +function createTrack() { + let el = document.createElement('track'); + el.setAttribute('default', ''); + el.setAttribute('kind', 'captions'); + el.setAttribute('srclang', 'en'); + return el; +} + +let secureRedirectURL = 'https://{{host}}:{{ports[https][0]}}/fetch/api/resources/redirect.py?location='; +let insecureRedirectURL = 'http://{{host}}:{{ports[http][0]}}/fetch/api/resources/redirect.py?location='; +let secureTestURL = 'https://{{host}}:{{ports[https][0]}}/fetch/metadata/'; +let insecureTestURL = 'http://{{host}}:{{ports[http][0]}}/fetch/metadata/'; + +// Helper to craft an URL that will go from HTTPS => HTTP => HTTPS to +// simulate us downgrading then upgrading again during the same redirect chain. +function MultipleRedirectTo(partialPath) { + let finalURL = insecureRedirectURL + encodeURIComponent(secureTestURL + partialPath); + return secureRedirectURL + encodeURIComponent(finalURL); +} + +// Helper to craft an URL that will go from HTTP => HTTPS to simulate upgrading a +// given request. +function upgradeRedirectTo(partialPath) { + return insecureRedirectURL + encodeURIComponent(secureTestURL + partialPath); +} + +// Helper to craft an URL that will go from HTTPS => HTTP to simulate downgrading a +// given request. +function downgradeRedirectTo(partialPath) { + return secureRedirectURL + encodeURIComponent(insecureTestURL + partialPath); +} + +// Helper to run common redirect test cases that don't require special setup on +// the test page itself. +function RunCommonRedirectTests(testNamePrefix, urlHelperMethod, expectedResults) { + async_test(t => { + let testWindow = window.open(urlHelperMethod('resources/post-to-owner.py?top-level-navigation' + nonce)); + t.add_cleanup(_ => testWindow.close()); + window.addEventListener('message', t.step_func(e => { + if (e.source != testWindow) { + return; + } + + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'navigate'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'document'; + assert_header_equals(e.data, expectation, testNamePrefix + ' top level navigation'); + t.done(); + })); + }, testNamePrefix + ' top level navigation'); + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'embed-https-redirect' + nonce; + let e = document.createElement('embed'); + e.src = urlHelperMethod('resources/record-header.py?file=' + key); + e.onload = e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'navigate'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'embed'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' embed'))) + .then(resolve) + .catch(e => reject(e)); + }; + document.body.appendChild(e); + }); + }, testNamePrefix + ' embed'); + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'object-https-redirect' + nonce; + let e = document.createElement('object'); + e.data = urlHelperMethod('resources/record-header.py?file=' + key); + e.onload = e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'navigate'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'object'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' object'))) + .then(resolve) + .catch(e => reject(e)); + }; + document.body.appendChild(e); + }); + }, testNamePrefix + ' object'); + + if (document.createElement('link').relList.supports('preload')) { + async_test(t => { + let key = 'preload' + nonce; + let e = document.createElement('link'); + e.rel = 'preload'; + e.href = urlHelperMethod('resources/record-header.py?file=' + key); + e.setAttribute('as', 'track'); + e.onload = e.onerror = t.step_func_done(e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'cors'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(t.step_func(response => response.text())) + .then(t.step_func_done(text => assert_header_equals(text, expectation, testNamePrefix + ' preload'))) + .catch(t.unreached_func()); + }); + document.head.appendChild(e); + }, testNamePrefix + ' preload'); + } + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'style-https-redirect' + nonce; + let e = document.createElement('link'); + e.rel = 'stylesheet'; + e.href = urlHelperMethod('resources/record-header.py?file=' + key); + e.onload = e => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'no-cors'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'style'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' stylesheet'))) + .then(resolve) + .catch(e => reject(e)); + }; + document.body.appendChild(e); + }); + }, testNamePrefix + ' stylesheet'); + + promise_test(t => { + return new Promise((resolve, reject) => { + let key = 'track-https-redirect' + nonce; + let video = createVideoElement(); + let el = createTrack(); + el.src = urlHelperMethod('resources/record-header.py?file=' + key); + el.onload = t.step_func(_ => { + let expectation = { ...expectedResults }; + if (expectation['mode'] != '') + expectation['mode'] = 'cors'; + if (expectation['dest'] == 'font') + expectation['dest'] = 'track'; + fetch('/fetch/metadata/resources/record-header.py?retrieve=true&file=' + key) + .then(response => response.text()) + .then(t.step_func(text => assert_header_equals(text, expectation, testNamePrefix + ' track'))) + .then(resolve); + }); + video.appendChild(el); + document.body.appendChild(video); + }); + }, testNamePrefix + ' track'); +} diff --git a/test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors-frame.html b/test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors-frame.html new file mode 100644 index 0000000..9879802 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors-frame.html @@ -0,0 +1,3 @@ + + +Page Title diff --git a/test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors.sw.js b/test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors.sw.js new file mode 100644 index 0000000..36c55a7 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/serviceworker-accessors.sw.js @@ -0,0 +1,14 @@ +addEventListener("fetch", event => { + event.waitUntil(async function () { + if (!event.clientId) return; + const client = await clients.get(event.clientId); + if (!client) return; + + client.postMessage({ + "dest": event.request.headers.get("sec-fetch-dest"), + "mode": event.request.headers.get("sec-fetch-mode"), + "site": event.request.headers.get("sec-fetch-site"), + "user": event.request.headers.get("sec-fetch-user") + }); + }()); +}); diff --git a/test/fixtures/wpt/fetch/metadata/resources/sharedWorker.js b/test/fixtures/wpt/fetch/metadata/resources/sharedWorker.js new file mode 100644 index 0000000..5eb89cb --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/sharedWorker.js @@ -0,0 +1,9 @@ +onconnect = function(e) { + var port = e.ports[0]; + + port.addEventListener('message', function(e) { + port.postMessage("Ready"); + }); + + port.start(); +} diff --git a/test/fixtures/wpt/fetch/metadata/resources/unload-with-beacon.html b/test/fixtures/wpt/fetch/metadata/resources/unload-with-beacon.html new file mode 100644 index 0000000..b00c9a5 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/unload-with-beacon.html @@ -0,0 +1,12 @@ + + diff --git a/test/fixtures/wpt/fetch/metadata/resources/xslt-test.sub.xml b/test/fixtures/wpt/fetch/metadata/resources/xslt-test.sub.xml new file mode 100644 index 0000000..acb478a --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/resources/xslt-test.sub.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/serviceworker-accessors.https.sub.html b/test/fixtures/wpt/fetch/metadata/serviceworker-accessors.https.sub.html new file mode 100644 index 0000000..03a8321 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/serviceworker-accessors.https.sub.html @@ -0,0 +1,51 @@ + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/sharedworker.https.sub.html b/test/fixtures/wpt/fetch/metadata/sharedworker.https.sub.html new file mode 100644 index 0000000..4df8582 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/sharedworker.https.sub.html @@ -0,0 +1,40 @@ + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/style.https.sub.html b/test/fixtures/wpt/fetch/metadata/style.https.sub.html new file mode 100644 index 0000000..a30d81d --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/style.https.sub.html @@ -0,0 +1,86 @@ + + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/README.md b/test/fixtures/wpt/fetch/metadata/tools/README.md new file mode 100644 index 0000000..1c3bac2 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/README.md @@ -0,0 +1,126 @@ +# Fetch Metadata test generation framework + +This directory defines a command-line tool for procedurally generating WPT +tests. + +## Motivation + +Many features of the web platform involve the browser making one or more HTTP +requests to remote servers. Only some aspects of these requests are specified +within the standard that defines the relevant feature. Other aspects are +specified by external standards which span the entire platform (e.g. [Fetch +Metadata Request Headers](https://w3c.github.io/webappsec-fetch-metadata/)). + +This state of affairs makes it difficult to maintain test coverage for two +reasons: + +- When a new feature introduces a new kind of web request, it must be verified + to integrate with every cross-cutting standard. +- When a new cross-cutting standard is introduced, it must be verified to + integrate with every kind of web request. + +The tool in this directory attempts to reduce this tension. It allows +maintainers to express instructions for making web requests in an abstract +sense. These generic instructions can be reused by to produce a different suite +of tests for each cross-cutting feature. + +When a new kind of request is proposed, a single generic template can be +defined here. This will provide the maintainers of all cross-cutting features +with clear instruction on how to extend their test suite with the new feature. + +Similarly, when a new cross-cutting feature is proposed, the authors can use +this tool to build a test suite which spans the entire platform. + +## Build script + +To generate the Fetch Metadata tests, run `./wpt update-built --include fetch` +in the root of the repository. + +## Configuration + +The test generation tool requires a YAML-formatted configuration file as its +input. The file should define a dictionary with the following keys: + +- `templates` - a string describing the filesystem path from which template + files should be loaded +- `output_directory` - a string describing the filesystem path where the + generated test files should be written +- `cases` - a list of dictionaries describing how the test templates should be + expanded with individual subtests; each dictionary should have the following + keys: + - `all_subtests` - properties which should be defined for every expansion + - `common_axis` - a list of dictionaries + - `template_axes` - a dictionary relating template names to properties that + should be used when expanding that particular template + +Internally, the tool creates a set of "subtests" for each template. This set is +the Cartesian product of the `common_axis` and the given template's entry in +the `template_axes` dictionary. It uses this set of subtests to expand the +template, creating an output file. Refer to the next section for a concrete +example of how the expansion is performed. + +In general, the tool will output a single file for each template. However, the +`filename_flags` attribute has special semantics. It is used to separate +subtests for the same template file. This is intended to accommodate [the +web-platform-test's filename-based +conventions](https://web-platform-tests.org/writing-tests/file-names.html). + +For instance, when `.https` is present in a test file's name, the WPT test +harness will load that test using the HTTPS protocol. Subtests which include +the value `https` in the `filename_flags` property will be expanded using the +appropriate template but written to a distinct file whose name includes +`.https`. + +The generation tool requires that the configuration file references every +template in the `templates` directory. Because templates and configuration +files may be contributed by different people, this requirement ensures that +configuration authors are aware of all available templates. Some templates may +not be relevant for some features; in those cases, the configuration file can +include an empty array for the template's entry in the `template_axes` +dictionary (as in `template3.html` in the example which follows). + +## Expansion example + +In the following example configuration file, `a`, `b`, `s`, `w`, `x`, `y`, and +`z` all represent associative arrays. + +```yaml +templates: path/to/templates +output_directory: path/to/output +cases: + - every_subtest: s + common_axis: [a, b] + template_axes: + template1.html: [w] + template2.html: [x, y, z] + template3.html: [] +``` + +When run with such a configuration file, the tool would generate two files, +expanded with data as described below (where `(a, b)` represents the union of +`a` and `b`): + + template1.html: [(a, w), (b, w)] + template2.html: [(a, x), (b, x), (a, y), (b, y), (a, z), (b, z)] + template3.html: (zero tests; not expanded) + +## Design Considerations + +**Efficiency of generated output** The tool is capable of generating a large +number of tests given a small amount of input. Naively structured, this could +result in test suites which take large amount of time and computational +resources to complete. The tool has been designed to help authors structure the +generated output to reduce these resource requirements. + +**Literalness of generated output** Because the generated output is how most +people will interact with the tests, it is important that it be approachable. +This tool avoids outputting abstractions which would frustrate attempts to read +the source code or step through its execution environment. + +**Simplicity** The test generation logic itself was written to be approachable. +This makes it easier to anticipate how the tool will behave with new input, and +it lowers the bar for others to contribute improvements. + +Non-goals include conciseness of template files (verbosity makes the potential +expansions more predictable) and conciseness of generated output (verbosity +aids in the interpretation of results). diff --git a/test/fixtures/wpt/fetch/metadata/tools/fetch-metadata.conf.yml b/test/fixtures/wpt/fetch/metadata/tools/fetch-metadata.conf.yml new file mode 100644 index 0000000..425a4e1 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/fetch-metadata.conf.yml @@ -0,0 +1,943 @@ +--- +templates: templates +output_directory: ../generated +cases: + - all_subtests: + expected: NULL + filename_flags: [] + common_axis: + - headerName: sec-fetch-site + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-site + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-site + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-mode + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-mode + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-mode + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-dest + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-dest + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-dest + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-user + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-user + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-user + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + - headerName: sec-fetch-storage-access + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-storage-access + origins: [httpSameSite] + description: Not sent to non-trustworthy same-site destination + - headerName: sec-fetch-storage-access + origins: [httpCrossSite] + description: Not sent to non-trustworthy cross-site destination + template_axes: + # The `AudioWorklet` interface is only available in secure contexts + # https://webaudio.github.io/web-audio-api/#AudioWorklet + audioworklet.https.sub.html: [] + # Service workers are only available in secure context + fetch-via-serviceworker.https.sub.html: [] + # Service workers are only available in secure context + serviceworker.https.sub.html: [] + + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + script-json-module-import-static.sub.html: [{}] + svg-image.sub.html: [{}] + window-history.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + # `new Worker()` only makes same-origin requests, therefore we split it + # out into the next block. + worker-dedicated-constructor.sub.html: [] + + - all_subtests: + expected: NULL + filename_flags: [] + common_axis: + - headerName: sec-fetch-site + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-mode + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-dest + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-user + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + - headerName: sec-fetch-storage-access + origins: [httpOrigin] + description: Not sent to non-trustworthy same-origin destination + template_axes: + # All the templates in this block are unused with the exception of + # `worker-dedicated-constructor` + audioworklet.https.sub.html: [] + fetch-via-serviceworker.https.sub.html: [] + serviceworker.https.sub.html: [] + css-images.sub.html: [] + css-font-face.sub.html: [] + element-a.sub.html: [] + element-area.sub.html: [] + element-audio.sub.html: [] + element-embed.sub.html: [] + element-frame.sub.html: [] + element-iframe.sub.html: [] + element-img.sub.html: [] + element-img-environment-change.sub.html: [] + element-input-image.sub.html: [] + element-link-icon.sub.html: [] + element-link-prefetch.optional.sub.html: [] + element-meta-refresh.optional.sub.html: [] + element-picture.sub.html: [] + element-script.sub.html: [] + element-video.sub.html: [] + element-video-poster.sub.html: [] + fetch.sub.html: [] + form-submission.sub.html: [] + header-link.sub.html: [] + header-refresh.optional.sub.html: [] + window-location.sub.html: [] + script-module-import-dynamic.sub.html: [] + script-module-import-static.sub.html: [] + script-json-module-import-static.sub.html: [] + svg-image.sub.html: [] + window-history.sub.html: [] + worker-dedicated-importscripts.sub.html: [] + # `new Worker()` only makes same-origin requests, so we populate its + # generated tests here. + worker-dedicated-constructor.sub.html: [{}] + + # Sec-Fetch-Site - direct requests + - all_subtests: + headerName: sec-fetch-site + filename_flags: [https] + common_axis: + - description: Same origin + origins: [httpsOrigin] + expected: same-origin + - description: Cross-site + origins: [httpsCrossSite] + expected: cross-site + - description: Same site + origins: [httpsSameSite] + expected: same-site + template_axes: + # Unused + # - the request mode of all "classic" worker scripts is set to + # "same-origin" + # https://html.spec.whatwg.org/#fetch-a-classic-worker-script + # - the request mode of all "top-level "module" worker scripts is set to + # "same-origin": + # https://html.spec.whatwg.org/#fetch-a-single-module-script + worker-dedicated-constructor.sub.html: [] + + audioworklet.https.sub.html: [{}] + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{ init: { mode: no-cors } }] + fetch-via-serviceworker.https.sub.html: [{ init: { mode: no-cors } }] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + script-json-module-import-static.sub.html: [{}] + serviceworker.https.sub.html: [{}] + svg-image.sub.html: [{}] + window-history.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + + # Sec-Fetch-Site - redirection from HTTP + - all_subtests: + headerName: sec-fetch-site + filename_flags: [] + common_axis: + - description: HTTPS downgrade (header not sent) + origins: [httpsOrigin, httpOrigin] + expected: NULL + - description: HTTPS upgrade + origins: [httpOrigin, httpsOrigin] + expected: cross-site + - description: HTTPS downgrade-upgrade + origins: [httpsOrigin, httpOrigin, httpsOrigin] + expected: cross-site + template_axes: + # Unused + # The `AudioWorklet` interface is only available in secure contexts + # https://webaudio.github.io/web-audio-api/#AudioWorklet + audioworklet.https.sub.html: [] + # Service workers are only available in secure context + fetch-via-serviceworker.https.sub.html: [] + # Service workers' redirect mode is "error" + serviceworker.https.sub.html: [] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + window-history.sub.html: [] + # Unused + # - the request mode of all "classic" worker scripts is set to + # "same-origin" + # https://html.spec.whatwg.org/#fetch-a-classic-worker-script + # - the request mode of all "top-level "module" worker scripts is set to + # "same-origin": + # https://html.spec.whatwg.org/#fetch-a-single-module-script + worker-dedicated-constructor.sub.html: [] + + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + script-json-module-import-static.sub.html: [{}] + svg-image.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + + # Sec-Fetch-Site - redirection from HTTPS + - all_subtests: + headerName: sec-fetch-site + filename_flags: [https] + common_axis: + - description: Same-Origin -> Cross-Site -> Same-Origin redirect + origins: [httpsOrigin, httpsCrossSite, httpsOrigin] + expected: cross-site + - description: Same-Origin -> Same-Site -> Same-Origin redirect + origins: [httpsOrigin, httpsSameSite, httpsOrigin] + expected: same-site + - description: Cross-Site -> Same Origin + origins: [httpsCrossSite, httpsOrigin] + expected: cross-site + - description: Cross-Site -> Same-Site + origins: [httpsCrossSite, httpsSameSite] + expected: cross-site + - description: Cross-Site -> Cross-Site + origins: [httpsCrossSite, httpsCrossSite] + expected: cross-site + - description: Same-Origin -> Same Origin + origins: [httpsOrigin, httpsOrigin] + expected: same-origin + - description: Same-Origin -> Same-Site + origins: [httpsOrigin, httpsSameSite] + expected: same-site + - description: Same-Origin -> Cross-Site + origins: [httpsOrigin, httpsCrossSite] + expected: cross-site + - description: Same-Site -> Same Origin + origins: [httpsSameSite, httpsOrigin] + expected: same-site + - description: Same-Site -> Same-Site + origins: [httpsSameSite, httpsSameSite] + expected: same-site + - description: Same-Site -> Cross-Site + origins: [httpsSameSite, httpsCrossSite] + expected: cross-site + template_axes: + # Service Workers' redirect mode is "error" + serviceworker.https.sub.html: [] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + window-history.sub.html: [] + # Unused + # - the request mode of all "classic" worker scripts is set to + # "same-origin" + # https://html.spec.whatwg.org/#fetch-a-classic-worker-script + # - the request mode of all "top-level "module" worker scripts is set to + # "same-origin": + # https://html.spec.whatwg.org/#fetch-a-single-module-script + worker-dedicated-constructor.sub.html: [] + + audioworklet.https.sub.html: [{}] + css-images.sub.html: + - filename_flags: [tentative] + css-font-face.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: + - {} + - elementAttrs: { type: module } + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{ init: { mode: no-cors } }] + fetch-via-serviceworker.https.sub.html: [{ init: { mode: no-cors } }] + form-submission.sub.html: + - method: GET + - method: POST + header-link.sub.html: + - rel: icon + - rel: stylesheet + header-refresh.optional.sub.html: [{}] + window-location.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + script-json-module-import-static.sub.html: [{}] + svg-image.sub.html: [{}] + worker-dedicated-importscripts.sub.html: [{}] + + # Sec-Fetch-Site - redirection with mixed content + # These tests verify the effect that redirection has on the request's "site". + # The initial request must be made to a resource that is "same-site" with its + # origin. This avoids false positives because if the request were made to a + # cross-site resource, the value of "cross-site" would be assigned regardless + # of the subseqent redirection. + # + # Because these conditions necessarily warrant mixed content, only templates + # which can be configured to allow mixed content [1] can be used. + # + # [1] https://w3c.github.io/webappsec-mixed-content/#should-block-fetch + + - common_axis: + - description: HTTPS downgrade-upgrade + headerName: sec-fetch-site + origins: [httpsOrigin, httpOrigin, httpsOrigin] + expected: cross-site + filename_flags: [https] + template_axes: + # Mixed Content considers only a small subset of requests as + # "optionally-blockable." These are the only requests that can be tested + # for the "downgrade-upgrade" scenario, so all other templates must be + # explicitly ignored. + audioworklet.https.sub.html: [] + css-font-face.sub.html: [] + element-embed.sub.html: [] + element-frame.sub.html: [] + element-iframe.sub.html: [] + element-img-environment-change.sub.html: [] + element-link-icon.sub.html: [] + element-link-prefetch.optional.sub.html: [] + element-picture.sub.html: [] + element-script.sub.html: [] + fetch.sub.html: [] + fetch-via-serviceworker.https.sub.html: [] + header-link.sub.html: [] + script-module-import-static.sub.html: [] + script-module-import-dynamic.sub.html: [] + script-json-module-import-static.sub.html: [] + # Service Workers' redirect mode is "error" + serviceworker.https.sub.html: [] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + window-history.sub.html: [] + worker-dedicated-constructor.sub.html: [] + worker-dedicated-importscripts.sub.html: [] + # Avoid duplicate subtest for 'sec-fetch-site - HTTPS downgrade-upgrade' + css-images.sub.html: + - filename_flags: [tentative] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + element-audio.sub.html: [{}] + element-img.sub.html: + # srcset omitted because it is not "optionally-blockable" + # https://w3c.github.io/webappsec-mixed-content/#category-optionally-blockable + - sourceAttr: src + element-input-image.sub.html: [{}] + element-meta-refresh.optional.sub.html: [{}] + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-refresh.optional.sub.html: [{}] + svg-image.sub.html: [{}] + window-location.sub.html: [{}] + + # Sec-Fetch-Mode + # These tests are served over HTTPS so the induced requests will be both + # same-origin with the document [1] and a potentially-trustworthy URL [2]. + # + # [1] https://html.spec.whatwg.org/multipage/origin.html#same-origin + # [2] https://w3c.github.io/webappsec-secure-contexts/#potentially-trustworthy-url + - common_axis: + - headerName: sec-fetch-mode + filename_flags: [https] + origins: [] + template_axes: + audioworklet.https.sub.html: + # https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-single-module-script + - expected: cors + css-images.sub.html: + - expected: no-cors + filename_flags: [tentative] + css-font-face.sub.html: + - expected: cors + filename_flags: [tentative] + element-a.sub.html: + - expected: navigate + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: no-cors + element-area.sub.html: + - expected: navigate + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: no-cors + element-audio.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-embed.sub.html: + - expected: no-cors + element-frame.sub.html: + - expected: navigate + element-iframe.sub.html: + - expected: navigate + element-img.sub.html: + - sourceAttr: src + expected: no-cors + - sourceAttr: src + expected: cors + elementAttrs: { crossorigin: '' } + - sourceAttr: src + expected: cors + elementAttrs: { crossorigin: anonymous } + - sourceAttr: src + expected: cors + elementAttrs: { crossorigin: use-credentials } + - sourceAttr: srcset + expected: no-cors + - sourceAttr: srcset + expected: cors + elementAttrs: { crossorigin: '' } + - sourceAttr: srcset + expected: cors + elementAttrs: { crossorigin: anonymous } + - sourceAttr: srcset + expected: cors + elementAttrs: { crossorigin: use-credentials } + element-img-environment-change.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-input-image.sub.html: + - expected: no-cors + element-link-icon.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-link-prefetch.optional.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-meta-refresh.optional.sub.html: + - expected: navigate + element-picture.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-script.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { type: module } + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-video.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + element-video-poster.sub.html: + - expected: no-cors + fetch.sub.html: + - expected: cors + - expected: cors + init: { mode: cors } + - expected: no-cors + init: { mode: no-cors } + - expected: same-origin + init: { mode: same-origin } + fetch-via-serviceworker.https.sub.html: + - expected: cors + - expected: cors + init: { mode: cors } + - expected: no-cors + init: { mode: no-cors } + - expected: same-origin + init: { mode: same-origin } + form-submission.sub.html: + - method: GET + expected: navigate + - method: POST + expected: navigate + header-link.sub.html: + - rel: icon + expected: no-cors + - rel: stylesheet + expected: no-cors + header-refresh.optional.sub.html: + - expected: navigate + window-history.sub.html: + - expected: navigate + window-location.sub.html: + - expected: navigate + script-module-import-dynamic.sub.html: + - expected: cors + script-module-import-static.sub.html: + - expected: cors + script-json-module-import-static.sub.html: + - expected: cors + # https://svgwg.org/svg2-draft/linking.html#processingURL-fetch + svg-image.sub.html: + - expected: no-cors + - expected: cors + elementAttrs: { crossorigin: '' } + - expected: cors + elementAttrs: { crossorigin: anonymous } + - expected: cors + elementAttrs: { crossorigin: use-credentials } + serviceworker.https.sub.html: + - expected: same-origin + options: { type: 'classic' } + # https://github.com/whatwg/html/pull/5875 + - expected: same-origin + worker-dedicated-constructor.sub.html: + - expected: same-origin + - options: { type: module } + expected: same-origin + worker-dedicated-importscripts.sub.html: + - expected: no-cors + + # Sec-Fetch-Dest + - common_axis: + - headerName: sec-fetch-dest + filename_flags: [https] + origins: [] + template_axes: + audioworklet.https.sub.html: + # https://github.com/WebAudio/web-audio-api/issues/2203 + - expected: audioworklet + css-images.sub.html: + - expected: image + filename_flags: [tentative] + css-font-face.sub.html: + - expected: font + filename_flags: [tentative] + element-a.sub.html: + - expected: document + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: empty + element-area.sub.html: + - expected: document + # https://html.spec.whatwg.org/multipage/links.html#downloading-hyperlinks + - elementAttrs: {download: ''} + expected: empty + element-audio.sub.html: + - expected: audio + element-embed.sub.html: + - expected: embed + element-frame.sub.html: + # https://github.com/whatwg/html/pull/4976 + - expected: frame + element-iframe.sub.html: + # https://github.com/whatwg/html/pull/4976 + - expected: iframe + element-img.sub.html: + - sourceAttr: src + expected: image + - sourceAttr: srcset + expected: image + element-img-environment-change.sub.html: + - expected: image + element-input-image.sub.html: + - expected: image + element-link-icon.sub.html: + - expected: empty + element-link-prefetch.optional.sub.html: + - expected: empty + - elementAttrs: { as: audio } + expected: audio + - elementAttrs: { as: document } + expected: document + - elementAttrs: { as: embed } + expected: embed + - elementAttrs: { as: fetch } + expected: fetch + - elementAttrs: { as: font } + expected: font + - elementAttrs: { as: image } + expected: image + - elementAttrs: { as: object } + expected: object + - elementAttrs: { as: script } + expected: script + - elementAttrs: { as: style } + expected: style + - elementAttrs: { as: track } + expected: track + - elementAttrs: { as: video } + expected: video + - elementAttrs: { as: worker } + expected: worker + element-meta-refresh.optional.sub.html: + - expected: document + element-picture.sub.html: + - expected: image + element-script.sub.html: + - expected: script + element-video.sub.html: + - expected: video + element-video-poster.sub.html: + - expected: image + fetch.sub.html: + - expected: empty + fetch-via-serviceworker.https.sub.html: + - expected: empty + form-submission.sub.html: + - method: GET + expected: document + - method: POST + expected: document + header-link.sub.html: + - rel: icon + expected: empty + - rel: stylesheet + filename_flags: [tentative] + expected: style + header-refresh.optional.sub.html: + - expected: document + window-history.sub.html: + - expected: document + window-location.sub.html: + - expected: document + script-module-import-dynamic.sub.html: + - expected: script + script-module-import-static.sub.html: + - expected: script + script-json-module-import-static.sub.html: + - expected: json + serviceworker.https.sub.html: + - expected: serviceworker + # Implemented as "image" in Chromium and Firefox, but specified as + # "empty" + # https://github.com/w3c/svgwg/issues/782 + svg-image.sub.html: + - expected: empty + worker-dedicated-constructor.sub.html: + - expected: worker + - options: { type: module } + expected: worker + worker-dedicated-importscripts.sub.html: + - expected: script + + # Sec-Fetch-User + - common_axis: + - headerName: sec-fetch-user + filename_flags: [https] + origins: [] + template_axes: + audioworklet.https.sub.html: + - expected: NULL + css-images.sub.html: + - expected: NULL + filename_flags: [tentative] + css-font-face.sub.html: + - expected: NULL + filename_flags: [tentative] + element-a.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-area.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-audio.sub.html: + - expected: NULL + element-embed.sub.html: + - expected: NULL + element-frame.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-iframe.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + element-img.sub.html: + - sourceAttr: src + expected: NULL + - sourceAttr: srcset + expected: NULL + element-img-environment-change.sub.html: + - expected: NULL + element-input-image.sub.html: + - expected: NULL + element-link-icon.sub.html: + - expected: NULL + element-link-prefetch.optional.sub.html: + - expected: NULL + element-meta-refresh.optional.sub.html: + - expected: NULL + element-picture.sub.html: + - expected: NULL + element-script.sub.html: + - expected: NULL + element-video.sub.html: + - expected: NULL + element-video-poster.sub.html: + - expected: NULL + fetch.sub.html: + - expected: NULL + fetch-via-serviceworker.https.sub.html: + - expected: NULL + form-submission.sub.html: + - method: GET + expected: NULL + - method: GET + userActivated: TRUE + expected: ?1 + - method: POST + expected: NULL + - method: POST + userActivated: TRUE + expected: ?1 + header-link.sub.html: + - rel: icon + expected: NULL + - rel: stylesheet + expected: NULL + header-refresh.optional.sub.html: + - expected: NULL + window-history.sub.html: + - expected: NULL + window-location.sub.html: + - expected: NULL + - userActivated: TRUE + expected: ?1 + script-module-import-dynamic.sub.html: + - expected: NULL + script-module-import-static.sub.html: + - expected: NULL + script-json-module-import-static.sub.html: + - expected: NULL + serviceworker.https.sub.html: + - expected: NULL + svg-image.sub.html: + - expected: NULL + worker-dedicated-constructor.sub.html: + - expected: NULL + - options: { type: module } + expected: NULL + worker-dedicated-importscripts.sub.html: + - expected: NULL + # Sec-Fetch-Storage-Access + - all_subtests: + headerName: sec-fetch-storage-access + filename_flags: [https] + common_axis: + - description: Cross-site + origins: [httpsCrossSite] + expected: none + - description: Same site + origins: [httpsSameSite] + expected: NULL + template_axes: + # Service Workers' redirect mode is "error" + serviceworker.https.sub.html: [{}] + # Interstitial locations in an HTTP redirect chain are not added to the + # session history, so these requests cannot be initiated using the + # History API. + css-images.sub.html: + - filename_flags: [tentative] + element-audio.sub.html: [{}] + element-embed.sub.html: [{}] + element-frame.sub.html: [{}] + element-iframe.sub.html: [{}] + element-img.sub.html: + - sourceAttr: src + - sourceAttr: srcset + element-img-environment-change.sub.html: [{}] + element-input-image.sub.html: [{}] + element-link-icon.sub.html: [{}] + element-link-prefetch.optional.sub.html: [{}] + element-picture.sub.html: [{}] + element-script.sub.html: [{}] + element-video.sub.html: [{}] + element-video-poster.sub.html: [{}] + fetch.sub.html: [{ init: { mode: no-cors , credentials: include } }] + fetch-via-serviceworker.https.sub.html: [{ init: { mode: no-cors , credentials: include } }] + header-link.sub.html: + - rel: icon + - rel: stylesheet + svg-image.sub.html: [{}] + worker-dedicated-constructor.sub.html: [] + worker-dedicated-importscripts.sub.html: [{}] + # The following are cases where the Sec-Fetch-Storage-Access header should + # not be attached at all. + - all_subtests: + headerName: sec-fetch-storage-access + filename_flags: [https] + common_axis: + - description: Cross-site + origins: [httpsCrossSite] + expected: NULL + - description: Same site + origins: [httpsSameSite] + expected: NULL + template_axes: + audioworklet.https.sub.html: [{}] + css-font-face.sub.html: + - filename_flags: [tentative] + element-meta-refresh.optional.sub.html: [{}] + element-a.sub.html: [{}] + element-area.sub.html: [{}] + form-submission.sub.html: + - method: GET + - method: POST + header-refresh.optional.sub.html: [{}] + script-module-import-dynamic.sub.html: [{}] + script-module-import-static.sub.html: [{}] + window-history.sub.html: [{}] + window-location.sub.html: [{}] diff --git a/test/fixtures/wpt/fetch/metadata/tools/generate.py b/test/fixtures/wpt/fetch/metadata/tools/generate.py new file mode 100755 index 0000000..fa850c8 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/generate.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +import itertools +import os + +import jinja2 +import yaml + +HERE = os.path.abspath(os.path.dirname(__file__)) +PROJECT_ROOT = os.path.join(HERE, '..', '..', '..') + +def find_templates(starting_directory): + for directory, subdirectories, file_names in os.walk(starting_directory): + for file_name in file_names: + if file_name.startswith('.'): + continue + yield file_name, os.path.join(directory, file_name) + +def test_name(directory, template_name, subtest_flags): + ''' + Create a test name based on a template and the WPT file name flags [1] + required for a given subtest. This name is used to determine how subtests + may be grouped together. In order to promote grouping, the combination uses + a few aspects of how file name flags are interpreted: + + - repeated flags have no effect, so duplicates are removed + - flag sequence does not matter, so flags are consistently sorted + + directory | template_name | subtest_flags | result + ----------|------------------|-----------------|------- + cors | image.html | [] | cors/image.html + cors | image.https.html | [] | cors/image.https.html + cors | image.html | [https] | cors/image.https.html + cors | image.https.html | [https] | cors/image.https.html + cors | image.https.html | [https] | cors/image.https.html + cors | image.sub.html | [https] | cors/image.https.sub.html + cors | image.https.html | [sub] | cors/image.https.sub.html + + [1] docs/writing-tests/file-names.md + ''' + template_name_parts = template_name.split('.') + flags = set(subtest_flags) | set(template_name_parts[1:-1]) + test_name_parts = ( + [template_name_parts[0]] + + sorted(flags) + + [template_name_parts[-1]] + ) + return os.path.join(directory, '.'.join(test_name_parts)) + +def merge(a, b): + if type(a) != type(b): + raise Exception('Cannot merge disparate types') + if type(a) == list: + return a + b + if type(a) == dict: + merged = {} + + for key in a: + if key in b: + merged[key] = merge(a[key], b[key]) + else: + merged[key] = a[key] + + for key in b: + if not key in a: + merged[key] = b[key] + + return merged + + raise Exception('Cannot merge {} type'.format(type(a).__name__)) + +def product(a, b): + ''' + Given two lists of objects, compute their Cartesian product by merging the + elements together. For example, + + product( + [{'a': 1}, {'b': 2}], + [{'c': 3}, {'d': 4}, {'e': 5}] + ) + + returns the following list: + + [ + {'a': 1, 'c': 3}, + {'a': 1, 'd': 4}, + {'a': 1, 'e': 5}, + {'b': 2, 'c': 3}, + {'b': 2, 'd': 4}, + {'b': 2, 'e': 5} + ] + ''' + result = [] + + for a_object in a: + for b_object in b: + result.append(merge(a_object, b_object)) + + return result + +def make_provenance(project_root, cases, template): + return '\n'.join([ + 'This test was procedurally generated. Please do not modify it directly.', + 'Sources:', + '- {}'.format(os.path.relpath(cases, project_root)), + '- {}'.format(os.path.relpath(template, project_root)) + ]) + +def collection_filter(obj, title): + if not obj: + return 'no {}'.format(title) + + members = [] + for name, value in obj.items(): + if value == '': + members.append(name) + else: + members.append('{}={}'.format(name, value)) + + return '{}: {}'.format(title, ', '.join(members)) + +def pad_filter(value, side, padding): + if not value: + return '' + if side == 'start': + return padding + value + + return value + padding + +def main(config_file): + with open(config_file, 'r') as handle: + config = yaml.safe_load(handle.read()) + + templates_directory = os.path.normpath( + os.path.join(os.path.dirname(config_file), config['templates']) + ) + + environment = jinja2.Environment( + variable_start_string='[%', + variable_end_string='%]' + ) + environment.filters['collection'] = collection_filter + environment.filters['pad'] = pad_filter + templates = {} + subtests = {} + + for template_name, path in find_templates(templates_directory): + subtests[template_name] = [] + with open(path, 'r') as handle: + templates[template_name] = environment.from_string(handle.read()) + + for case in config['cases']: + unused_templates = set(templates) - set(case['template_axes']) + + # This warning is intended to help authors avoid mistakenly omitting + # templates. It can be silenced by extending the`template_axes` + # dictionary with an empty list for templates which are intentionally + # unused. + if unused_templates: + print( + 'Warning: case does not reference the following templates:' + ) + print('\n'.join('- {}'.format(name) for name in unused_templates)) + + common_axis = product( + case['common_axis'], [case.get('all_subtests', {})] + ) + + for template_name, template_axis in case['template_axes'].items(): + subtests[template_name].extend(product(common_axis, template_axis)) + + for template_name, template in templates.items(): + provenance = make_provenance( + PROJECT_ROOT, + config_file, + os.path.join(templates_directory, template_name) + ) + get_filename = lambda subtest: test_name( + config['output_directory'], + template_name, + subtest['filename_flags'] + ) + subtests_by_filename = itertools.groupby( + sorted(subtests[template_name], key=get_filename), + key=get_filename + ) + for filename, some_subtests in subtests_by_filename: + with open(filename, 'w') as handle: + handle.write(templates[template_name].render( + subtests=list(some_subtests), + provenance=provenance + ) + '\n') + +if __name__ == '__main__': + main('fetch-metadata.conf.yml') diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/audioworklet.https.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/audioworklet.https.sub.html new file mode 100644 index 0000000..7be309c --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/audioworklet.https.sub.html @@ -0,0 +1,53 @@ + + + + + HTTP headers on request for AudioWorklet module + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/css-font-face.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/css-font-face.sub.html new file mode 100644 index 0000000..94b33f4 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/css-font-face.sub.html @@ -0,0 +1,60 @@ + + + + + HTTP headers on request for CSS font-face + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/css-images.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/css-images.sub.html new file mode 100644 index 0000000..e394f9f --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/css-images.sub.html @@ -0,0 +1,137 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for CSS image-accepting properties + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-a.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-a.sub.html new file mode 100644 index 0000000..2bd8e8a --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-a.sub.html @@ -0,0 +1,72 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "a" element navigation + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-area.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-area.sub.html new file mode 100644 index 0000000..0cef5b2 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-area.sub.html @@ -0,0 +1,72 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "area" element navigation + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-audio.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-audio.sub.html new file mode 100644 index 0000000..92bc221 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-audio.sub.html @@ -0,0 +1,51 @@ + + + + + HTTP headers on request for HTML "audio" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-embed.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-embed.sub.html new file mode 100644 index 0000000..18ce09e --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-embed.sub.html @@ -0,0 +1,54 @@ + + + + + HTTP headers on request for HTML "embed" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-frame.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-frame.sub.html new file mode 100644 index 0000000..ce90171 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-frame.sub.html @@ -0,0 +1,62 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-iframe.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-iframe.sub.html new file mode 100644 index 0000000..43a632a --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-iframe.sub.html @@ -0,0 +1,62 @@ + + + + + HTTP headers on request for HTML "frame" element source + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-img-environment-change.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-img-environment-change.sub.html new file mode 100644 index 0000000..5a65114 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-img-environment-change.sub.html @@ -0,0 +1,78 @@ + + + + + HTTP headers on image request triggered by change to environment + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-img.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-img.sub.html new file mode 100644 index 0000000..1dac584 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-img.sub.html @@ -0,0 +1,52 @@ + + + + + HTTP headers on request for HTML "img" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-input-image.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-input-image.sub.html new file mode 100644 index 0000000..3c50008 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-input-image.sub.html @@ -0,0 +1,48 @@ + + + + + HTTP headers on request for HTML "input" element with type="button" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-link-icon.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-link-icon.sub.html new file mode 100644 index 0000000..18ce12a --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-link-icon.sub.html @@ -0,0 +1,75 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "link" element with rel="icon" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html new file mode 100644 index 0000000..59d677d --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-link-prefetch.optional.sub.html @@ -0,0 +1,71 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTML "link" element with rel="prefetch" + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html new file mode 100644 index 0000000..5a8d8f8 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-meta-refresh.optional.sub.html @@ -0,0 +1,60 @@ + + + + + HTTP headers on request for HTML "meta" element with http-equiv="refresh" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-picture.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-picture.sub.html new file mode 100644 index 0000000..903aeed --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-picture.sub.html @@ -0,0 +1,101 @@ + + + + + HTTP headers on request for HTML "picture" element source + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-script.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-script.sub.html new file mode 100644 index 0000000..4a281ae --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-script.sub.html @@ -0,0 +1,54 @@ + + + + + HTTP headers on request for HTML "script" element source + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-video-poster.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-video-poster.sub.html new file mode 100644 index 0000000..9cdaf06 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-video-poster.sub.html @@ -0,0 +1,62 @@ + + + + + HTTP headers on request for HTML "video" element "poster" + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/element-video.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/element-video.sub.html new file mode 100644 index 0000000..1b7b976 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/element-video.sub.html @@ -0,0 +1,51 @@ + + + + + HTTP headers on request for HTML "video" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html new file mode 100644 index 0000000..eead710 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/fetch-via-serviceworker.https.sub.html @@ -0,0 +1,88 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request using the "fetch" API and passing through a Serive Worker + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/fetch.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/fetch.sub.html new file mode 100644 index 0000000..a8dc536 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/fetch.sub.html @@ -0,0 +1,42 @@ + + + + + HTTP headers on request using the "fetch" API + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/form-submission.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/form-submission.sub.html new file mode 100644 index 0000000..4c9c8c5 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/form-submission.sub.html @@ -0,0 +1,87 @@ + + + + + + HTTP headers on request for HTML form navigation + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/header-link.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/header-link.sub.html new file mode 100644 index 0000000..2831f22 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/header-link.sub.html @@ -0,0 +1,56 @@ + + + + + HTTP headers on request for HTTP "Link" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/header-refresh.optional.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/header-refresh.optional.sub.html new file mode 100644 index 0000000..ec963d5 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/header-refresh.optional.sub.html @@ -0,0 +1,59 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for HTTP "Refresh" header + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/script-json-module-import-static.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/script-json-module-import-static.sub.html new file mode 100644 index 0000000..76aecbd --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/script-json-module-import-static.sub.html @@ -0,0 +1,58 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html new file mode 100644 index 0000000..653d3cd --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-dynamic.sub.html @@ -0,0 +1,35 @@ + + + + + HTTP headers on request for dynamic ECMAScript module import + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-static.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-static.sub.html new file mode 100644 index 0000000..c8d5f95 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/script-module-import-static.sub.html @@ -0,0 +1,53 @@ + + + + + HTTP headers on request for static ECMAScript module import + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/serviceworker.https.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/serviceworker.https.sub.html new file mode 100644 index 0000000..8284325 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/serviceworker.https.sub.html @@ -0,0 +1,72 @@ + + + + + + HTTP headers on request for Service Workers + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/svg-image.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/svg-image.sub.html new file mode 100644 index 0000000..52f7806 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/svg-image.sub.html @@ -0,0 +1,75 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for SVG "image" element source + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/window-history.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/window-history.sub.html new file mode 100644 index 0000000..286d019 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/window-history.sub.html @@ -0,0 +1,134 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for navigation via the HTML History API + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/window-location.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/window-location.sub.html new file mode 100644 index 0000000..96f3912 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/window-location.sub.html @@ -0,0 +1,128 @@ + + + + + {%- if subtests|length > 10 %} + + {%- endif %} + HTTP headers on request for navigation via the HTML Location API + + + {%- if subtests|selectattr('userActivated')|list %} + + + {%- endif %} + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html new file mode 100644 index 0000000..fede596 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-constructor.sub.html @@ -0,0 +1,49 @@ + + + + + HTTP headers on request for dedicated worker via the "Worker" constructor + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html b/test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html new file mode 100644 index 0000000..93e6374 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/tools/templates/worker-dedicated-importscripts.sub.html @@ -0,0 +1,54 @@ + + + + + HTTP headers on request for dedicated worker via the "importScripts" API + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/track.https.sub.html b/test/fixtures/wpt/fetch/metadata/track.https.sub.html new file mode 100644 index 0000000..346798f --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/track.https.sub.html @@ -0,0 +1,119 @@ + + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/trailing-dot.https.sub.any.js b/test/fixtures/wpt/fetch/metadata/trailing-dot.https.sub.any.js new file mode 100644 index 0000000..5e32fc4 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/trailing-dot.https.sub.any.js @@ -0,0 +1,30 @@ +// META: global=window,worker +// META: script=/fetch/metadata/resources/helper.js + +// Site +promise_test(t => { + return validate_expectations_custom_url("https://{{host}}.:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Fetching a resource from the same origin, but spelled with a trailing dot."); +}, "Fetching a resource from the same origin, but spelled with a trailing dot."); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[][www]}}.:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Fetching a resource from the same site, but spelled with a trailing dot."); +}, "Fetching a resource from the same site, but spelled with a trailing dot."); + +promise_test(t => { + return validate_expectations_custom_url("https://{{hosts[alt][www]}}.:{{ports[https][0]}}/fetch/metadata/resources/echo-as-json.py", {}, { + "site": "cross-site", + "user": "", + "mode": "cors", + "dest": "empty" + }, "Fetching a resource from a cross-site host, spelled with a trailing dot."); +}, "Fetching a resource from a cross-site host, spelled with a trailing dot."); diff --git a/test/fixtures/wpt/fetch/metadata/unload.https.sub.html b/test/fixtures/wpt/fetch/metadata/unload.https.sub.html new file mode 100644 index 0000000..bc26048 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/unload.https.sub.html @@ -0,0 +1,64 @@ + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/window-open.https.sub.html b/test/fixtures/wpt/fetch/metadata/window-open.https.sub.html new file mode 100644 index 0000000..94ba76a --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/window-open.https.sub.html @@ -0,0 +1,199 @@ + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/worker.https.sub.html b/test/fixtures/wpt/fetch/metadata/worker.https.sub.html new file mode 100644 index 0000000..20a4fe5 --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/worker.https.sub.html @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/test/fixtures/wpt/fetch/metadata/xslt.https.sub.html b/test/fixtures/wpt/fetch/metadata/xslt.https.sub.html new file mode 100644 index 0000000..dc72d7b --- /dev/null +++ b/test/fixtures/wpt/fetch/metadata/xslt.https.sub.html @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/test/fixtures/wpt/fetch/nosniff/image.html b/test/fixtures/wpt/fetch/nosniff/image.html new file mode 100644 index 0000000..9dfdb94 --- /dev/null +++ b/test/fixtures/wpt/fetch/nosniff/image.html @@ -0,0 +1,39 @@ + + +
+ diff --git a/test/fixtures/wpt/fetch/nosniff/importscripts.html b/test/fixtures/wpt/fetch/nosniff/importscripts.html new file mode 100644 index 0000000..920b6bd --- /dev/null +++ b/test/fixtures/wpt/fetch/nosniff/importscripts.html @@ -0,0 +1,14 @@ + + +
+ diff --git a/test/fixtures/wpt/fetch/nosniff/importscripts.js b/test/fixtures/wpt/fetch/nosniff/importscripts.js new file mode 100644 index 0000000..1895280 --- /dev/null +++ b/test/fixtures/wpt/fetch/nosniff/importscripts.js @@ -0,0 +1,28 @@ +// Testing importScripts() +function log(w) { this.postMessage(w) } +function f() { log("FAIL") } +function p() { log("PASS") } + +const get_url = (mime, outcome) => { + let url = "resources/js.py" + if (mime != null) { + url += "?type=" + encodeURIComponent(mime) + } + if (outcome) { + url += "&outcome=p" + } + return url +} + +[null, "", "x", "x/x", "text/html", "text/json"].forEach(function(mime) { + try { + importScripts(get_url(mime)) + } catch(e) { + (e.name == "NetworkError") ? p() : log("FAIL (no NetworkError exception): " + mime) + } + +}) +importScripts(get_url("text/javascript", true)) +importScripts(get_url("text/ecmascript", true)) +importScripts(get_url("text/ecmascript;blah", true)) +log("END") diff --git a/test/fixtures/wpt/fetch/nosniff/parsing-nosniff.window.js b/test/fixtures/wpt/fetch/nosniff/parsing-nosniff.window.js new file mode 100644 index 0000000..2a26486 --- /dev/null +++ b/test/fixtures/wpt/fetch/nosniff/parsing-nosniff.window.js @@ -0,0 +1,27 @@ +promise_test(() => fetch("resources/x-content-type-options.json").then(res => res.json()).then(runTests), "Loading JSON…"); + +function runTests(allTestData) { + for (let i = 0; i < allTestData.length; i++) { + const testData = allTestData[i], + input = encodeURIComponent(testData.input); + promise_test(t => { + let resolve; + const promise = new Promise(r => resolve = r); + const script = document.createElement("script"); + t.add_cleanup(() => script.remove()); + // A + +
+ diff --git a/test/fixtures/wpt/fetch/nosniff/stylesheet.html b/test/fixtures/wpt/fetch/nosniff/stylesheet.html new file mode 100644 index 0000000..8f2b547 --- /dev/null +++ b/test/fixtures/wpt/fetch/nosniff/stylesheet.html @@ -0,0 +1,60 @@ + + + +
+ diff --git a/test/fixtures/wpt/fetch/nosniff/worker.html b/test/fixtures/wpt/fetch/nosniff/worker.html new file mode 100644 index 0000000..c8c1076 --- /dev/null +++ b/test/fixtures/wpt/fetch/nosniff/worker.html @@ -0,0 +1,28 @@ + + +
+ diff --git a/test/fixtures/wpt/fetch/orb/resources/data.json b/test/fixtures/wpt/fetch/orb/resources/data.json new file mode 100644 index 0000000..f2a886f --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/data.json @@ -0,0 +1,3 @@ +{ + "hello": "world" +} diff --git a/test/fixtures/wpt/fetch/orb/resources/data_non_ascii.json b/test/fixtures/wpt/fetch/orb/resources/data_non_ascii.json new file mode 100644 index 0000000..64566c5 --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/data_non_ascii.json @@ -0,0 +1 @@ +["你好"] diff --git a/test/fixtures/wpt/fetch/orb/resources/empty.json b/test/fixtures/wpt/fetch/orb/resources/empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/empty.json @@ -0,0 +1 @@ +{} diff --git a/test/fixtures/wpt/fetch/orb/resources/font.ttf b/test/fixtures/wpt/fetch/orb/resources/font.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9023592ef5aa83a03dd6957398897a585062ca57 GIT binary patch literal 2528 zcmds3+iM(E9RAMC>}--IZHkSlbcJb}Hmx+fn~4|=c}SaNsWzb@-3F9mI_^#~3%fJR z?rbg~7_kpEQi_7Nf1zrjK~Q`sQnW2nkwV|M-@%oK-E?>x_(MpM29?acOqAH}eAZX8>i{v90{QD@UK8 z?l;0S4h7BS^-meAn|!xZ@)uj54c;RECHbzRm$TF>nv6e6K2fq3%b3Ch^~cB?u2r(v zkH7ad5c`2P>9SY#cXvOjFn>GsdB|P~`n~De%#NWyu}z}@xPEE<^GzId1b6hq`YrNJ zpl7(G&#mANPHPA{)^6*E!$^@bL|Q0mLqc}TB|Swb8%8pe2%7wX7*!uCHz~QWfyJ-r z7tPW^=Wn!Ro%h$|>{uSdXvUa|I&fOQrS7LPvS9~Z(v&zMA=;65&=C=`DhY|mZ&Kj!3HhbNxDPn<$e@rAG|9>`>_U%Yaa|m@d0+T+9$}A07}*9%}w^Ondom zl3bI=hUcy>--uAx7wLGEX&Kzn_%3s9JFtg_4YL!pi){|FXP{H;ZW!kJ85v$FZVvZc z=7R1t%y+FTKz4K3D>LVrE9j_0Kg^W>kV`b=3OX8csX3V|_H9EhakU|rxWPtGG$xDs zeRLLqoPhkgUkcxKN!R$Lr8Sp8i&%_ketg8+5v}5Y_$i__v?%)`I)-*-GNN_L7dTC! z$#2##gbi9?mv|+j6|{;sB3i|`_#mP+>{8kyItD{YMzl`3g%NltV+j=$Fb4-d3>-ub zhlow2(MK>aNlgJoLYZ6^7Cnmetngdg!aW6>yiIwPzj@l!;1b)kFc{MzW$@m3p1uag z87D`H8(I%iBJ=u;J%|+dLb#J*WgAu=<5fZ*DXp;5R9MY}C{;>IjO(NK5lxbD9RfzY z@=~QR=lI6K+#$nE_oaaWNmxCd;m?tPvxY zJ8xC9c9rx5g?W}XCSRSBcZdsV~Z&>YL1E97mjWcg0TE4dJ(nei;|UEZ=>^4@JlJ9c3=csI~@nRO6C WZTNf5{_GpcUH|0vRERIFfAlvXi@|mP literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/orb/resources/image.png b/test/fixtures/wpt/fetch/orb/resources/image.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/orb/resources/js-unlabeled-utf16-without-bom.json b/test/fixtures/wpt/fetch/orb/resources/js-unlabeled-utf16-without-bom.json new file mode 100644 index 0000000000000000000000000000000000000000..157a8f5430862e504c1225de69b998b114f5c289 GIT binary patch literal 70 zcmXSC$YjW4NMXolC}+@P$Y4lhC}xOfNM)!1;$((Wh7us10u(6*@``|J3xFaD47NaA N0_2whWvv;w7y$N*4KDxy literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/orb/resources/js-unlabeled.js b/test/fixtures/wpt/fetch/orb/resources/js-unlabeled.js new file mode 100644 index 0000000..a880a5b --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/js-unlabeled.js @@ -0,0 +1 @@ +window.has_executed_script = true; diff --git a/test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png b/test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png.headers b/test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png.headers new file mode 100644 index 0000000..156209f --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/png-mislabeled-as-html.png.headers @@ -0,0 +1 @@ +Content-Type: text/html diff --git a/test/fixtures/wpt/fetch/orb/resources/png-unlabeled.png b/test/fixtures/wpt/fetch/orb/resources/png-unlabeled.png new file mode 100644 index 0000000000000000000000000000000000000000..820f8cace2143bfc45c0c301e84b6c29b8630068 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD3?#3*wSy!Wi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gjy!dj$D1FjT2AFf_CT|`v++n6B;aK<+8d}OFfd9ReoF^w N_jL7hS?83{1OVd{KuZ7s literal 0 HcmV?d00001 diff --git a/test/fixtures/wpt/fetch/orb/resources/script-asm-js-invalid.js b/test/fixtures/wpt/fetch/orb/resources/script-asm-js-invalid.js new file mode 100644 index 0000000..8d1bbd6 --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/script-asm-js-invalid.js @@ -0,0 +1,4 @@ +function f() { + "use asm"; + return; +} diff --git a/test/fixtures/wpt/fetch/orb/resources/script-asm-js-valid.js b/test/fixtures/wpt/fetch/orb/resources/script-asm-js-valid.js new file mode 100644 index 0000000..79b375f --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/script-asm-js-valid.js @@ -0,0 +1,4 @@ +function f() { + "use asm"; + return {}; +} diff --git a/test/fixtures/wpt/fetch/orb/resources/script-iso-8559-1.js b/test/fixtures/wpt/fetch/orb/resources/script-iso-8559-1.js new file mode 100644 index 0000000..3bccb6a --- /dev/null +++ b/test/fixtures/wpt/fetch/orb/resources/script-iso-8559-1.js @@ -0,0 +1,4 @@ +"use strict"; +function fn() { + return "§A¦n"; +} diff --git a/test/fixtures/wpt/fetch/orb/resources/script-utf16-bom.js b/test/fixtures/wpt/fetch/orb/resources/script-utf16-bom.js new file mode 100644 index 0000000000000000000000000000000000000000..16b76e9d5e431fd9c363b0b1a15ba095e2a9fa11 GIT binary patch literal 92 zcmezWPl=(Fp_n0+K>eWS}F>3_u(hP=PTR-<`~R#tG*A|1EHYf%yOf;}RfO ufq}uKfq{X=$I;i-SkKZ@&oq=;0A!D5GnzfrG91YqkUhcZ{y~zb783yc element.remove()); + return new Promise((resolve, reject) => { + element.onerror = e => reject(new TypeError()); + element.onload = resolve; + + document.body.appendChild(element); + }); +} + +function testImageInitiator(t, path) { + return testElementInitiator(t, path, "img"); +} + +function testAudioInitiator(t, path) { + return testElementInitiator(t, path, "audio"); +} + +function testVideoInitiator(t, path) { + return testElementInitiator(t, path, "video"); +} + +function testScriptInitiator(t, path) { + return testElementInitiator(t, path, "script"); +} + +function runTest(t, test, file, options, ...pipe) { + const path = `${file}${pipe.length ? `?pipe=${pipe.join("|")}` : ""}`; + return test(t, path, options) +} + +function testRunAll(file, testCallback, adapter, options) { + let testcase = function (test, message, skip) { + return {test, message, skip}; + }; + + const name = "..."; + [ testcase(testFetchNoCors, `fetch(${name}, {mode: "no-cors"})`, false || options.skip.includes("fetch")), + testcase(testImageInitiator, ``, options.onlyFetch || options.skip.includes("image")), + testcase(testAudioInitiator, `

Relevant issue: +<embed> should support loading random HTML documents, like <object> +