@slopware/sloppy-darwin-arm64 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +5 -0
- package/bin/sloppy +0 -0
- package/bin/sloppyc +0 -0
- package/docs/KNOWN_LIMITATIONS.md +16 -0
- package/docs/LICENSES.md +6 -0
- package/docs/NOTICE.md +8 -0
- package/examples/README.md +140 -0
- package/examples/auth-api/README.md +20 -0
- package/examples/auth-api/app.js +61 -0
- package/examples/auth-api/appsettings.json +7 -0
- package/examples/auth-api/sloppy.json +5 -0
- package/examples/cache-basic/README.md +9 -0
- package/examples/cache-basic/app.js +32 -0
- package/examples/cache-hybrid-postgres/README.md +10 -0
- package/examples/cache-hybrid-postgres/app.js +27 -0
- package/examples/cache-output-api/README.md +10 -0
- package/examples/cache-output-api/app.js +35 -0
- package/examples/codec-base64-hex/README.md +14 -0
- package/examples/codec-base64-hex/app.js +15 -0
- package/examples/codec-checksums/README.md +15 -0
- package/examples/codec-checksums/app.js +8 -0
- package/examples/codec-compression/README.md +13 -0
- package/examples/codec-compression/app.js +9 -0
- package/examples/codec-streaming-compression/README.md +19 -0
- package/examples/codec-streaming-compression/app.js +16 -0
- package/examples/codec-text-binary/README.md +16 -0
- package/examples/codec-text-binary/app.js +17 -0
- package/examples/compiler-hello/README.md +71 -0
- package/examples/compiler-hello/app.js +7 -0
- package/examples/compiler-hello/expected/app.js +8 -0
- package/examples/compiler-hello/expected/app.js.map +53 -0
- package/examples/compiler-hello/expected/app.plan.json +229 -0
- package/examples/compiler-hello/expected/routes.slrt +0 -0
- package/examples/config-basic/README.md +13 -0
- package/examples/config-basic/app.js +13 -0
- package/examples/config-basic/appsettings.json +7 -0
- package/examples/config-secrets-redaction/README.md +9 -0
- package/examples/config-secrets-redaction/app.js +9 -0
- package/examples/config-secrets-redaction/appsettings.json +5 -0
- package/examples/config-strict-mode/README.md +7 -0
- package/examples/config-strict-mode/app.js +10 -0
- package/examples/config-strict-mode/appsettings.json +7 -0
- package/examples/configured-api/README.md +38 -0
- package/examples/configured-api/app.js +12 -0
- package/examples/configured-api/appsettings.Development.json +5 -0
- package/examples/configured-api/appsettings.json +6 -0
- package/examples/configured-api/sloppy.json +5 -0
- package/examples/core-config-secrets/README.md +10 -0
- package/examples/core-config-secrets/app.js +15 -0
- package/examples/core-fs-time-codec/README.md +9 -0
- package/examples/core-fs-time-codec/app.js +8 -0
- package/examples/core-network-time-codec/README.md +11 -0
- package/examples/core-network-time-codec/app.js +20 -0
- package/examples/core-policy-audit/README.md +7 -0
- package/examples/core-policy-audit/app.js +22 -0
- package/examples/core-process-time-codec/README.md +8 -0
- package/examples/core-process-time-codec/app.js +28 -0
- package/examples/core-worker-time/README.md +8 -0
- package/examples/core-worker-time/app.js +17 -0
- package/examples/crypto-hash-hmac/README.md +17 -0
- package/examples/crypto-hash-hmac/app.js +29 -0
- package/examples/crypto-password/README.md +21 -0
- package/examples/crypto-password/app.js +12 -0
- package/examples/crypto-random-token/README.md +16 -0
- package/examples/crypto-random-token/app.js +12 -0
- package/examples/crypto-secret-constant-time/README.md +21 -0
- package/examples/crypto-secret-constant-time/app.js +15 -0
- package/examples/data-foundation/README.md +39 -0
- package/examples/data-foundation/app.js +63 -0
- package/examples/dependency-graph/README.md +19 -0
- package/examples/dependency-graph/fixtures/graph-helper/index.js +3 -0
- package/examples/dependency-graph/fixtures/graph-helper/package.json +6 -0
- package/examples/dependency-graph/package.json +7 -0
- package/examples/dependency-graph/public/message.txt +1 -0
- package/examples/dependency-graph/sloppy.json +9 -0
- package/examples/dependency-graph/src/main.ts +8 -0
- package/examples/dogfood/README.md +23 -0
- package/examples/dogfood/dogfood.json +136 -0
- package/examples/dynamic-module-include/README.md +20 -0
- package/examples/dynamic-module-include/public/readme.txt +1 -0
- package/examples/dynamic-module-include/sloppy.json +12 -0
- package/examples/dynamic-module-include/src/main.ts +6 -0
- package/examples/dynamic-module-include/src/plugins/alpha.js +3 -0
- package/examples/dynamic-module-include/src/plugins/beta.js +3 -0
- package/examples/ergonomics/README.md +42 -0
- package/examples/ergonomics/app.js +38 -0
- package/examples/framework-controller/README.md +12 -0
- package/examples/framework-controller/app.js +31 -0
- package/examples/framework-di-services/README.md +17 -0
- package/examples/framework-di-services/app.ts +40 -0
- package/examples/framework-explicit-binding/README.md +12 -0
- package/examples/framework-explicit-binding/app.ts +34 -0
- package/examples/framework-hello/README.md +16 -0
- package/examples/framework-hello/app.ts +16 -0
- package/examples/framework-postgres-crud/README.md +73 -0
- package/examples/framework-postgres-crud/app.ts +64 -0
- package/examples/framework-sqlite-crud/README.md +52 -0
- package/examples/framework-sqlite-crud/app.ts +90 -0
- package/examples/framework-sqlite-crud/appsettings.json +11 -0
- package/examples/framework-sqlserver-crud/README.md +73 -0
- package/examples/framework-sqlserver-crud/app.ts +64 -0
- package/examples/framework-validation-errors/README.md +12 -0
- package/examples/framework-validation-errors/app.ts +16 -0
- package/examples/fs-basic/README.md +24 -0
- package/examples/fs-basic/app.js +12 -0
- package/examples/fs-roots-policy/README.md +14 -0
- package/examples/fs-roots-policy/app.js +4 -0
- package/examples/fs-streams/README.md +18 -0
- package/examples/fs-streams/app.js +11 -0
- package/examples/fs-watch/README.md +19 -0
- package/examples/fs-watch/app.js +11 -0
- package/examples/hello/README.md +63 -0
- package/examples/hello/app.js +19 -0
- package/examples/hello-minimal/README.md +51 -0
- package/examples/hello-minimal/sloppy.json +5 -0
- package/examples/hello-minimal/src/main.ts +9 -0
- package/examples/http-client-basic/README.md +11 -0
- package/examples/http-client-basic/app.js +46 -0
- package/examples/http-client-generated/README.md +22 -0
- package/examples/http-client-generated/openapi.json +45 -0
- package/examples/http-client-resilience/README.md +4 -0
- package/examples/http-client-resilience/app.js +38 -0
- package/examples/http-client-runtime-loopback/README.md +24 -0
- package/examples/http-client-testhost/README.md +4 -0
- package/examples/http-client-testhost/app.js +27 -0
- package/examples/http-client-testhost-package-mock/README.md +26 -0
- package/examples/http-client-typed/README.md +5 -0
- package/examples/http-client-typed/app.js +33 -0
- package/examples/modules-api/README.md +30 -0
- package/examples/modules-api/app.js +9 -0
- package/examples/modules-api/modules/routes.js +16 -0
- package/examples/modules-api/sloppy.json +5 -0
- package/examples/modules-basic/README.md +32 -0
- package/examples/modules-basic/app.js +41 -0
- package/examples/net-deadline-cancel/README.md +13 -0
- package/examples/net-deadline-cancel/app.js +34 -0
- package/examples/net-local-ipc/README.md +12 -0
- package/examples/net-local-ipc/app.js +46 -0
- package/examples/net-policy-strict/README.md +12 -0
- package/examples/net-policy-strict/app.js +34 -0
- package/examples/net-tcp-client/README.md +10 -0
- package/examples/net-tcp-client/app.js +23 -0
- package/examples/net-tcp-echo/README.md +11 -0
- package/examples/net-tcp-echo/app.js +45 -0
- package/examples/net-tcp-server/README.md +10 -0
- package/examples/net-tcp-server/app.js +28 -0
- package/examples/node-compat-path-events/README.md +15 -0
- package/examples/node-compat-path-events/sloppy.json +6 -0
- package/examples/node-compat-path-events/src/main.ts +15 -0
- package/examples/ops-compiler/README.md +9 -0
- package/examples/ops-compiler/app.js +26 -0
- package/examples/ops-health-metrics-management/README.md +14 -0
- package/examples/ops-health-metrics-management/app.js +24 -0
- package/examples/orm-basic/README.md +17 -0
- package/examples/orm-basic/app.js +82 -0
- package/examples/orm-cursor-export/README.md +16 -0
- package/examples/orm-cursor-export/app.js +28 -0
- package/examples/orm-migrations/README.md +14 -0
- package/examples/orm-migrations/migrations/.gitkeep +1 -0
- package/examples/orm-migrations/sloppy.json +9 -0
- package/examples/orm-migrations/src/app.ts +34 -0
- package/examples/orm-relations-includes/README.md +10 -0
- package/examples/orm-relations-includes/app.js +47 -0
- package/examples/orm-testservices/README.md +37 -0
- package/examples/orm-testservices/test.mjs +32 -0
- package/examples/os-runtime-api/README.md +11 -0
- package/examples/os-runtime-api/app.js +44 -0
- package/examples/package-zod-like/README.md +28 -0
- package/examples/package-zod-like/fixtures/zod-like/index.js +48 -0
- package/examples/package-zod-like/fixtures/zod-like/package.json +12 -0
- package/examples/package-zod-like/package.json +7 -0
- package/examples/package-zod-like/sloppy.json +6 -0
- package/examples/package-zod-like/src/main.ts +16 -0
- package/examples/postgres-basic/README.md +31 -0
- package/examples/postgres-basic/app.js +50 -0
- package/examples/prealpha-control-plane/README.md +50 -0
- package/examples/prealpha-control-plane/appsettings.Development.json +11 -0
- package/examples/prealpha-control-plane/appsettings.json +15 -0
- package/examples/prealpha-control-plane/sloppy.json +5 -0
- package/examples/prealpha-control-plane/src/db/schema.js +7 -0
- package/examples/prealpha-control-plane/src/db/seed.js +6 -0
- package/examples/prealpha-control-plane/src/main.js +21 -0
- package/examples/prealpha-control-plane/src/routes/apps.js +34 -0
- package/examples/prealpha-control-plane/src/routes/builds.js +25 -0
- package/examples/prealpha-control-plane/src/routes/deployments.js +19 -0
- package/examples/prealpha-control-plane/src/routes/diagnostics.js +11 -0
- package/examples/prealpha-control-plane/src/routes/health.js +27 -0
- package/examples/prealpha-control-plane/src/routes/projects.js +38 -0
- package/examples/prealpha-control-plane/src/services/diagnosticsSink.js +11 -0
- package/examples/prealpha-control-plane/src/services/repositories.js +9 -0
- package/examples/prealpha-control-plane/src/validation/schemas.js +6 -0
- package/examples/program-fs-process/README.md +31 -0
- package/examples/program-fs-process/sloppy.json +9 -0
- package/examples/program-fs-process/src/main.ts +27 -0
- package/examples/program-hello/README.md +32 -0
- package/examples/program-hello/main.ts +8 -0
- package/examples/program-hello/message.ts +1 -0
- package/examples/program-hello/sloppy.json +5 -0
- package/examples/rate-limit-auth/README.md +3 -0
- package/examples/rate-limit-auth/app.js +14 -0
- package/examples/rate-limit-basic/README.md +3 -0
- package/examples/rate-limit-basic/app.js +13 -0
- package/examples/rate-limit-redis/README.md +5 -0
- package/examples/rate-limit-redis/app.js +20 -0
- package/examples/rate-limit-testhost/README.md +4 -0
- package/examples/rate-limit-testhost/app.js +13 -0
- package/examples/rate-limit-websocket/README.md +3 -0
- package/examples/rate-limit-websocket/app.js +16 -0
- package/examples/realtime-auth/README.md +8 -0
- package/examples/realtime-auth/app.js +25 -0
- package/examples/realtime-auth/test.mjs +43 -0
- package/examples/realtime-chat/README.md +8 -0
- package/examples/realtime-chat/app.js +32 -0
- package/examples/realtime-chat/test.mjs +52 -0
- package/examples/realtime-dashboard/README.md +20 -0
- package/examples/realtime-dashboard/app.js +37 -0
- package/examples/realtime-presence/README.md +8 -0
- package/examples/realtime-presence/app.js +32 -0
- package/examples/realtime-presence/test.mjs +50 -0
- package/examples/realtime-testhost/README.md +8 -0
- package/examples/realtime-testhost/test.mjs +31 -0
- package/examples/redis-basic/README.md +17 -0
- package/examples/redis-basic/app.js +39 -0
- package/examples/redis-cache/README.md +14 -0
- package/examples/redis-cache/app.js +36 -0
- package/examples/redis-locks/README.md +13 -0
- package/examples/redis-locks/app.js +49 -0
- package/examples/request-context/README.md +32 -0
- package/examples/request-context/app.js +15 -0
- package/examples/sqlite-basic/README.md +52 -0
- package/examples/sqlite-basic/app.js +56 -0
- package/examples/sqlserver-basic/README.md +36 -0
- package/examples/sqlserver-basic/app.js +59 -0
- package/examples/static-files-basic/README.md +11 -0
- package/examples/static-files-basic/app.js +12 -0
- package/examples/static-files-basic/public/app.js +1 -0
- package/examples/static-files-basic/public/site.css +3 -0
- package/examples/static-files-package/README.md +12 -0
- package/examples/static-files-package/app.js +10 -0
- package/examples/static-files-package/public/index.html +2 -0
- package/examples/static-files-precompressed/README.md +12 -0
- package/examples/static-files-precompressed/app.js +11 -0
- package/examples/static-files-precompressed/public/app.js +1 -0
- package/examples/static-files-precompressed/public/app.js.br +0 -0
- package/examples/static-files-precompressed/public/app.js.gz +0 -0
- package/examples/static-files-spa/README.md +12 -0
- package/examples/static-files-spa/app.js +16 -0
- package/examples/static-files-spa/dist/assets/app.js +1 -0
- package/examples/static-files-spa/dist/index.html +4 -0
- package/examples/static-files-testhost/README.md +8 -0
- package/examples/static-files-testhost/app.js +13 -0
- package/examples/static-files-testhost/public/app.js +1 -0
- package/examples/static-files-testhost/public/app.js.gz +0 -0
- package/examples/static-files-testhost/test.mjs +38 -0
- package/examples/testhost-basic/README.md +26 -0
- package/examples/testhost-db/README.md +31 -0
- package/examples/testservices-postgres/README.md +68 -0
- package/examples/testservices-redis/README.md +71 -0
- package/examples/testservices-sqlserver/README.md +75 -0
- package/examples/time-basic/README.md +18 -0
- package/examples/time-basic/app.js +12 -0
- package/examples/time-deadline-cancellation/README.md +11 -0
- package/examples/time-deadline-cancellation/app.js +27 -0
- package/examples/time-fake-clock/README.md +14 -0
- package/examples/time-fake-clock/app.js +25 -0
- package/examples/time-interval-schedule/README.md +13 -0
- package/examples/time-interval-schedule/app.js +60 -0
- package/examples/users-api-sqlite/README.md +74 -0
- package/examples/users-api-sqlite/app.js +11 -0
- package/examples/users-api-sqlite/appsettings.Development.json +11 -0
- package/examples/users-api-sqlite/appsettings.json +11 -0
- package/examples/users-api-sqlite/modules/users.js +40 -0
- package/examples/users-api-sqlite/sloppy.json +5 -0
- package/examples/validation-errors/README.md +36 -0
- package/examples/validation-errors/app.js +14 -0
- package/examples/validation-errors/invalid-user.http +6 -0
- package/examples/validation-errors/sloppy.json +5 -0
- package/examples/web-dynamic-routes/README.md +17 -0
- package/examples/web-dynamic-routes/app.ts +27 -0
- package/examples/webhooks-basic/README.md +11 -0
- package/examples/webhooks-basic/app.js +48 -0
- package/examples/websocket-auth/README.md +8 -0
- package/examples/websocket-auth/app.js +16 -0
- package/examples/websocket-echo/README.md +9 -0
- package/examples/websocket-echo/app.js +36 -0
- package/examples/websocket-json-schema/README.md +5 -0
- package/examples/websocket-json-schema/app.js +25 -0
- package/examples/websocket-testhost/README.md +11 -0
- package/examples/websocket-testhost/test.mjs +49 -0
- package/examples/workers-background-service/README.md +7 -0
- package/examples/workers-background-service/app.js +16 -0
- package/examples/workers-js-isolate/README.md +8 -0
- package/examples/workers-js-isolate/app.js +19 -0
- package/examples/workers-js-isolate/workers/parser.ts +11 -0
- package/examples/workers-shutdown/README.md +6 -0
- package/examples/workers-shutdown/app.js +26 -0
- package/examples/workers-workerpool/README.md +6 -0
- package/examples/workers-workerpool/app.js +23 -0
- package/examples/workers-workqueue/README.md +8 -0
- package/examples/workers-workqueue/app.js +24 -0
- package/manifest.json +59 -0
- package/package.json +31 -0
- package/stdlib/sloppy/README.md +177 -0
- package/stdlib/sloppy/app.js +2142 -0
- package/stdlib/sloppy/auth.js +1813 -0
- package/stdlib/sloppy/bootstrap.manifest.json +83 -0
- package/stdlib/sloppy/cache.js +1542 -0
- package/stdlib/sloppy/codec.js +1153 -0
- package/stdlib/sloppy/config.js +61 -0
- package/stdlib/sloppy/crypto.js +312 -0
- package/stdlib/sloppy/data.js +2945 -0
- package/stdlib/sloppy/ffi.js +185 -0
- package/stdlib/sloppy/fs.js +795 -0
- package/stdlib/sloppy/health.js +603 -0
- package/stdlib/sloppy/http.js +1595 -0
- package/stdlib/sloppy/index.js +59 -0
- package/stdlib/sloppy/internal/bytes.js +31 -0
- package/stdlib/sloppy/internal/capabilities.js +155 -0
- package/stdlib/sloppy/internal/config.js +640 -0
- package/stdlib/sloppy/internal/disposable.js +31 -0
- package/stdlib/sloppy/internal/headers.js +63 -0
- package/stdlib/sloppy/internal/intrinsics.js +2 -0
- package/stdlib/sloppy/internal/json.js +20 -0
- package/stdlib/sloppy/internal/logging.js +278 -0
- package/stdlib/sloppy/internal/modules.js +405 -0
- package/stdlib/sloppy/internal/redaction.js +87 -0
- package/stdlib/sloppy/internal/routes.js +2279 -0
- package/stdlib/sloppy/internal/runtime-classic.js +19837 -0
- package/stdlib/sloppy/internal/services.js +690 -0
- package/stdlib/sloppy/internal/shared.js +32 -0
- package/stdlib/sloppy/internal/testhost-diagnostics.js +88 -0
- package/stdlib/sloppy/internal/testhost-http-server.js +238 -0
- package/stdlib/sloppy/internal/testhost-http.js +118 -0
- package/stdlib/sloppy/internal/testhost-loopback.js +50 -0
- package/stdlib/sloppy/internal/testservices-docker.js +154 -0
- package/stdlib/sloppy/internal/validation.js +117 -0
- package/stdlib/sloppy/metrics.js +427 -0
- package/stdlib/sloppy/net.js +5208 -0
- package/stdlib/sloppy/node/assert/strict.js +39 -0
- package/stdlib/sloppy/node/assert.js +228 -0
- package/stdlib/sloppy/node/buffer.js +247 -0
- package/stdlib/sloppy/node/console.js +33 -0
- package/stdlib/sloppy/node/constants.js +9 -0
- package/stdlib/sloppy/node/crypto.js +89 -0
- package/stdlib/sloppy/node/diagnostics_channel.js +41 -0
- package/stdlib/sloppy/node/events.js +113 -0
- package/stdlib/sloppy/node/fs/promises.js +27 -0
- package/stdlib/sloppy/node/fs.js +280 -0
- package/stdlib/sloppy/node/http.js +11 -0
- package/stdlib/sloppy/node/https.js +11 -0
- package/stdlib/sloppy/node/module.js +40 -0
- package/stdlib/sloppy/node/os.js +22 -0
- package/stdlib/sloppy/node/path.js +78 -0
- package/stdlib/sloppy/node/perf_hooks.js +12 -0
- package/stdlib/sloppy/node/process.js +129 -0
- package/stdlib/sloppy/node/querystring.js +21 -0
- package/stdlib/sloppy/node/stream/promises.js +3 -0
- package/stdlib/sloppy/node/stream.js +132 -0
- package/stdlib/sloppy/node/string_decoder.js +23 -0
- package/stdlib/sloppy/node/timers.js +26 -0
- package/stdlib/sloppy/node/tty.js +18 -0
- package/stdlib/sloppy/node/url.js +17 -0
- package/stdlib/sloppy/node/util.js +95 -0
- package/stdlib/sloppy/node/zlib.js +72 -0
- package/stdlib/sloppy/orm.js +2188 -0
- package/stdlib/sloppy/os.js +580 -0
- package/stdlib/sloppy/problem-details.js +29 -0
- package/stdlib/sloppy/providers/sqlite.js +26 -0
- package/stdlib/sloppy/rate-limit.js +856 -0
- package/stdlib/sloppy/realtime.js +1508 -0
- package/stdlib/sloppy/redis.js +1272 -0
- package/stdlib/sloppy/request-id.js +184 -0
- package/stdlib/sloppy/request-logging.js +101 -0
- package/stdlib/sloppy/results.js +933 -0
- package/stdlib/sloppy/schema.js +546 -0
- package/stdlib/sloppy/testing.js +4081 -0
- package/stdlib/sloppy/testservices.js +1041 -0
- package/stdlib/sloppy/time.js +894 -0
- package/stdlib/sloppy/webhooks.js +1330 -0
- package/stdlib/sloppy/workers.js +986 -0
- package/templates/api/README.md +82 -0
- package/templates/api/appsettings.Development.json +14 -0
- package/templates/api/appsettings.json +13 -0
- package/templates/api/data/.gitkeep +1 -0
- package/templates/api/gitignore +4 -0
- package/templates/api/migrations/0001_create_users.sql +1 -0
- package/templates/api/package.json +16 -0
- package/templates/api/public/hello.txt +1 -0
- package/templates/api/sloppy.json +14 -0
- package/templates/api/src/config.ts +1 -0
- package/templates/api/src/db/migrate.ts +14 -0
- package/templates/api/src/db/schema.ts +4 -0
- package/templates/api/src/db/usersRepository.ts +23 -0
- package/templates/api/src/main.ts +18 -0
- package/templates/api/src/models/user.ts +7 -0
- package/templates/api/src/routes/health.ts +20 -0
- package/templates/api/src/routes/users.ts +40 -0
- package/templates/api/src/services/usersService.ts +21 -0
- package/templates/api/tsconfig.json +15 -0
- package/templates/cli/README.md +16 -0
- package/templates/cli/gitignore +2 -0
- package/templates/cli/package.json +13 -0
- package/templates/cli/sloppy.json +6 -0
- package/templates/cli/src/commands/echo.ts +9 -0
- package/templates/cli/src/commands/inspect.ts +20 -0
- package/templates/cli/src/main.ts +50 -0
- package/templates/cli/tsconfig.json +15 -0
- package/templates/minimal-api/README.md +14 -0
- package/templates/minimal-api/gitignore +3 -0
- package/templates/minimal-api/package.json +14 -0
- package/templates/minimal-api/sloppy.json +5 -0
- package/templates/minimal-api/src/main.ts +9 -0
- package/templates/minimal-api/tsconfig.json +15 -0
- package/templates/node-compat/README.md +40 -0
- package/templates/node-compat/gitignore +2 -0
- package/templates/node-compat/package.json +11 -0
- package/templates/node-compat/sloppy.json +6 -0
- package/templates/node-compat/src/main.ts +40 -0
- package/templates/package-api/README.md +44 -0
- package/templates/package-api/fixtures/validator-lite/index.js +7 -0
- package/templates/package-api/fixtures/validator-lite/package.json +6 -0
- package/templates/package-api/gitignore +3 -0
- package/templates/package-api/package.json +17 -0
- package/templates/package-api/sloppy.json +5 -0
- package/templates/package-api/src/main.ts +10 -0
- package/templates/package-api/src/routes/health.ts +5 -0
- package/templates/package-api/src/routes/users.ts +12 -0
- package/templates/package-api/tsconfig.json +15 -0
- package/templates/program/README.md +12 -0
- package/templates/program/gitignore +1 -0
- package/templates/program/package.json +10 -0
- package/templates/program/sloppy.json +6 -0
- package/templates/program/src/main.ts +9 -0
|
@@ -0,0 +1,4081 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import { Base64Url, Text } from "./codec.js";
|
|
5
|
+
import { Cache, isCache } from "./cache.js";
|
|
6
|
+
import { Hmac, Secret } from "./crypto.js";
|
|
7
|
+
import { data, Migrations } from "./data.js";
|
|
8
|
+
import { Directory, File } from "./fs.js";
|
|
9
|
+
import { createTestHttpServiceOverrides, TestHttp } from "./http.js";
|
|
10
|
+
import { HttpClient } from "./net.js";
|
|
11
|
+
import { Process as SloppyProcess, System as SloppySystem } from "./os.js";
|
|
12
|
+
import { RAW_JSON_BODY, serializeJson } from "./results.js";
|
|
13
|
+
import { isRealtimeChannel } from "./realtime.js";
|
|
14
|
+
import { Schema, validationProblem } from "./schema.js";
|
|
15
|
+
import { TestServices as ImportedTestServices } from "./testservices.js";
|
|
16
|
+
import { createDiagnosticsStore, normalizeOverrideMap } from "./internal/testhost-diagnostics.js";
|
|
17
|
+
import { startHttpMockServer } from "./internal/testhost-http-server.js";
|
|
18
|
+
import {
|
|
19
|
+
assertHeaderName,
|
|
20
|
+
assertHeaderValue,
|
|
21
|
+
copyBytes,
|
|
22
|
+
createHeadersLike,
|
|
23
|
+
headerEntriesFromObject,
|
|
24
|
+
headersToEntries,
|
|
25
|
+
responseHeaderEntries,
|
|
26
|
+
setDefaultHeader,
|
|
27
|
+
} from "./internal/testhost-http.js";
|
|
28
|
+
import {
|
|
29
|
+
loopbackAuthority,
|
|
30
|
+
releaseLoopbackReservation,
|
|
31
|
+
reserveLoopbackPort,
|
|
32
|
+
validateLoopbackPort,
|
|
33
|
+
} from "./internal/testhost-loopback.js";
|
|
34
|
+
import { isHttpToken, isPlainObject } from "./internal/validation.js";
|
|
35
|
+
|
|
36
|
+
const SUPPORTED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
37
|
+
const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
38
|
+
const PROBLEM_CONTENT_TYPE = "application/problem+json; charset=utf-8";
|
|
39
|
+
const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
40
|
+
const TestServices = Object.freeze(ImportedTestServices);
|
|
41
|
+
const TESTHOST_BINARY_BODY = Symbol("sloppyTestHostBinaryBody");
|
|
42
|
+
const TESTHOST_TEXT_BODY = Symbol("sloppyTestHostTextBody");
|
|
43
|
+
const DEFAULT_SERIALIZATION_OPTIONS = Object.freeze({
|
|
44
|
+
json: undefined,
|
|
45
|
+
contentNegotiation: Object.freeze({
|
|
46
|
+
strictAccept: false,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
const ROUTE_PARAM_PATTERN = /^\{([A-Za-z_][0-9A-Za-z_]*)(?::(str|int|uuid|alpha|float))?\}$/u;
|
|
50
|
+
const WEBSOCKET_ROUTE_OPTIONS = Symbol.for("sloppy.websocket.routeOptions");
|
|
51
|
+
const STATIC_MIME_TYPES = Object.freeze({
|
|
52
|
+
".css": "text/css; charset=utf-8",
|
|
53
|
+
".gif": "image/gif",
|
|
54
|
+
".html": "text/html; charset=utf-8",
|
|
55
|
+
".ico": "image/x-icon",
|
|
56
|
+
".jpeg": "image/jpeg",
|
|
57
|
+
".jpg": "image/jpeg",
|
|
58
|
+
".js": "application/javascript; charset=utf-8",
|
|
59
|
+
".json": "application/json; charset=utf-8",
|
|
60
|
+
".map": "application/json; charset=utf-8",
|
|
61
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
62
|
+
".otf": "font/otf",
|
|
63
|
+
".pdf": "application/pdf",
|
|
64
|
+
".png": "image/png",
|
|
65
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
66
|
+
".ttf": "font/ttf",
|
|
67
|
+
".txt": "text/plain; charset=utf-8",
|
|
68
|
+
".wasm": "application/wasm",
|
|
69
|
+
".webp": "image/webp",
|
|
70
|
+
".woff": "font/woff",
|
|
71
|
+
".woff2": "font/woff2",
|
|
72
|
+
".xml": "application/xml; charset=utf-8",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function nowMs() {
|
|
76
|
+
if (globalThis.performance !== undefined && typeof globalThis.performance.now === "function") {
|
|
77
|
+
return globalThis.performance.now();
|
|
78
|
+
}
|
|
79
|
+
return Date.now();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function bodySourceCount(options) {
|
|
83
|
+
return ["body", "text", "json"].filter((key) => options?.[key] !== undefined).length +
|
|
84
|
+
(options?.[TESTHOST_TEXT_BODY] === undefined ? 0 : 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeRequestBodyWithOptions(options, headerEntries, serializationOptions) {
|
|
88
|
+
if (options === undefined || options === null) {
|
|
89
|
+
return new Uint8Array(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!isPlainObject(options)) {
|
|
93
|
+
throw new TypeError("Sloppy test host request options must be a plain object.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sources = bodySourceCount(options);
|
|
97
|
+
if (sources > 1) {
|
|
98
|
+
throw new TypeError("Sloppy test host request options must use one body source.");
|
|
99
|
+
}
|
|
100
|
+
if (sources === 0) {
|
|
101
|
+
return new Uint8Array(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let bytes;
|
|
105
|
+
if (options.json !== undefined) {
|
|
106
|
+
let text;
|
|
107
|
+
try {
|
|
108
|
+
text = serializeJson(options.json, serializationOptions.json);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new TypeError(`Sloppy test host JSON body could not be serialized: ${error.message}`);
|
|
111
|
+
}
|
|
112
|
+
if (text === undefined) {
|
|
113
|
+
throw new TypeError("Sloppy test host JSON body must be JSON serializable.");
|
|
114
|
+
}
|
|
115
|
+
bytes = Text.utf8.encode(text);
|
|
116
|
+
setDefaultHeader(headerEntries, "Content-Type", JSON_CONTENT_TYPE);
|
|
117
|
+
} else if (options.text !== undefined) {
|
|
118
|
+
bytes = Text.utf8.encode(String(options.text));
|
|
119
|
+
setDefaultHeader(headerEntries, "Content-Type", TEXT_CONTENT_TYPE);
|
|
120
|
+
} else if (options[TESTHOST_TEXT_BODY] !== undefined) {
|
|
121
|
+
bytes = Text.utf8.encode(String(options[TESTHOST_TEXT_BODY]));
|
|
122
|
+
} else if (typeof options.body === "string") {
|
|
123
|
+
bytes = Text.utf8.encode(options.body);
|
|
124
|
+
} else {
|
|
125
|
+
bytes = copyBytes(options.body, "body");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (bytes.byteLength !== 0) {
|
|
129
|
+
setDefaultHeader(headerEntries, "Content-Length", String(bytes.byteLength));
|
|
130
|
+
}
|
|
131
|
+
return bytes;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeRequestBody(options, headerEntries) {
|
|
135
|
+
return normalizeRequestBodyWithOptions(options, headerEntries, DEFAULT_SERIALIZATION_OPTIONS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function splitTarget(target) {
|
|
139
|
+
if (typeof target !== "string" || target.length === 0 || !target.startsWith("/")) {
|
|
140
|
+
throw new TypeError("Sloppy test host target must be a request target starting with '/'.");
|
|
141
|
+
}
|
|
142
|
+
if (target.includes("#")) {
|
|
143
|
+
throw new TypeError("Sloppy test host target must not include a fragment.");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const queryIndex = target.indexOf("?");
|
|
147
|
+
const rawPath = queryIndex < 0 ? target : target.slice(0, queryIndex);
|
|
148
|
+
const queryString = queryIndex < 0 ? "" : target.slice(queryIndex + 1);
|
|
149
|
+
if (rawPath.length === 0 || !rawPath.startsWith("/")) {
|
|
150
|
+
throw new TypeError("Sloppy test host target path must start with '/'.");
|
|
151
|
+
}
|
|
152
|
+
const path = decodePercentComponent(rawPath, "path", false);
|
|
153
|
+
return { path, queryString, rawTarget: target };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function decodePercentComponent(value, subject, plusAsSpace) {
|
|
157
|
+
const encoded = plusAsSpace ? value.replace(/\+/gu, " ") : value;
|
|
158
|
+
if (/%(?![0-9A-Fa-f]{2})/u.test(encoded)) {
|
|
159
|
+
throw new TypeError(`Sloppy test host ${subject} percent escapes must use two hex digits.`);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return decodeURIComponent(encoded);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new TypeError(`Sloppy test host ${subject} percent escapes must be valid UTF-8.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseQuery(queryString) {
|
|
169
|
+
const query = {};
|
|
170
|
+
if (queryString.length === 0) {
|
|
171
|
+
return Object.freeze(query);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const pair of queryString.split("&")) {
|
|
175
|
+
const equals = pair.indexOf("=");
|
|
176
|
+
const name = equals < 0 ? pair : pair.slice(0, equals);
|
|
177
|
+
const value = equals < 0 ? "" : pair.slice(equals + 1);
|
|
178
|
+
query[decodePercentComponent(name, "query", true)] = decodePercentComponent(value, "query", true);
|
|
179
|
+
}
|
|
180
|
+
return Object.freeze(query);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function splitRouteSegments(value) {
|
|
184
|
+
if (value === "/") {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
if (!value.startsWith("/") || value.endsWith("/")) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
return value.slice(1).split("/");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parsePatternParam(segment) {
|
|
194
|
+
const match = ROUTE_PARAM_PATTERN.exec(segment);
|
|
195
|
+
if (match === null) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
return { name: match[1], type: match[2] ?? "str" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function routeSegmentMatchesParam(param, value) {
|
|
202
|
+
if (value.length === 0) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (param.type === "int") {
|
|
206
|
+
return /^[0-9]+$/u.test(value);
|
|
207
|
+
}
|
|
208
|
+
if (param.type === "uuid") {
|
|
209
|
+
return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/u.test(value);
|
|
210
|
+
}
|
|
211
|
+
if (param.type === "alpha") {
|
|
212
|
+
return /^[A-Za-z]+$/u.test(value);
|
|
213
|
+
}
|
|
214
|
+
if (param.type === "float") {
|
|
215
|
+
return /^[0-9]*\.[0-9]+$|^[0-9]+\.[0-9]*$/u.test(value);
|
|
216
|
+
}
|
|
217
|
+
return param.type === "str";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function matchRoutePattern(pattern, path) {
|
|
221
|
+
const patternSegments = splitRouteSegments(pattern);
|
|
222
|
+
const pathSegments = splitRouteSegments(path);
|
|
223
|
+
if (patternSegments === undefined || pathSegments === undefined || patternSegments.length !== pathSegments.length) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const route = {};
|
|
228
|
+
for (let index = 0; index < patternSegments.length; index += 1) {
|
|
229
|
+
const patternSegment = patternSegments[index];
|
|
230
|
+
const pathSegment = pathSegments[index];
|
|
231
|
+
const param = parsePatternParam(patternSegment);
|
|
232
|
+
if (param === undefined) {
|
|
233
|
+
if (patternSegment !== pathSegment) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!routeSegmentMatchesParam(param, pathSegment)) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
route[param.name] = pathSegment;
|
|
242
|
+
}
|
|
243
|
+
return Object.freeze(route);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mediaType(contentType) {
|
|
247
|
+
return contentType.split(";", 1)[0].trim().toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function contentTypeParameter(contentType, name) {
|
|
251
|
+
const target = name.toLowerCase();
|
|
252
|
+
for (const part of contentType.split(";").slice(1)) {
|
|
253
|
+
const equals = part.indexOf("=");
|
|
254
|
+
if (equals < 0) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const key = part.slice(0, equals).trim().toLowerCase();
|
|
258
|
+
let value = part.slice(equals + 1).trim();
|
|
259
|
+
if (value.length >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
|
260
|
+
value = value.slice(1, -1);
|
|
261
|
+
}
|
|
262
|
+
if (key === target) {
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isJsonMediaType(contentType) {
|
|
270
|
+
const type = mediaType(contentType);
|
|
271
|
+
return type === "application/json" || (type.startsWith("application/") && type.endsWith("+json"));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function acceptsMediaType(accept, contentType) {
|
|
275
|
+
if (accept === undefined || accept.trim().length === 0) {
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
const responseType = mediaType(contentType);
|
|
279
|
+
const slash = responseType.indexOf("/");
|
|
280
|
+
const responseTop = slash < 0 ? responseType : responseType.slice(0, slash);
|
|
281
|
+
|
|
282
|
+
for (const rawPart of accept.split(",")) {
|
|
283
|
+
const part = rawPart.trim();
|
|
284
|
+
if (part.length === 0) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const [range, ...parameters] = part.split(";").map((value) => value.trim().toLowerCase());
|
|
288
|
+
const qParameter = parameters.find((parameter) => parameter.startsWith("q="));
|
|
289
|
+
if (qParameter !== undefined && Number(qParameter.slice(2)) === 0) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (range === "*/*" || range === responseType) {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
if (range.endsWith("/*") && range.slice(0, -2) === responseTop) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function bodyKindForRequest(headers, bodyBytes) {
|
|
303
|
+
if (bodyBytes.byteLength === 0) {
|
|
304
|
+
return "none";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const contentType = headers.get("content-type");
|
|
308
|
+
if (contentType === undefined) {
|
|
309
|
+
return "unsupported";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (isJsonMediaType(contentType)) {
|
|
313
|
+
try {
|
|
314
|
+
JSON.parse(Text.utf8.decode(bodyBytes));
|
|
315
|
+
} catch {
|
|
316
|
+
return "malformed-json";
|
|
317
|
+
}
|
|
318
|
+
return "json";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const type = mediaType(contentType);
|
|
322
|
+
if (type === "text/plain") {
|
|
323
|
+
return "text";
|
|
324
|
+
}
|
|
325
|
+
if (type === "application/octet-stream") {
|
|
326
|
+
return "bytes";
|
|
327
|
+
}
|
|
328
|
+
if (type === "application/x-www-form-urlencoded") {
|
|
329
|
+
return "form";
|
|
330
|
+
}
|
|
331
|
+
if (type === "multipart/form-data") {
|
|
332
|
+
const boundary = contentTypeParameter(contentType, "boundary");
|
|
333
|
+
return typeof boundary === "string" && boundary.length > 0
|
|
334
|
+
? "multipart"
|
|
335
|
+
: "malformed-multipart";
|
|
336
|
+
}
|
|
337
|
+
return "unsupported";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function parseCookieHeader(value) {
|
|
341
|
+
const cookies = new Map();
|
|
342
|
+
if (value === undefined) {
|
|
343
|
+
return cookies;
|
|
344
|
+
}
|
|
345
|
+
for (const pair of value.split(";")) {
|
|
346
|
+
const equals = pair.indexOf("=");
|
|
347
|
+
if (equals <= 0) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const name = pair.slice(0, equals).trim();
|
|
351
|
+
let rawValue = pair.slice(equals + 1).trim();
|
|
352
|
+
if (!isHttpToken(name)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (rawValue.length >= 2 && rawValue.startsWith("\"") && rawValue.endsWith("\"")) {
|
|
356
|
+
rawValue = rawValue.slice(1, -1);
|
|
357
|
+
}
|
|
358
|
+
cookies.set(name, decodePercentComponent(rawValue, "cookie", false));
|
|
359
|
+
}
|
|
360
|
+
return cookies;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function createCookiesLike(headers) {
|
|
364
|
+
const cookies = parseCookieHeader(headers.get("cookie"));
|
|
365
|
+
return Object.freeze({
|
|
366
|
+
get(name) {
|
|
367
|
+
if (typeof name !== "string") {
|
|
368
|
+
throw new TypeError("Sloppy test host cookies.get name must be a string.");
|
|
369
|
+
}
|
|
370
|
+
return cookies.get(name) ?? null;
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function createConfigOverlay(baseConfig, overrides, secrets) {
|
|
376
|
+
const configOverrides = normalizeOverrideMap(overrides, "config");
|
|
377
|
+
const secretOverrides = normalizeOverrideMap(secrets, "secret");
|
|
378
|
+
const merged = Object.freeze({
|
|
379
|
+
...configOverrides,
|
|
380
|
+
...secretOverrides,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
function hasOverride(key) {
|
|
384
|
+
return Object.prototype.hasOwnProperty.call(merged, key);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function valueOrBase(key, fallback, required) {
|
|
388
|
+
if (hasOverride(key)) {
|
|
389
|
+
return merged[key];
|
|
390
|
+
}
|
|
391
|
+
if (fallback !== undefined) {
|
|
392
|
+
return baseConfig.get(key, fallback);
|
|
393
|
+
}
|
|
394
|
+
return required ? baseConfig.require(key) : baseConfig.get(key);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return Object.freeze({
|
|
398
|
+
get(key, fallback) {
|
|
399
|
+
return valueOrBase(key, fallback, false);
|
|
400
|
+
},
|
|
401
|
+
has(key) {
|
|
402
|
+
return hasOverride(key) || baseConfig.has(key);
|
|
403
|
+
},
|
|
404
|
+
require(key) {
|
|
405
|
+
return valueOrBase(key, undefined, true);
|
|
406
|
+
},
|
|
407
|
+
getString(key, fallback) {
|
|
408
|
+
const value = valueOrBase(key, fallback, fallback === undefined);
|
|
409
|
+
if (typeof value !== "string") {
|
|
410
|
+
throw new TypeError(`Sloppy config key '${key}' must be a string.`);
|
|
411
|
+
}
|
|
412
|
+
return value;
|
|
413
|
+
},
|
|
414
|
+
getInt(key, fallback) {
|
|
415
|
+
const value = Number(valueOrBase(key, fallback, fallback === undefined));
|
|
416
|
+
if (!Number.isInteger(value)) {
|
|
417
|
+
throw new TypeError(`Sloppy config key '${key}' must be an integer.`);
|
|
418
|
+
}
|
|
419
|
+
return value;
|
|
420
|
+
},
|
|
421
|
+
getNumber(key, fallback) {
|
|
422
|
+
const value = Number(valueOrBase(key, fallback, fallback === undefined));
|
|
423
|
+
if (!Number.isFinite(value)) {
|
|
424
|
+
throw new TypeError(`Sloppy config key '${key}' must be a number.`);
|
|
425
|
+
}
|
|
426
|
+
return value;
|
|
427
|
+
},
|
|
428
|
+
getBool(key, fallback) {
|
|
429
|
+
const value = valueOrBase(key, fallback, fallback === undefined);
|
|
430
|
+
if (typeof value === "boolean") {
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
if (typeof value === "string" && /^(true|false)$/iu.test(value)) {
|
|
434
|
+
return value.toLowerCase() === "true";
|
|
435
|
+
}
|
|
436
|
+
throw new TypeError(`Sloppy config key '${key}' must be a boolean.`);
|
|
437
|
+
},
|
|
438
|
+
getBoolean(key, fallback) {
|
|
439
|
+
return this.getBool(key, fallback);
|
|
440
|
+
},
|
|
441
|
+
getDuration(key, fallback) {
|
|
442
|
+
if (!hasOverride(key)) {
|
|
443
|
+
return baseConfig.getDuration(key, fallback);
|
|
444
|
+
}
|
|
445
|
+
const value = valueOrBase(key, fallback, fallback === undefined);
|
|
446
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
if (typeof value === "string") {
|
|
450
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)$/iu);
|
|
451
|
+
if (match !== null) {
|
|
452
|
+
const factors = { ms: 1, s: 1000, m: 60 * 1000, h: 60 * 60 * 1000 };
|
|
453
|
+
return Number(match[1]) * factors[match[2].toLowerCase()];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
throw new TypeError(`Sloppy config key '${key}' must be a duration in ms, s, m, or h.`);
|
|
457
|
+
},
|
|
458
|
+
getBytes(key, fallback) {
|
|
459
|
+
if (!hasOverride(key)) {
|
|
460
|
+
return baseConfig.getBytes(key, fallback);
|
|
461
|
+
}
|
|
462
|
+
const value = valueOrBase(key, fallback, fallback === undefined);
|
|
463
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
464
|
+
return value;
|
|
465
|
+
}
|
|
466
|
+
if (typeof value === "string") {
|
|
467
|
+
const match = value.trim().match(/^(\d+)\s*(b|kb|mb|gb|kib|mib|gib)$/iu);
|
|
468
|
+
if (match !== null) {
|
|
469
|
+
const factors = {
|
|
470
|
+
b: 1,
|
|
471
|
+
kb: 1000,
|
|
472
|
+
mb: 1000 * 1000,
|
|
473
|
+
gb: 1000 * 1000 * 1000,
|
|
474
|
+
kib: 1024,
|
|
475
|
+
mib: 1024 * 1024,
|
|
476
|
+
gib: 1024 * 1024 * 1024,
|
|
477
|
+
};
|
|
478
|
+
return Number(match[1]) * factors[match[2].toLowerCase()];
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
throw new TypeError(`Sloppy config key '${key}' must be a byte size.`);
|
|
482
|
+
},
|
|
483
|
+
getArray(key, fallback) {
|
|
484
|
+
const value = valueOrBase(key, fallback, fallback === undefined);
|
|
485
|
+
if (!Array.isArray(value)) {
|
|
486
|
+
throw new TypeError(`Sloppy config key '${key}' must be an array.`);
|
|
487
|
+
}
|
|
488
|
+
return Object.freeze([...value]);
|
|
489
|
+
},
|
|
490
|
+
getObject(key, fallback) {
|
|
491
|
+
const value = valueOrBase(key, fallback, fallback === undefined);
|
|
492
|
+
if (!isPlainObject(value)) {
|
|
493
|
+
throw new TypeError(`Sloppy config key '${key}' must be an object.`);
|
|
494
|
+
}
|
|
495
|
+
return Object.freeze({ ...value });
|
|
496
|
+
},
|
|
497
|
+
getSecret(key) {
|
|
498
|
+
const value = valueOrBase(key, undefined, true);
|
|
499
|
+
return Object.freeze({
|
|
500
|
+
value() {
|
|
501
|
+
return String(value);
|
|
502
|
+
},
|
|
503
|
+
toString() {
|
|
504
|
+
return "[Secret redacted]";
|
|
505
|
+
},
|
|
506
|
+
toJSON() {
|
|
507
|
+
return "[Secret redacted]";
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
bind(key, schema) {
|
|
512
|
+
if (Object.keys(merged).some((entry) => entry === key || entry.startsWith(`${key}:`))) {
|
|
513
|
+
const object = {};
|
|
514
|
+
const prefix = `${key}:`;
|
|
515
|
+
for (const [entryKey, entryValue] of Object.entries(merged)) {
|
|
516
|
+
if (entryKey.startsWith(prefix)) {
|
|
517
|
+
object[entryKey.slice(prefix.length)] = entryValue;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return Object.freeze({
|
|
521
|
+
...baseConfig.bind(key, schema),
|
|
522
|
+
...object,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
return baseConfig.bind(key, schema);
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function disposeOverrideValues(values) {
|
|
531
|
+
const pending = [];
|
|
532
|
+
const seen = new Set();
|
|
533
|
+
for (const value of values) {
|
|
534
|
+
if (value === null || value === undefined) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (seen.has(value)) {
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
seen.add(value);
|
|
541
|
+
const cleanup = value[Symbol.asyncDispose] ?? value[Symbol.dispose] ?? value.dispose ?? value.close;
|
|
542
|
+
if (typeof cleanup === "function") {
|
|
543
|
+
pending.push(Promise.resolve(cleanup.call(value)));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return Promise.all(pending).then(() => undefined);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function createServiceOverlay(baseServices, serviceOverrides, providerOverrides, cacheOverrides, httpClientOverrides, redisOverrides) {
|
|
550
|
+
const serviceMap = normalizeOverrideMap(serviceOverrides, "service");
|
|
551
|
+
const httpOverrideMap = createTestHttpServiceOverrides(httpClientOverrides);
|
|
552
|
+
const providerMap = normalizeOverrideMap(providerOverrides, "provider");
|
|
553
|
+
const cacheMap = normalizeOverrideMap(cacheOverrides, "cache");
|
|
554
|
+
const redisMap = normalizeOverrideMap(redisOverrides, "redis");
|
|
555
|
+
const merged = new Map(Object.entries(serviceMap));
|
|
556
|
+
const httpOverrides = new Map(Object.entries(httpOverrideMap));
|
|
557
|
+
for (const [name, provider] of Object.entries(providerMap)) {
|
|
558
|
+
if (provider === null || typeof provider !== "object") {
|
|
559
|
+
throw new TypeError(`Sloppy TestHost provider override '${name}' must be an object.`);
|
|
560
|
+
}
|
|
561
|
+
merged.set(name, provider);
|
|
562
|
+
merged.set(`data.${name}`, provider);
|
|
563
|
+
}
|
|
564
|
+
for (const [name, redis] of Object.entries(redisMap)) {
|
|
565
|
+
if (redis === null || typeof redis !== "object") {
|
|
566
|
+
throw new TypeError(`Sloppy TestHost redis override '${name}' must be an object.`);
|
|
567
|
+
}
|
|
568
|
+
merged.set(`redis.${name}`, redis);
|
|
569
|
+
}
|
|
570
|
+
for (const [name, cache] of Object.entries(cacheMap)) {
|
|
571
|
+
if (!isCache(cache)) {
|
|
572
|
+
throw new TypeError(`Sloppy TestHost cache override '${name}' must be a Cache instance.`);
|
|
573
|
+
}
|
|
574
|
+
merged.set(Cache.token(name), cache);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function wrapScope(scope) {
|
|
578
|
+
function getHttpClientOverride(token) {
|
|
579
|
+
const registration = token?.__sloppyHttpClientRegistration;
|
|
580
|
+
if (registration?.kind === "typed" && httpOverrides.has(registration.namedToken)) {
|
|
581
|
+
return registration.createTyped(httpOverrides.get(registration.namedToken));
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return Object.freeze({
|
|
587
|
+
capabilities: scope.capabilities,
|
|
588
|
+
config: scope.config,
|
|
589
|
+
get(token) {
|
|
590
|
+
const httpClient = getHttpClientOverride(token);
|
|
591
|
+
if (httpClient !== undefined) {
|
|
592
|
+
return httpClient;
|
|
593
|
+
}
|
|
594
|
+
if (httpOverrides.has(token)) {
|
|
595
|
+
return httpOverrides.get(token);
|
|
596
|
+
}
|
|
597
|
+
const tokenKey = token?.__sloppyRedisToken ?? token;
|
|
598
|
+
if (merged.has(tokenKey)) {
|
|
599
|
+
return merged.get(tokenKey);
|
|
600
|
+
}
|
|
601
|
+
return scope.get(token);
|
|
602
|
+
},
|
|
603
|
+
tryGet(token) {
|
|
604
|
+
const httpClient = getHttpClientOverride(token);
|
|
605
|
+
if (httpClient !== undefined) {
|
|
606
|
+
return httpClient;
|
|
607
|
+
}
|
|
608
|
+
if (httpOverrides.has(token)) {
|
|
609
|
+
return httpOverrides.get(token);
|
|
610
|
+
}
|
|
611
|
+
const tokenKey = token?.__sloppyRedisToken ?? token;
|
|
612
|
+
if (merged.has(tokenKey)) {
|
|
613
|
+
return merged.get(tokenKey);
|
|
614
|
+
}
|
|
615
|
+
return scope.tryGet(token);
|
|
616
|
+
},
|
|
617
|
+
dispose() {
|
|
618
|
+
return scope.dispose();
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return Object.freeze({
|
|
624
|
+
get(token) {
|
|
625
|
+
const registration = token?.__sloppyHttpClientRegistration;
|
|
626
|
+
if (registration?.kind === "typed" && httpOverrides.has(registration.namedToken)) {
|
|
627
|
+
return registration.createTyped(httpOverrides.get(registration.namedToken));
|
|
628
|
+
}
|
|
629
|
+
if (httpOverrides.has(token)) {
|
|
630
|
+
return httpOverrides.get(token);
|
|
631
|
+
}
|
|
632
|
+
const tokenKey = token?.__sloppyRedisToken ?? token;
|
|
633
|
+
if (merged.has(tokenKey)) {
|
|
634
|
+
return merged.get(tokenKey);
|
|
635
|
+
}
|
|
636
|
+
return baseServices.get(token);
|
|
637
|
+
},
|
|
638
|
+
tryGet(token) {
|
|
639
|
+
const registration = token?.__sloppyHttpClientRegistration;
|
|
640
|
+
if (registration?.kind === "typed" && httpOverrides.has(registration.namedToken)) {
|
|
641
|
+
return registration.createTyped(httpOverrides.get(registration.namedToken));
|
|
642
|
+
}
|
|
643
|
+
if (httpOverrides.has(token)) {
|
|
644
|
+
return httpOverrides.get(token);
|
|
645
|
+
}
|
|
646
|
+
const tokenKey = token?.__sloppyRedisToken ?? token;
|
|
647
|
+
if (merged.has(tokenKey)) {
|
|
648
|
+
return merged.get(tokenKey);
|
|
649
|
+
}
|
|
650
|
+
return baseServices.tryGet(token);
|
|
651
|
+
},
|
|
652
|
+
createScope() {
|
|
653
|
+
return wrapScope(baseServices.createScope());
|
|
654
|
+
},
|
|
655
|
+
dispose() {
|
|
656
|
+
return Promise.all([
|
|
657
|
+
disposeOverrideValues(merged.values()),
|
|
658
|
+
disposeOverrideValues(httpOverrides.values()),
|
|
659
|
+
]).then(() => undefined);
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function arrayPairsLookup(pairs, name) {
|
|
665
|
+
for (let index = pairs.length - 1; index >= 0; index -= 1) {
|
|
666
|
+
if (pairs[index][0] === name) {
|
|
667
|
+
return pairs[index][1];
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function createFileLike(fieldName, name, contentType, bytes) {
|
|
674
|
+
const fileBytes = new Uint8Array(bytes);
|
|
675
|
+
return Object.freeze({
|
|
676
|
+
fieldName,
|
|
677
|
+
name,
|
|
678
|
+
contentType,
|
|
679
|
+
size: fileBytes.byteLength,
|
|
680
|
+
bytes() {
|
|
681
|
+
return new Uint8Array(fileBytes);
|
|
682
|
+
},
|
|
683
|
+
text() {
|
|
684
|
+
return Text.utf8.decode(fileBytes);
|
|
685
|
+
},
|
|
686
|
+
async saveTo(path) {
|
|
687
|
+
await File.writeBytes(path, fileBytes);
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function createFormLike(fields, files = []) {
|
|
693
|
+
const frozenFields = Object.freeze(fields.map(([name, value]) => Object.freeze([name, value])));
|
|
694
|
+
const frozenFiles = Object.freeze(files.map(([name, file]) => Object.freeze([name, file])));
|
|
695
|
+
return Object.freeze({
|
|
696
|
+
get(name) {
|
|
697
|
+
return arrayPairsLookup(frozenFields, name);
|
|
698
|
+
},
|
|
699
|
+
entries() {
|
|
700
|
+
return frozenFields[Symbol.iterator]();
|
|
701
|
+
},
|
|
702
|
+
file(name) {
|
|
703
|
+
return arrayPairsLookup(frozenFiles, name);
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function parseFormUrlEncoded(bytes) {
|
|
709
|
+
const fields = [];
|
|
710
|
+
const text = Text.utf8.decode(bytes);
|
|
711
|
+
if (text.length === 0) {
|
|
712
|
+
return createFormLike(fields);
|
|
713
|
+
}
|
|
714
|
+
for (const pair of text.split("&")) {
|
|
715
|
+
const equals = pair.indexOf("=");
|
|
716
|
+
const name = equals < 0 ? pair : pair.slice(0, equals);
|
|
717
|
+
const value = equals < 0 ? "" : pair.slice(equals + 1);
|
|
718
|
+
fields.push([
|
|
719
|
+
decodePercentComponent(name, "form field", true),
|
|
720
|
+
decodePercentComponent(value, "form value", true),
|
|
721
|
+
]);
|
|
722
|
+
}
|
|
723
|
+
return createFormLike(fields);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function parsePartHeaders(text) {
|
|
727
|
+
const headers = new Map();
|
|
728
|
+
for (const line of text.split("\r\n")) {
|
|
729
|
+
const colon = line.indexOf(":");
|
|
730
|
+
if (colon <= 0) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
|
|
734
|
+
}
|
|
735
|
+
return headers;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function parseContentDisposition(value) {
|
|
739
|
+
const output = {};
|
|
740
|
+
for (const part of value.split(";").slice(1)) {
|
|
741
|
+
const equals = part.indexOf("=");
|
|
742
|
+
if (equals < 0) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const key = part.slice(0, equals).trim().toLowerCase();
|
|
746
|
+
let fieldValue = part.slice(equals + 1).trim();
|
|
747
|
+
if (fieldValue.length >= 2 && fieldValue.startsWith("\"") && fieldValue.endsWith("\"")) {
|
|
748
|
+
fieldValue = fieldValue.slice(1, -1);
|
|
749
|
+
}
|
|
750
|
+
output[key] = fieldValue;
|
|
751
|
+
}
|
|
752
|
+
return output;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function parseMultipart(bytes, contentType) {
|
|
756
|
+
const boundary = contentTypeParameter(contentType, "boundary");
|
|
757
|
+
if (boundary === undefined || boundary.length === 0) {
|
|
758
|
+
throw new TypeError("Sloppy test host multipart boundary is required.");
|
|
759
|
+
}
|
|
760
|
+
// Test-host multipart parsing round-trips file parts through UTF-8 text.
|
|
761
|
+
// Do not use it to assert arbitrary binary upload byte fidelity.
|
|
762
|
+
const text = Text.utf8.decode(bytes);
|
|
763
|
+
const fields = [];
|
|
764
|
+
const files = [];
|
|
765
|
+
for (const rawPart of text.split(`--${boundary}`)) {
|
|
766
|
+
if (rawPart === "" || rawPart === "--\r\n" || rawPart === "--") {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const part = rawPart.startsWith("\r\n") ? rawPart.slice(2) : rawPart;
|
|
770
|
+
const headerEnd = part.indexOf("\r\n\r\n");
|
|
771
|
+
if (headerEnd < 0) {
|
|
772
|
+
throw new TypeError("Sloppy test host multipart part is malformed.");
|
|
773
|
+
}
|
|
774
|
+
const headers = parsePartHeaders(part.slice(0, headerEnd));
|
|
775
|
+
let body = part.slice(headerEnd + 4);
|
|
776
|
+
if (body.endsWith("\r\n")) {
|
|
777
|
+
body = body.slice(0, -2);
|
|
778
|
+
}
|
|
779
|
+
const disposition = parseContentDisposition(headers.get("content-disposition") ?? "");
|
|
780
|
+
if (disposition.name === undefined) {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (disposition.filename !== undefined) {
|
|
784
|
+
files.push([
|
|
785
|
+
disposition.name,
|
|
786
|
+
createFileLike(disposition.name, disposition.filename, headers.get("content-type") ?? "application/octet-stream", Text.utf8.encode(body)),
|
|
787
|
+
]);
|
|
788
|
+
} else {
|
|
789
|
+
fields.push([disposition.name, body]);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return createFormLike(fields, files);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function createBodyObject(kind, bytes) {
|
|
796
|
+
let consumed = false;
|
|
797
|
+
let textCache;
|
|
798
|
+
function readJson() {
|
|
799
|
+
if (consumed) {
|
|
800
|
+
throw new TypeError("Request body is already consumed.");
|
|
801
|
+
}
|
|
802
|
+
consumed = true;
|
|
803
|
+
if (kind !== "json") {
|
|
804
|
+
throw new TypeError("Request body is not available as JSON.");
|
|
805
|
+
}
|
|
806
|
+
textCache ??= Text.utf8.decode(bytes);
|
|
807
|
+
return JSON.parse(textCache);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const body = {
|
|
811
|
+
get consumed() {
|
|
812
|
+
return consumed;
|
|
813
|
+
},
|
|
814
|
+
bytes() {
|
|
815
|
+
if (consumed) {
|
|
816
|
+
throw new TypeError("Request body is already consumed.");
|
|
817
|
+
}
|
|
818
|
+
consumed = true;
|
|
819
|
+
return new Uint8Array(bytes);
|
|
820
|
+
},
|
|
821
|
+
text() {
|
|
822
|
+
if (consumed) {
|
|
823
|
+
throw new TypeError("Request body is already consumed.");
|
|
824
|
+
}
|
|
825
|
+
consumed = true;
|
|
826
|
+
textCache ??= Text.utf8.decode(bytes);
|
|
827
|
+
return textCache;
|
|
828
|
+
},
|
|
829
|
+
json() {
|
|
830
|
+
return readJson();
|
|
831
|
+
},
|
|
832
|
+
async validate(schema) {
|
|
833
|
+
return Schema.validate(readJson(), schema);
|
|
834
|
+
},
|
|
835
|
+
};
|
|
836
|
+
return Object.freeze(body);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function createRequestObject(method, targetParts, headers, bodyKind, bodyBytes) {
|
|
840
|
+
let textCache;
|
|
841
|
+
let formCache;
|
|
842
|
+
const request = {
|
|
843
|
+
method,
|
|
844
|
+
path: targetParts.path,
|
|
845
|
+
rawTarget: targetParts.rawTarget,
|
|
846
|
+
headers,
|
|
847
|
+
body: createBodyObject(bodyKind, bodyBytes),
|
|
848
|
+
contentType: headers.get("content-type") ?? null,
|
|
849
|
+
contentLength: bodyBytes.byteLength === 0 ? null : bodyBytes.byteLength,
|
|
850
|
+
bytes() {
|
|
851
|
+
return new Uint8Array(bodyBytes);
|
|
852
|
+
},
|
|
853
|
+
text() {
|
|
854
|
+
textCache ??= Text.utf8.decode(bodyBytes);
|
|
855
|
+
return textCache;
|
|
856
|
+
},
|
|
857
|
+
json() {
|
|
858
|
+
if (bodyKind !== "json") {
|
|
859
|
+
throw new TypeError("Request body is not available as JSON.");
|
|
860
|
+
}
|
|
861
|
+
textCache ??= Text.utf8.decode(bodyBytes);
|
|
862
|
+
return JSON.parse(textCache);
|
|
863
|
+
},
|
|
864
|
+
form() {
|
|
865
|
+
if (bodyKind !== "form") {
|
|
866
|
+
throw new TypeError("Request body is not available as form data.");
|
|
867
|
+
}
|
|
868
|
+
formCache ??= parseFormUrlEncoded(bodyBytes);
|
|
869
|
+
return formCache;
|
|
870
|
+
},
|
|
871
|
+
multipart() {
|
|
872
|
+
if (bodyKind !== "multipart") {
|
|
873
|
+
throw new TypeError("Request body is not available as multipart data.");
|
|
874
|
+
}
|
|
875
|
+
formCache ??= parseMultipart(bodyBytes, headers.get("content-type") ?? "");
|
|
876
|
+
return formCache;
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
return Object.freeze(request);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function signalObject() {
|
|
883
|
+
return Object.freeze({
|
|
884
|
+
aborted: false,
|
|
885
|
+
reason: null,
|
|
886
|
+
throwIfAborted() {},
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function createContext(app, hostState, method, targetParts, headers, route, matchedRoute, bodyKind, bodyBytes, options = undefined) {
|
|
891
|
+
const request = createRequestObject(method, targetParts, headers, bodyKind, bodyBytes);
|
|
892
|
+
const services = hostState.services.createScope();
|
|
893
|
+
const context = {
|
|
894
|
+
services,
|
|
895
|
+
db: services.tryGet("data.main") ?? services.tryGet("main"),
|
|
896
|
+
capabilities: app.capabilities,
|
|
897
|
+
config: hostState.config,
|
|
898
|
+
log: app.log,
|
|
899
|
+
diagnostics: hostState.diagnostics,
|
|
900
|
+
metrics: typeof app.__getMetricsRegistry === "function" ? app.__getMetricsRegistry() : undefined,
|
|
901
|
+
__sloppyTestHostMetrics: hostState.metrics,
|
|
902
|
+
get webhooks() {
|
|
903
|
+
const services = this.services;
|
|
904
|
+
return services !== undefined && services !== null && typeof services.tryGet === "function"
|
|
905
|
+
? services.tryGet("webhooks")
|
|
906
|
+
: undefined;
|
|
907
|
+
},
|
|
908
|
+
user: options?.user,
|
|
909
|
+
requireUser() {
|
|
910
|
+
if (this.user?.authenticated !== true) {
|
|
911
|
+
throw new Error("SLOPPY_E_AUTH_UNAUTHORIZED");
|
|
912
|
+
}
|
|
913
|
+
return this.user;
|
|
914
|
+
},
|
|
915
|
+
clock: hostState.clock,
|
|
916
|
+
route,
|
|
917
|
+
routePattern: matchedRoute?.pattern ?? null,
|
|
918
|
+
query: parseQuery(targetParts.queryString),
|
|
919
|
+
request,
|
|
920
|
+
body: request.body,
|
|
921
|
+
cookies: createCookiesLike(headers),
|
|
922
|
+
connection: Object.freeze({
|
|
923
|
+
id: "test-host",
|
|
924
|
+
protocol: "http",
|
|
925
|
+
scheme: "test",
|
|
926
|
+
secure: false,
|
|
927
|
+
remoteAddress: options?.remoteAddress ?? "test-host",
|
|
928
|
+
}),
|
|
929
|
+
signal: signalObject(),
|
|
930
|
+
deadline: null,
|
|
931
|
+
lifecycle: typeof app.__getLifecycle === "function"
|
|
932
|
+
? app.__getLifecycle()
|
|
933
|
+
: Object.freeze({
|
|
934
|
+
startupComplete: true,
|
|
935
|
+
shuttingDown: false,
|
|
936
|
+
}),
|
|
937
|
+
};
|
|
938
|
+
return Object.freeze(context);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function descriptorHeaders(result) {
|
|
942
|
+
const entries = [];
|
|
943
|
+
if (result?.headers !== undefined && result.headers !== null) {
|
|
944
|
+
for (const [name, value] of Object.entries(result.headers)) {
|
|
945
|
+
entries.push([name, value]);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (result?.location !== undefined) {
|
|
949
|
+
entries.push(["Location", result.location]);
|
|
950
|
+
}
|
|
951
|
+
if (result?.contentType !== undefined) {
|
|
952
|
+
entries.push(["Content-Type", result.contentType]);
|
|
953
|
+
}
|
|
954
|
+
if (Array.isArray(result?.setCookies)) {
|
|
955
|
+
for (const value of result.setCookies) {
|
|
956
|
+
entries.push(["Set-Cookie", value]);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return entries;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function assertExpectedResponseValue(actual, expected, subject) {
|
|
963
|
+
if (expected instanceof RegExp) {
|
|
964
|
+
if (!expected.test(String(actual ?? ""))) {
|
|
965
|
+
throw new Error(`Sloppy test host expected ${subject} to match ${expected}, got ${actual}.`);
|
|
966
|
+
}
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (!Object.is(actual, expected)) {
|
|
970
|
+
throw new Error(`Sloppy test host expected ${subject} ${expected}, got ${actual}.`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function assertDeepJsonEqual(actual, expected, subject) {
|
|
975
|
+
const actualText = serializeJson(actual);
|
|
976
|
+
const expectedText = serializeJson(expected);
|
|
977
|
+
if (actualText !== expectedText) {
|
|
978
|
+
throw new Error(`Sloppy test host expected ${subject} ${expectedText}, got ${actualText}.`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function assertJsonIncludes(actual, expected, subject) {
|
|
983
|
+
if (expected === undefined) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (expected === null || typeof expected !== "object" || Array.isArray(expected)) {
|
|
987
|
+
assertDeepJsonEqual(actual, expected, subject);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (actual === null || typeof actual !== "object" || Array.isArray(actual)) {
|
|
991
|
+
throw new Error(`Sloppy test host expected ${subject} to include ${serializeJson(expected)}, got ${serializeJson(actual)}.`);
|
|
992
|
+
}
|
|
993
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
994
|
+
if (!Object.hasOwn(actual, key)) {
|
|
995
|
+
throw new Error(`Sloppy test host expected ${subject} to include key '${key}'.`);
|
|
996
|
+
}
|
|
997
|
+
assertJsonIncludes(actual[key], value, `${subject}.${key}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function responseFromParts(status, headers, bodyBytes) {
|
|
1002
|
+
const body = new Uint8Array(bodyBytes);
|
|
1003
|
+
const headerEntries = [...headers];
|
|
1004
|
+
setDefaultHeader(headerEntries, "Content-Length", String(body.byteLength));
|
|
1005
|
+
const response = {
|
|
1006
|
+
status,
|
|
1007
|
+
headers: createHeadersLike(headerEntries),
|
|
1008
|
+
bytes() {
|
|
1009
|
+
return new Uint8Array(body);
|
|
1010
|
+
},
|
|
1011
|
+
text() {
|
|
1012
|
+
return Text.utf8.decode(body);
|
|
1013
|
+
},
|
|
1014
|
+
json() {
|
|
1015
|
+
return JSON.parse(Text.utf8.decode(body));
|
|
1016
|
+
},
|
|
1017
|
+
problem() {
|
|
1018
|
+
const value = JSON.parse(Text.utf8.decode(body));
|
|
1019
|
+
if (!isPlainObject(value) || !Number.isInteger(value.status)) {
|
|
1020
|
+
throw new TypeError("Sloppy test host response is not a ProblemDetails body.");
|
|
1021
|
+
}
|
|
1022
|
+
return value;
|
|
1023
|
+
},
|
|
1024
|
+
expectStatus(expected) {
|
|
1025
|
+
assertExpectedResponseValue(status, expected, "status");
|
|
1026
|
+
return response;
|
|
1027
|
+
},
|
|
1028
|
+
expectHeader(name, expected) {
|
|
1029
|
+
assertHeaderName(name, "response assertion");
|
|
1030
|
+
const actual = response.headers.get(name);
|
|
1031
|
+
if (actual === undefined) {
|
|
1032
|
+
throw new Error(`Sloppy test host expected response header '${name}'.`);
|
|
1033
|
+
}
|
|
1034
|
+
assertExpectedResponseValue(actual, expected, `header '${name}'`);
|
|
1035
|
+
return response;
|
|
1036
|
+
},
|
|
1037
|
+
expectJson(expectedOrSchema = undefined) {
|
|
1038
|
+
const value = response.json();
|
|
1039
|
+
if (expectedOrSchema !== undefined) {
|
|
1040
|
+
if (typeof expectedOrSchema?.validate === "function") {
|
|
1041
|
+
Schema.validate(value, expectedOrSchema);
|
|
1042
|
+
} else {
|
|
1043
|
+
assertDeepJsonEqual(value, expectedOrSchema, "JSON");
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return response;
|
|
1047
|
+
},
|
|
1048
|
+
expectText(expected = undefined) {
|
|
1049
|
+
const value = response.text();
|
|
1050
|
+
if (expected !== undefined) {
|
|
1051
|
+
assertExpectedResponseValue(value, expected, "text");
|
|
1052
|
+
}
|
|
1053
|
+
return response;
|
|
1054
|
+
},
|
|
1055
|
+
expectProblem(expected = {}) {
|
|
1056
|
+
const problem = response.problem();
|
|
1057
|
+
if (expected.status !== undefined) {
|
|
1058
|
+
assertExpectedResponseValue(problem.status, expected.status, "problem status");
|
|
1059
|
+
}
|
|
1060
|
+
if (expected.code !== undefined) {
|
|
1061
|
+
assertExpectedResponseValue(problem.code, expected.code, "problem code");
|
|
1062
|
+
}
|
|
1063
|
+
if (expected.title !== undefined) {
|
|
1064
|
+
assertExpectedResponseValue(problem.title, expected.title, "problem title");
|
|
1065
|
+
}
|
|
1066
|
+
return response;
|
|
1067
|
+
},
|
|
1068
|
+
expectNoBody() {
|
|
1069
|
+
if (body.byteLength !== 0) {
|
|
1070
|
+
throw new Error(`Sloppy test host expected no response body, got ${body.byteLength} byte(s).`);
|
|
1071
|
+
}
|
|
1072
|
+
return response;
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
return Object.freeze(response);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function responseFromText(status, text, contentType = TEXT_CONTENT_TYPE) {
|
|
1079
|
+
return responseFromParts(status, [["Content-Type", contentType]], Text.utf8.encode(text));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function responseFromErrorStatus(app, status, context = undefined, fallbackText = `${status}\n`) {
|
|
1083
|
+
if (typeof app.__handleErrorStatus === "function") {
|
|
1084
|
+
const result = app.__handleErrorStatus(status, context);
|
|
1085
|
+
if (result !== undefined) {
|
|
1086
|
+
return responseFromResult(result);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return responseFromText(status, fallbackText);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function nodePathModules() {
|
|
1093
|
+
return { fs: nodeFs, path: STATIC_PATH_MODULE };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function staticNormalizeFsPath(value) {
|
|
1097
|
+
return String(value).replace(/\\/gu, "/").replace(/\/+/gu, "/").replace(/\/$/u, "");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function staticPathIsAbsolute(value) {
|
|
1101
|
+
return /^\/|^[A-Za-z]:[\\/]/u.test(String(value)) || String(value).startsWith("\\\\");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function staticPathResolve(base, ...parts) {
|
|
1105
|
+
let output = String(base);
|
|
1106
|
+
for (const part of parts) {
|
|
1107
|
+
const value = String(part);
|
|
1108
|
+
if (value.length === 0) {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
output = staticPathIsAbsolute(value) ? value : `${output.replace(/[\\/]$/u, "")}/${value}`;
|
|
1112
|
+
}
|
|
1113
|
+
return staticNormalizeFsPath(output);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function staticPathRelative(root, file) {
|
|
1117
|
+
const normalizedRoot = staticNormalizeFsPath(root);
|
|
1118
|
+
const normalizedFile = staticNormalizeFsPath(file);
|
|
1119
|
+
const foldCase = globalThis.process?.platform === "win32";
|
|
1120
|
+
const rootKey = foldCase ? normalizedRoot.toLowerCase() : normalizedRoot;
|
|
1121
|
+
const fileKey = foldCase ? normalizedFile.toLowerCase() : normalizedFile;
|
|
1122
|
+
if (fileKey === rootKey) {
|
|
1123
|
+
return "";
|
|
1124
|
+
}
|
|
1125
|
+
const prefix = `${rootKey}/`;
|
|
1126
|
+
if (fileKey.startsWith(prefix)) {
|
|
1127
|
+
return normalizedFile.slice(prefix.length);
|
|
1128
|
+
}
|
|
1129
|
+
return "../outside";
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function staticPathExtname(value) {
|
|
1133
|
+
const leaf = String(value).split(/[\\/]/u).pop() ?? "";
|
|
1134
|
+
const dot = leaf.lastIndexOf(".");
|
|
1135
|
+
return dot <= 0 ? "" : leaf.slice(dot);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const STATIC_PATH_MODULE = Object.freeze({
|
|
1139
|
+
extname: staticPathExtname,
|
|
1140
|
+
isAbsolute: staticPathIsAbsolute,
|
|
1141
|
+
join: staticPathResolve,
|
|
1142
|
+
relative: staticPathRelative,
|
|
1143
|
+
resolve: staticPathResolve,
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
function staticMountMatches(mount, path) {
|
|
1147
|
+
return path === mount || (mount === "/" ? path.startsWith("/") : path.startsWith(`${mount}/`));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function staticRequestRelativePath(mount, path) {
|
|
1151
|
+
const value = mount === "/" ? path.slice(1) : path.slice(mount.length).replace(/^\/+/u, "");
|
|
1152
|
+
return value.length === 0 ? "" : value;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function staticPathSegments(relative) {
|
|
1156
|
+
if (/[\x00-\x1F\x7F\\]/u.test(relative)) {
|
|
1157
|
+
return undefined;
|
|
1158
|
+
}
|
|
1159
|
+
if (relative.startsWith("/") || /^[A-Za-z]:/u.test(relative) || relative.startsWith("//")) {
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
1162
|
+
if (relative.length === 0) {
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
const segments = relative.split("/");
|
|
1166
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
return segments;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function staticExtension(pathModule, relative) {
|
|
1173
|
+
return pathModule.extname(relative).toLowerCase();
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function staticContentType(pathModule, relative, overrides = undefined) {
|
|
1177
|
+
const ext = staticExtension(pathModule, relative);
|
|
1178
|
+
return overrides?.[ext.replace(/^\./u, "")] ?? overrides?.[ext] ??
|
|
1179
|
+
STATIC_MIME_TYPES[ext] ?? "application/octet-stream";
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function staticCacheControl(entry, relative, pathModule, isFallback) {
|
|
1183
|
+
if (entry.kind === "spa") {
|
|
1184
|
+
if (isFallback) {
|
|
1185
|
+
return entry.cacheControl?.html;
|
|
1186
|
+
}
|
|
1187
|
+
return entry.cacheControl?.assets;
|
|
1188
|
+
}
|
|
1189
|
+
return entry.cacheControl;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function normalizeStaticEncodings(value) {
|
|
1193
|
+
if (value === true) {
|
|
1194
|
+
return ["br", "gzip"];
|
|
1195
|
+
}
|
|
1196
|
+
if (Array.isArray(value)) {
|
|
1197
|
+
return value;
|
|
1198
|
+
}
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function requestEncodingQuality(headers, encoding) {
|
|
1203
|
+
const value = headers.get("accept-encoding") ?? "";
|
|
1204
|
+
let best = 0;
|
|
1205
|
+
for (const part of value.split(",")) {
|
|
1206
|
+
const [name, ...params] = part.trim().toLowerCase().split(";").map((entry) => entry.trim()).filter(Boolean);
|
|
1207
|
+
if (name !== encoding && name !== "*") {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
let quality = 1;
|
|
1211
|
+
for (const param of params) {
|
|
1212
|
+
const match = /^q=([0-9.]+)$/u.exec(param);
|
|
1213
|
+
if (match !== null) {
|
|
1214
|
+
quality = Number(match[1]);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (Number.isFinite(quality) && quality >= 0 && quality <= 1) {
|
|
1218
|
+
best = Math.max(best, quality);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return best;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function staticExistingVariant(fs, basePath, headers, encodings) {
|
|
1225
|
+
let selected;
|
|
1226
|
+
let selectedQuality = 0;
|
|
1227
|
+
for (const encoding of encodings) {
|
|
1228
|
+
const extension = encoding === "br" ? ".br" : ".gz";
|
|
1229
|
+
const contentEncoding = encoding === "gzip" ? "gzip" : "br";
|
|
1230
|
+
const quality = requestEncodingQuality(headers, contentEncoding);
|
|
1231
|
+
if (quality <= 0) {
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
const candidate = `${basePath}${extension}`;
|
|
1235
|
+
try {
|
|
1236
|
+
const stat = await fs.stat(candidate);
|
|
1237
|
+
if (stat.isFile()) {
|
|
1238
|
+
if (selected === undefined || quality > selectedQuality) {
|
|
1239
|
+
selected = { path: candidate, stat, contentEncoding };
|
|
1240
|
+
selectedQuality = quality;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
} catch {
|
|
1244
|
+
// Missing precompressed variants are optional.
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return selected;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function staticEtag(bytes, variant) {
|
|
1251
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
1252
|
+
return `W/"sha256:${hash}-${variant ?? "identity"}"`;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function staticValidatorsMatch(headers, etag, mtime) {
|
|
1256
|
+
const ifNoneMatch = headers.get("if-none-match");
|
|
1257
|
+
if (ifNoneMatch !== undefined) {
|
|
1258
|
+
return ifNoneMatch.split(",").map((entry) => entry.trim()).some((entry) => entry === "*" || entry === etag);
|
|
1259
|
+
}
|
|
1260
|
+
const ifModifiedSince = headers.get("if-modified-since");
|
|
1261
|
+
if (ifModifiedSince !== undefined) {
|
|
1262
|
+
const parsed = Date.parse(ifModifiedSince);
|
|
1263
|
+
if (Number.isFinite(parsed)) {
|
|
1264
|
+
return Math.floor(mtime.getTime() / 1000) <= Math.floor(parsed / 1000);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function staticParseRange(value, size) {
|
|
1271
|
+
if (value === undefined) {
|
|
1272
|
+
return undefined;
|
|
1273
|
+
}
|
|
1274
|
+
const match = /^bytes=(\d*)-(\d*)$/u.exec(value.trim());
|
|
1275
|
+
if (match === null || (match[1].length === 0 && match[2].length === 0)) {
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
let start;
|
|
1279
|
+
let end;
|
|
1280
|
+
if (match[1].length === 0) {
|
|
1281
|
+
const suffix = Number(match[2]);
|
|
1282
|
+
if (!Number.isSafeInteger(suffix) || suffix <= 0) {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
start = Math.max(0, size - suffix);
|
|
1286
|
+
end = size - 1;
|
|
1287
|
+
} else {
|
|
1288
|
+
start = Number(match[1]);
|
|
1289
|
+
end = match[2].length === 0 ? size - 1 : Number(match[2]);
|
|
1290
|
+
}
|
|
1291
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
return { start, end: Math.min(end, size - 1) };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function staticResponseFromFile(bytes, metadata, requestHeaders) {
|
|
1298
|
+
const headers = {
|
|
1299
|
+
ETag: metadata.etag,
|
|
1300
|
+
"Last-Modified": metadata.lastModified,
|
|
1301
|
+
"Accept-Ranges": metadata.range ? "bytes" : "none",
|
|
1302
|
+
"X-Content-Type-Options": "nosniff",
|
|
1303
|
+
};
|
|
1304
|
+
if (metadata.cacheControl !== undefined) {
|
|
1305
|
+
headers["Cache-Control"] = metadata.cacheControl;
|
|
1306
|
+
}
|
|
1307
|
+
if (metadata.contentEncoding !== undefined) {
|
|
1308
|
+
headers["Content-Encoding"] = metadata.contentEncoding;
|
|
1309
|
+
headers.Vary = "Accept-Encoding";
|
|
1310
|
+
headers["Accept-Ranges"] = "none";
|
|
1311
|
+
}
|
|
1312
|
+
if (staticValidatorsMatch(requestHeaders, metadata.etag, metadata.mtime)) {
|
|
1313
|
+
return responseFromParts(304, Object.entries(headers), new Uint8Array(0));
|
|
1314
|
+
}
|
|
1315
|
+
if (metadata.range && metadata.contentEncoding === undefined) {
|
|
1316
|
+
const range = staticParseRange(requestHeaders.get("range"), bytes.byteLength);
|
|
1317
|
+
if (range === null) {
|
|
1318
|
+
return responseFromParts(416, [["Content-Range", `bytes */${bytes.byteLength}`], ...Object.entries(headers)], new Uint8Array(0));
|
|
1319
|
+
}
|
|
1320
|
+
if (range !== undefined) {
|
|
1321
|
+
headers["Content-Range"] = `bytes ${range.start}-${range.end}/${bytes.byteLength}`;
|
|
1322
|
+
return responseFromParts(206, [["Content-Type", metadata.contentType], ...Object.entries(headers)], bytes.slice(range.start, range.end + 1));
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
return responseFromParts(200, [["Content-Type", metadata.contentType], ...Object.entries(headers)], bytes);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function tryStaticFileResponse(entry, relative, headers, isFallback) {
|
|
1329
|
+
const modules = await nodePathModules();
|
|
1330
|
+
const { fs, path } = modules;
|
|
1331
|
+
const segments = staticPathSegments(relative);
|
|
1332
|
+
if (segments === undefined) {
|
|
1333
|
+
return responseFromText(403, "Forbidden\n");
|
|
1334
|
+
}
|
|
1335
|
+
if (segments.some((segment) => segment.startsWith("."))) {
|
|
1336
|
+
if (entry.dotfiles !== "allow") {
|
|
1337
|
+
return entry.dotfiles === "ignore"
|
|
1338
|
+
? responseFromText(404, "Not Found\n")
|
|
1339
|
+
: responseFromText(403, "Forbidden\n");
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const cwd = globalThis.process?.cwd?.() ?? ".";
|
|
1343
|
+
const root = path.resolve(cwd, entry.root);
|
|
1344
|
+
const rootReal = await fs.realpath(root);
|
|
1345
|
+
let filePath = path.resolve(rootReal, ...segments);
|
|
1346
|
+
let servedRelative = relative;
|
|
1347
|
+
let stat;
|
|
1348
|
+
try {
|
|
1349
|
+
stat = await fs.stat(filePath);
|
|
1350
|
+
if (stat.isDirectory()) {
|
|
1351
|
+
if (entry.index === false) {
|
|
1352
|
+
return responseFromText(404, "Not Found\n");
|
|
1353
|
+
}
|
|
1354
|
+
const index = entry.index ?? "index.html";
|
|
1355
|
+
filePath = path.join(filePath, index);
|
|
1356
|
+
servedRelative = relative.length === 0 ? index : `${relative.replace(/[\\/]$/u, "")}/${index}`;
|
|
1357
|
+
stat = await fs.stat(filePath);
|
|
1358
|
+
}
|
|
1359
|
+
} catch {
|
|
1360
|
+
return undefined;
|
|
1361
|
+
}
|
|
1362
|
+
if (!stat.isFile()) {
|
|
1363
|
+
return responseFromText(404, "Not Found\n");
|
|
1364
|
+
}
|
|
1365
|
+
const real = await fs.realpath(filePath);
|
|
1366
|
+
const relativeReal = path.relative(rootReal, real);
|
|
1367
|
+
if (relativeReal.startsWith("..") || path.isAbsolute(relativeReal)) {
|
|
1368
|
+
return responseFromText(403, "Forbidden\n");
|
|
1369
|
+
}
|
|
1370
|
+
if (stat.size > (entry.maxFileBytes ?? 1024 * 1024)) {
|
|
1371
|
+
return responseFromText(413, "Payload Too Large\n");
|
|
1372
|
+
}
|
|
1373
|
+
const variants = normalizeStaticEncodings(entry.precompressed);
|
|
1374
|
+
const variant = await staticExistingVariant(fs, real, headers, variants);
|
|
1375
|
+
const selectedPath = variant?.path ?? real;
|
|
1376
|
+
const selectedStat = variant?.stat ?? stat;
|
|
1377
|
+
if (selectedStat.size > (entry.maxFileBytes ?? 1024 * 1024)) {
|
|
1378
|
+
return responseFromText(413, "Payload Too Large\n");
|
|
1379
|
+
}
|
|
1380
|
+
const bytes = new Uint8Array(await fs.readFile(selectedPath));
|
|
1381
|
+
const cacheControl = staticCacheControl(entry, servedRelative, path, isFallback);
|
|
1382
|
+
return staticResponseFromFile(bytes, {
|
|
1383
|
+
contentType: staticContentType(path, servedRelative, entry.contentType),
|
|
1384
|
+
contentEncoding: variant?.contentEncoding,
|
|
1385
|
+
cacheControl,
|
|
1386
|
+
etag: staticEtag(bytes, variant?.contentEncoding),
|
|
1387
|
+
lastModified: selectedStat.mtime.toUTCString(),
|
|
1388
|
+
mtime: selectedStat.mtime,
|
|
1389
|
+
range: entry.range !== false,
|
|
1390
|
+
}, headers);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function responseFromStaticAssets(app, method, path, headers) {
|
|
1394
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
1395
|
+
return undefined;
|
|
1396
|
+
}
|
|
1397
|
+
const entries = typeof app.__getStaticAssets === "function" ? app.__getStaticAssets() : [];
|
|
1398
|
+
for (const entry of entries) {
|
|
1399
|
+
if (entry.kind === "static" && staticMountMatches(entry.mount, path)) {
|
|
1400
|
+
const relative = staticRequestRelativePath(entry.mount, path);
|
|
1401
|
+
const response = await tryStaticFileResponse(entry, relative, headers, false);
|
|
1402
|
+
if (response !== undefined) {
|
|
1403
|
+
return response;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
for (const entry of entries) {
|
|
1408
|
+
if (entry.kind !== "spa" || !staticMountMatches(entry.mount, path)) {
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
const relative = staticRequestRelativePath(entry.mount, path);
|
|
1412
|
+
const asset = await tryStaticFileResponse(entry, relative, headers, false);
|
|
1413
|
+
if (asset !== undefined) {
|
|
1414
|
+
return asset;
|
|
1415
|
+
}
|
|
1416
|
+
const lastSegment = relative.split("/").pop() ?? "";
|
|
1417
|
+
if (lastSegment.includes(".")) {
|
|
1418
|
+
return responseFromText(404, "Not Found\n");
|
|
1419
|
+
}
|
|
1420
|
+
return tryStaticFileResponse(entry, entry.fallback, headers, true);
|
|
1421
|
+
}
|
|
1422
|
+
return undefined;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function problemCodeFromResponse(response) {
|
|
1426
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1427
|
+
if (!isJsonMediaType(contentType) || !contentType.toLowerCase().includes("problem")) {
|
|
1428
|
+
return undefined;
|
|
1429
|
+
}
|
|
1430
|
+
try {
|
|
1431
|
+
const problem = response.problem();
|
|
1432
|
+
return problem.code;
|
|
1433
|
+
} catch {
|
|
1434
|
+
return undefined;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
function createMetricsStore() {
|
|
1439
|
+
const counters = new Map();
|
|
1440
|
+
const gauges = new Map();
|
|
1441
|
+
function increment(name, labels = {}, amount = 1) {
|
|
1442
|
+
const key = serializeJson({ name, labels });
|
|
1443
|
+
counters.set(key, (counters.get(key) ?? 0) + amount);
|
|
1444
|
+
}
|
|
1445
|
+
function gauge(name, labels = {}, value = 0) {
|
|
1446
|
+
const key = serializeJson({ name, labels });
|
|
1447
|
+
gauges.set(key, value);
|
|
1448
|
+
}
|
|
1449
|
+
const metrics = {
|
|
1450
|
+
increment,
|
|
1451
|
+
gauge,
|
|
1452
|
+
snapshot() {
|
|
1453
|
+
return Object.freeze([
|
|
1454
|
+
...Array.from(counters.entries(), ([key, value]) => Object.freeze({
|
|
1455
|
+
...JSON.parse(key),
|
|
1456
|
+
kind: "counter",
|
|
1457
|
+
value,
|
|
1458
|
+
})),
|
|
1459
|
+
...Array.from(gauges.entries(), ([key, value]) => Object.freeze({
|
|
1460
|
+
...JSON.parse(key),
|
|
1461
|
+
kind: "gauge",
|
|
1462
|
+
value,
|
|
1463
|
+
})),
|
|
1464
|
+
]);
|
|
1465
|
+
},
|
|
1466
|
+
expectCounter(name, expected, labels = {}) {
|
|
1467
|
+
const key = serializeJson({ name, labels });
|
|
1468
|
+
const actual = counters.get(key) ?? 0;
|
|
1469
|
+
if (actual !== expected) {
|
|
1470
|
+
throw new Error(`Sloppy TestHost expected counter '${name}' to be ${expected}, got ${actual}.`);
|
|
1471
|
+
}
|
|
1472
|
+
return metrics;
|
|
1473
|
+
},
|
|
1474
|
+
expectGauge(name, expected, labels = {}) {
|
|
1475
|
+
const key = serializeJson({ name, labels });
|
|
1476
|
+
const actual = gauges.get(key) ?? 0;
|
|
1477
|
+
if (actual !== expected) {
|
|
1478
|
+
throw new Error(`Sloppy TestHost expected gauge '${name}' to be ${expected}, got ${actual}.`);
|
|
1479
|
+
}
|
|
1480
|
+
return metrics;
|
|
1481
|
+
},
|
|
1482
|
+
};
|
|
1483
|
+
return Object.freeze(metrics);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function createHealthHelpers(host) {
|
|
1487
|
+
return Object.freeze({
|
|
1488
|
+
async snapshot(path = "/health") {
|
|
1489
|
+
return (await host.get(path)).json();
|
|
1490
|
+
},
|
|
1491
|
+
async check(name, path = "/health") {
|
|
1492
|
+
const body = await this.snapshot(path);
|
|
1493
|
+
if (Array.isArray(body.checks)) {
|
|
1494
|
+
return body.checks.find((entry) => entry.name === name);
|
|
1495
|
+
}
|
|
1496
|
+
return body.checks?.[name];
|
|
1497
|
+
},
|
|
1498
|
+
async expect(name, status, path = "/health") {
|
|
1499
|
+
const check = await this.check(name, path);
|
|
1500
|
+
if (check === undefined) {
|
|
1501
|
+
throw new Error(`Sloppy TestHost expected health check '${name}'.`);
|
|
1502
|
+
}
|
|
1503
|
+
if (check.status !== status) {
|
|
1504
|
+
throw new Error(`Sloppy TestHost expected health check '${name}' to be '${status}', got '${check.status}'.`);
|
|
1505
|
+
}
|
|
1506
|
+
return this;
|
|
1507
|
+
},
|
|
1508
|
+
async expectStatus(status, path = "/health") {
|
|
1509
|
+
const body = await this.snapshot(path);
|
|
1510
|
+
if (body.status !== status) {
|
|
1511
|
+
throw new Error(`Sloppy TestHost expected health status '${status}', got '${body.status}'.`);
|
|
1512
|
+
}
|
|
1513
|
+
return this;
|
|
1514
|
+
},
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function createJobsHelpers(jobs = undefined) {
|
|
1519
|
+
if (jobs !== undefined && !isPlainObject(jobs)) {
|
|
1520
|
+
throw new TypeError("Sloppy TestHost jobs hooks must be a plain object.");
|
|
1521
|
+
}
|
|
1522
|
+
const queue = [];
|
|
1523
|
+
const jobsApi = {
|
|
1524
|
+
enqueue(name, payload = {}) {
|
|
1525
|
+
queue.push({ name, payload, status: "queued" });
|
|
1526
|
+
return jobsApi;
|
|
1527
|
+
},
|
|
1528
|
+
snapshot() {
|
|
1529
|
+
if (typeof jobs?.snapshot === "function") {
|
|
1530
|
+
return jobs.snapshot();
|
|
1531
|
+
}
|
|
1532
|
+
return Object.freeze(queue.map((job) => Object.freeze({ ...job })));
|
|
1533
|
+
},
|
|
1534
|
+
expectEnqueued(name, payload = undefined) {
|
|
1535
|
+
const found = this.snapshot().some((job) => job.name === name &&
|
|
1536
|
+
(payload === undefined || serializeJson(job.payload) === serializeJson(payload)));
|
|
1537
|
+
if (!found) {
|
|
1538
|
+
throw new Error(`Sloppy TestHost expected job '${name}' to be enqueued.`);
|
|
1539
|
+
}
|
|
1540
|
+
return jobsApi;
|
|
1541
|
+
},
|
|
1542
|
+
async runNext() {
|
|
1543
|
+
if (typeof jobs?.runNext === "function") {
|
|
1544
|
+
return jobs.runNext();
|
|
1545
|
+
}
|
|
1546
|
+
const job = queue.find((entry) => entry.status === "queued");
|
|
1547
|
+
if (job !== undefined) {
|
|
1548
|
+
job.status = "succeeded";
|
|
1549
|
+
}
|
|
1550
|
+
return job;
|
|
1551
|
+
},
|
|
1552
|
+
async runAllDue() {
|
|
1553
|
+
if (typeof jobs?.runAllDue === "function") {
|
|
1554
|
+
return jobs.runAllDue();
|
|
1555
|
+
}
|
|
1556
|
+
for (const job of queue) {
|
|
1557
|
+
if (job.status === "queued") {
|
|
1558
|
+
job.status = "succeeded";
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return this.snapshot();
|
|
1562
|
+
},
|
|
1563
|
+
expectSucceeded(name) {
|
|
1564
|
+
if (!this.snapshot().some((job) => job.name === name && job.status === "succeeded")) {
|
|
1565
|
+
throw new Error(`Sloppy TestHost expected job '${name}' to succeed.`);
|
|
1566
|
+
}
|
|
1567
|
+
return jobsApi;
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
return Object.freeze(jobsApi);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function pathToOpenApiPath(pattern) {
|
|
1574
|
+
return pattern.replace(/\{([A-Za-z_][0-9A-Za-z_]*)(?::[^}]+)?\}/gu, "{$1}");
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function openApiFromRoutes(routes) {
|
|
1578
|
+
const paths = {};
|
|
1579
|
+
for (const route of routes) {
|
|
1580
|
+
const path = pathToOpenApiPath(route.pattern);
|
|
1581
|
+
const rateLimit = route.metadata?.rateLimit;
|
|
1582
|
+
const responses = {
|
|
1583
|
+
200: {
|
|
1584
|
+
description: "OK",
|
|
1585
|
+
},
|
|
1586
|
+
};
|
|
1587
|
+
if (Array.isArray(rateLimit) && rateLimit.length !== 0) {
|
|
1588
|
+
responses[429] = {
|
|
1589
|
+
description: "Too Many Requests",
|
|
1590
|
+
headers: {
|
|
1591
|
+
"Retry-After": {
|
|
1592
|
+
schema: { type: "string" },
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
paths[path] ??= {};
|
|
1598
|
+
paths[path][route.method.toLowerCase()] = {
|
|
1599
|
+
operationId: route.name,
|
|
1600
|
+
responses,
|
|
1601
|
+
...(route.metadata?.realtime === undefined ? {} : {
|
|
1602
|
+
"x-slop-realtime": {
|
|
1603
|
+
kind: route.metadata.realtime.kind,
|
|
1604
|
+
channel: route.metadata.realtime.channel?.name,
|
|
1605
|
+
transport: "websocket",
|
|
1606
|
+
},
|
|
1607
|
+
"x-slop-transport": "websocket",
|
|
1608
|
+
}),
|
|
1609
|
+
"x-slop-route": {
|
|
1610
|
+
pattern: route.pattern,
|
|
1611
|
+
name: route.name,
|
|
1612
|
+
},
|
|
1613
|
+
...(Array.isArray(rateLimit) && rateLimit.length !== 0
|
|
1614
|
+
? { "x-slop-rate-limit": rateLimit }
|
|
1615
|
+
: {}),
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
return Object.freeze({
|
|
1619
|
+
openapi: "3.0.3",
|
|
1620
|
+
info: Object.freeze({
|
|
1621
|
+
title: "Sloppy TestHost",
|
|
1622
|
+
version: "0.0.0-test",
|
|
1623
|
+
}),
|
|
1624
|
+
paths: Object.freeze(paths),
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function createOpenApiHelpers(loadDocument) {
|
|
1629
|
+
const helpers = async function openapi() {
|
|
1630
|
+
return loadDocument();
|
|
1631
|
+
};
|
|
1632
|
+
helpers.snapshot = helpers;
|
|
1633
|
+
helpers.expectRoute = async (method, path) => {
|
|
1634
|
+
const openApiDoc = await loadDocument();
|
|
1635
|
+
if (openApiDoc.paths?.[path]?.[method.toLowerCase()] === undefined) {
|
|
1636
|
+
throw new Error(`Sloppy TestHost expected OpenAPI route '${method.toUpperCase()} ${path}'.`);
|
|
1637
|
+
}
|
|
1638
|
+
return helpers;
|
|
1639
|
+
};
|
|
1640
|
+
helpers.expectResponse = async (method, path, status) => {
|
|
1641
|
+
const openApiDoc = await loadDocument();
|
|
1642
|
+
const operation = openApiDoc.paths?.[path]?.[method.toLowerCase()];
|
|
1643
|
+
if (operation?.responses?.[String(status)] === undefined) {
|
|
1644
|
+
throw new Error(`Sloppy TestHost expected OpenAPI response '${method.toUpperCase()} ${path} ${status}'.`);
|
|
1645
|
+
}
|
|
1646
|
+
return helpers;
|
|
1647
|
+
};
|
|
1648
|
+
helpers.expectComplete = async () => helpers;
|
|
1649
|
+
return Object.freeze(helpers);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
function responseFromProblem(problem) {
|
|
1653
|
+
return responseFromParts(
|
|
1654
|
+
problem.status ?? 400,
|
|
1655
|
+
[["Content-Type", PROBLEM_CONTENT_TYPE]],
|
|
1656
|
+
Text.utf8.encode(serializeJson(problem)),
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function isUnsupportedMediaHelperError(error) {
|
|
1661
|
+
return error instanceof TypeError &&
|
|
1662
|
+
/^Request body is not available as (JSON|form data|multipart data)\.$/u.test(error.message);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
function negotiatedResponse(response, requestHeaders, options) {
|
|
1666
|
+
if (options.contentNegotiation?.strictAccept !== true) {
|
|
1667
|
+
return response;
|
|
1668
|
+
}
|
|
1669
|
+
const contentType = response.headers.get("content-type");
|
|
1670
|
+
if (contentType === undefined || acceptsMediaType(requestHeaders.get("accept"), contentType)) {
|
|
1671
|
+
return response;
|
|
1672
|
+
}
|
|
1673
|
+
return responseFromText(406, "Not Acceptable\n");
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function responseFromResultWithOptions(result, serializationOptions) {
|
|
1677
|
+
if (typeof result === "string") {
|
|
1678
|
+
return responseFromText(200, result);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
if (result === undefined || result === null || result.__sloppyResult !== true) {
|
|
1682
|
+
throw new TypeError("Sloppy test host route handlers must return a string or Results.* descriptor.");
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
const headers = descriptorHeaders(result);
|
|
1686
|
+
if (result.kind === "empty") {
|
|
1687
|
+
return responseFromParts(result.status, headers, new Uint8Array(0));
|
|
1688
|
+
}
|
|
1689
|
+
if (result.kind === "text" || result.kind === "html") {
|
|
1690
|
+
return responseFromParts(result.status, headers, Text.utf8.encode(String(result.body)));
|
|
1691
|
+
}
|
|
1692
|
+
if (result.kind === "bytes") {
|
|
1693
|
+
return responseFromParts(result.status, headers, copyBytes(result.body, "response body"));
|
|
1694
|
+
}
|
|
1695
|
+
if (result.kind === "stream") {
|
|
1696
|
+
const chunks = Array.isArray(result.chunks) ? result.chunks : [];
|
|
1697
|
+
const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
1698
|
+
const body = new Uint8Array(length);
|
|
1699
|
+
let offset = 0;
|
|
1700
|
+
for (const chunk of chunks) {
|
|
1701
|
+
body.set(copyBytes(chunk, "stream response chunk"), offset);
|
|
1702
|
+
offset += chunk.byteLength;
|
|
1703
|
+
}
|
|
1704
|
+
return responseFromParts(result.status, headers, body);
|
|
1705
|
+
}
|
|
1706
|
+
if (result.kind === "json" || result.kind === "problem") {
|
|
1707
|
+
const body = Object.prototype.hasOwnProperty.call(result, RAW_JSON_BODY)
|
|
1708
|
+
? result[RAW_JSON_BODY]
|
|
1709
|
+
: (result.body === undefined ? null : result.body);
|
|
1710
|
+
return responseFromParts(
|
|
1711
|
+
result.status,
|
|
1712
|
+
headers,
|
|
1713
|
+
Text.utf8.encode(serializeJson(body, result.json ?? serializationOptions.json)),
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
throw new TypeError(`Sloppy test host does not support result kind '${result.kind}'.`);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function responseFromResult(result) {
|
|
1721
|
+
return responseFromResultWithOptions(result, DEFAULT_SERIALIZATION_OPTIONS);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function finalizeResponse(response, method) {
|
|
1725
|
+
if (response.status === 204 || response.status === 304) {
|
|
1726
|
+
return responseFromParts(response.status, responseHeaderEntries(response, true), new Uint8Array(0));
|
|
1727
|
+
}
|
|
1728
|
+
if (method === "HEAD") {
|
|
1729
|
+
return responseFromParts(response.status, responseHeaderEntries(response), new Uint8Array(0));
|
|
1730
|
+
}
|
|
1731
|
+
return response;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function findRoute(routes, method, path) {
|
|
1735
|
+
if (method === "HEAD") {
|
|
1736
|
+
let methodMismatch = false;
|
|
1737
|
+
let getMatch = undefined;
|
|
1738
|
+
let getParams = undefined;
|
|
1739
|
+
for (const route of routes) {
|
|
1740
|
+
const params = matchRoutePattern(route.pattern, path);
|
|
1741
|
+
if (params === undefined) {
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
if (route.method === "HEAD") {
|
|
1745
|
+
return { route, params, methodMismatch: false };
|
|
1746
|
+
}
|
|
1747
|
+
if (route.method === "GET" && getMatch === undefined) {
|
|
1748
|
+
getMatch = route;
|
|
1749
|
+
getParams = params;
|
|
1750
|
+
continue;
|
|
1751
|
+
}
|
|
1752
|
+
methodMismatch = true;
|
|
1753
|
+
}
|
|
1754
|
+
if (getMatch !== undefined) {
|
|
1755
|
+
return { route: getMatch, params: getParams, methodMismatch: false };
|
|
1756
|
+
}
|
|
1757
|
+
return { route: undefined, params: undefined, methodMismatch };
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
let methodMismatch = false;
|
|
1761
|
+
for (const route of routes) {
|
|
1762
|
+
const params = matchRoutePattern(route.pattern, path);
|
|
1763
|
+
if (params === undefined) {
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
if (route.method !== method) {
|
|
1767
|
+
methodMismatch = true;
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
return { route, params, methodMismatch: false };
|
|
1771
|
+
}
|
|
1772
|
+
return { route: undefined, params: undefined, methodMismatch };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function routeSegments(pattern) {
|
|
1776
|
+
return pattern === "/" ? [] : pattern.split("/").slice(1);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function routeSegmentRank(segment) {
|
|
1780
|
+
const param = parsePatternParam(segment);
|
|
1781
|
+
if (param === undefined) {
|
|
1782
|
+
return 3;
|
|
1783
|
+
}
|
|
1784
|
+
return param.type === "str" ? 1 : 2;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function compareRoutesByPrecedence(left, right) {
|
|
1788
|
+
const leftSegments = routeSegments(left.pattern);
|
|
1789
|
+
const rightSegments = routeSegments(right.pattern);
|
|
1790
|
+
const shared = Math.min(leftSegments.length, rightSegments.length);
|
|
1791
|
+
for (let index = 0; index < shared; index += 1) {
|
|
1792
|
+
const leftRank = routeSegmentRank(leftSegments[index]);
|
|
1793
|
+
const rightRank = routeSegmentRank(rightSegments[index]);
|
|
1794
|
+
if (leftRank !== rightRank) {
|
|
1795
|
+
return rightRank - leftRank;
|
|
1796
|
+
}
|
|
1797
|
+
if (leftRank === 3 && leftSegments[index] !== rightSegments[index]) {
|
|
1798
|
+
return leftSegments[index] < rightSegments[index] ? -1 : 1;
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
if (leftSegments.length !== rightSegments.length) {
|
|
1802
|
+
return rightSegments.length - leftSegments.length;
|
|
1803
|
+
}
|
|
1804
|
+
return left.__sourceOrder - right.__sourceOrder;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function snapshotRoutes(app) {
|
|
1808
|
+
return Object.freeze(app.__getRoutes()
|
|
1809
|
+
.map((route, index) => Object.freeze({ ...route, __sourceOrder: index }))
|
|
1810
|
+
.sort(compareRoutesByPrecedence));
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
function normalizeMethod(method) {
|
|
1814
|
+
if (typeof method !== "string" || method.trim().length === 0) {
|
|
1815
|
+
throw new TypeError("Sloppy test host method must be a non-empty string.");
|
|
1816
|
+
}
|
|
1817
|
+
const normalized = method.toUpperCase();
|
|
1818
|
+
if (!SUPPORTED_METHODS.has(normalized)) {
|
|
1819
|
+
throw new TypeError("Sloppy test host method is not supported.");
|
|
1820
|
+
}
|
|
1821
|
+
return normalized;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
function normalizeOptions(options) {
|
|
1825
|
+
if (options === undefined) {
|
|
1826
|
+
return {};
|
|
1827
|
+
}
|
|
1828
|
+
if (!isPlainObject(options)) {
|
|
1829
|
+
throw new TypeError("Sloppy test host request options must be a plain object.");
|
|
1830
|
+
}
|
|
1831
|
+
return options;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function mergeHeader(headers, name, value) {
|
|
1835
|
+
assertHeaderName(name, "request");
|
|
1836
|
+
assertHeaderValue(String(value), "request");
|
|
1837
|
+
headers[name] = String(value);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
function appendQuery(target, value) {
|
|
1841
|
+
if (value === undefined) {
|
|
1842
|
+
return target;
|
|
1843
|
+
}
|
|
1844
|
+
const suffix = typeof value === "string"
|
|
1845
|
+
? value.replace(/^\?/u, "")
|
|
1846
|
+
: new URLSearchParams(Object.entries(value).flatMap(([name, entry]) => {
|
|
1847
|
+
if (Array.isArray(entry)) {
|
|
1848
|
+
return entry.map((item) => [name, String(item)]);
|
|
1849
|
+
}
|
|
1850
|
+
return [[name, String(entry)]];
|
|
1851
|
+
})).toString();
|
|
1852
|
+
if (suffix.length === 0) {
|
|
1853
|
+
return target;
|
|
1854
|
+
}
|
|
1855
|
+
return `${target}${target.includes("?") ? "&" : "?"}${suffix}`;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function appendCookie(headers, name, value) {
|
|
1859
|
+
assertHeaderName(name, "cookie");
|
|
1860
|
+
const encoded = encodeURIComponent(String(value));
|
|
1861
|
+
const current = headers.Cookie ?? headers.cookie;
|
|
1862
|
+
const next = `${name}=${encoded}`;
|
|
1863
|
+
if (current === undefined) {
|
|
1864
|
+
headers.Cookie = next;
|
|
1865
|
+
} else if (headers.Cookie !== undefined) {
|
|
1866
|
+
headers.Cookie = `${headers.Cookie}; ${next}`;
|
|
1867
|
+
} else {
|
|
1868
|
+
headers.cookie = `${headers.cookie}; ${next}`;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
async function createJwt(claims, options = {}) {
|
|
1873
|
+
if (!isPlainObject(claims)) {
|
|
1874
|
+
throw new TypeError("Sloppy test host withJwt claims must be a plain object.");
|
|
1875
|
+
}
|
|
1876
|
+
if (typeof options.secret !== "string" || options.secret.length === 0) {
|
|
1877
|
+
throw new TypeError("Sloppy test host withJwt requires options.secret.");
|
|
1878
|
+
}
|
|
1879
|
+
const header = Base64Url.encode(Text.utf8.encode(serializeJson({ alg: "HS256", typ: "JWT" })));
|
|
1880
|
+
const payload = Base64Url.encode(Text.utf8.encode(serializeJson(claims)));
|
|
1881
|
+
const signature = await Hmac.sha256(Secret.fromUtf8(options.secret), `${header}.${payload}`);
|
|
1882
|
+
return `${header}.${payload}.${Base64Url.encode(signature)}`;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function createTestPrincipal(principal) {
|
|
1886
|
+
if (!isPlainObject(principal)) {
|
|
1887
|
+
throw new TypeError("Sloppy TestHost principal must be a plain object.");
|
|
1888
|
+
}
|
|
1889
|
+
const roles = Object.freeze([...(principal.roles ?? [])]);
|
|
1890
|
+
const scopes = Object.freeze([...(principal.scopes ?? [])]);
|
|
1891
|
+
const claims = Object.freeze({ ...(principal.claims ?? principal) });
|
|
1892
|
+
const user = {
|
|
1893
|
+
...principal,
|
|
1894
|
+
authenticated: principal.authenticated ?? true,
|
|
1895
|
+
roles,
|
|
1896
|
+
scopes,
|
|
1897
|
+
claims,
|
|
1898
|
+
hasRole(role) {
|
|
1899
|
+
return typeof role === "string" && roles.includes(role);
|
|
1900
|
+
},
|
|
1901
|
+
hasScope(scope) {
|
|
1902
|
+
return typeof scope === "string" && scopes.includes(scope);
|
|
1903
|
+
},
|
|
1904
|
+
hasClaim(name, value = undefined) {
|
|
1905
|
+
if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(claims, name)) {
|
|
1906
|
+
return false;
|
|
1907
|
+
}
|
|
1908
|
+
return value === undefined ? true : Object.is(claims[name], value);
|
|
1909
|
+
},
|
|
1910
|
+
};
|
|
1911
|
+
return Object.freeze(user);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
class RequestBuilder {
|
|
1915
|
+
constructor(host, method, target, options = undefined) {
|
|
1916
|
+
this._host = host;
|
|
1917
|
+
this._method = method;
|
|
1918
|
+
this._target = target;
|
|
1919
|
+
this._headers = {};
|
|
1920
|
+
this._body = {};
|
|
1921
|
+
this._requestOptions = {};
|
|
1922
|
+
this._timeoutMs = undefined;
|
|
1923
|
+
this._sent = undefined;
|
|
1924
|
+
this._jwt = undefined;
|
|
1925
|
+
if (options !== undefined) {
|
|
1926
|
+
if (!isPlainObject(options)) {
|
|
1927
|
+
throw new TypeError("Sloppy test host request options must be a plain object.");
|
|
1928
|
+
}
|
|
1929
|
+
this.headers(options.headers ?? {});
|
|
1930
|
+
this._requestOptions = { ...options };
|
|
1931
|
+
delete this._requestOptions.headers;
|
|
1932
|
+
delete this._requestOptions.body;
|
|
1933
|
+
delete this._requestOptions.text;
|
|
1934
|
+
delete this._requestOptions.json;
|
|
1935
|
+
delete this._requestOptions[TESTHOST_TEXT_BODY];
|
|
1936
|
+
delete this._requestOptions.timeout;
|
|
1937
|
+
delete this._requestOptions.timeoutMs;
|
|
1938
|
+
this._body = {};
|
|
1939
|
+
if (bodySourceCount(options) > 1) {
|
|
1940
|
+
throw new TypeError("Sloppy test host request options must use one body source.");
|
|
1941
|
+
}
|
|
1942
|
+
if (options.json !== undefined) {
|
|
1943
|
+
this._body.json = options.json;
|
|
1944
|
+
} else if (options.text !== undefined) {
|
|
1945
|
+
this._body.text = options.text;
|
|
1946
|
+
} else if (options[TESTHOST_TEXT_BODY] !== undefined) {
|
|
1947
|
+
this._body[TESTHOST_TEXT_BODY] = options[TESTHOST_TEXT_BODY];
|
|
1948
|
+
} else if (options.body !== undefined) {
|
|
1949
|
+
this._body.body = options.body;
|
|
1950
|
+
}
|
|
1951
|
+
if (options.timeoutMs !== undefined || options.timeout !== undefined) {
|
|
1952
|
+
this.timeout(options.timeoutMs ?? options.timeout);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
header(name, value) {
|
|
1958
|
+
mergeHeader(this._headers, name, value);
|
|
1959
|
+
return this;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
headers(values) {
|
|
1963
|
+
if (!isPlainObject(values)) {
|
|
1964
|
+
throw new TypeError("Sloppy test host headers() expects a plain object.");
|
|
1965
|
+
}
|
|
1966
|
+
for (const [name, value] of Object.entries(values)) {
|
|
1967
|
+
this.header(name, value);
|
|
1968
|
+
}
|
|
1969
|
+
return this;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
query(value) {
|
|
1973
|
+
this._target = appendQuery(this._target, value);
|
|
1974
|
+
return this;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
cookie(name, value) {
|
|
1978
|
+
appendCookie(this._headers, name, value);
|
|
1979
|
+
return this;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
cookies(values) {
|
|
1983
|
+
if (!isPlainObject(values)) {
|
|
1984
|
+
throw new TypeError("Sloppy test host cookies() expects a plain object.");
|
|
1985
|
+
}
|
|
1986
|
+
for (const [name, value] of Object.entries(values)) {
|
|
1987
|
+
this.cookie(name, value);
|
|
1988
|
+
}
|
|
1989
|
+
return this;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
json(value) {
|
|
1993
|
+
this._body = { json: value };
|
|
1994
|
+
return this;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
text(value) {
|
|
1998
|
+
this._body = { text: String(value) };
|
|
1999
|
+
return this;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
bytes(value) {
|
|
2003
|
+
this._body = { body: copyBytes(value, "bytes body") };
|
|
2004
|
+
this.header("Content-Type", "application/octet-stream");
|
|
2005
|
+
return this;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
form(values) {
|
|
2009
|
+
if (!isPlainObject(values)) {
|
|
2010
|
+
throw new TypeError("Sloppy test host form() expects a plain object.");
|
|
2011
|
+
}
|
|
2012
|
+
this._body = { [TESTHOST_TEXT_BODY]: new URLSearchParams(Object.entries(values).map(([name, value]) => [name, String(value)])).toString() };
|
|
2013
|
+
this.header("Content-Type", "application/x-www-form-urlencoded");
|
|
2014
|
+
return this;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
multipart() {
|
|
2018
|
+
throw new Error("Sloppy test host multipart builder is not supported by the current first-party API.");
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
timeout(ms) {
|
|
2022
|
+
if (!Number.isInteger(ms) || ms <= 0) {
|
|
2023
|
+
throw new TypeError("Sloppy test host timeout must be a positive integer millisecond value.");
|
|
2024
|
+
}
|
|
2025
|
+
this._timeoutMs = ms;
|
|
2026
|
+
return this;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
bearer(token) {
|
|
2030
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
2031
|
+
throw new TypeError("Sloppy test host bearer token must be a non-empty string.");
|
|
2032
|
+
}
|
|
2033
|
+
return this.header("Authorization", `Bearer ${token}`);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
apiKey(key, options = {}) {
|
|
2037
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
2038
|
+
throw new TypeError("Sloppy test host API key must be a non-empty string.");
|
|
2039
|
+
}
|
|
2040
|
+
const header = options.header ?? "x-api-key";
|
|
2041
|
+
return this.header(header, key);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
asUser(principal) {
|
|
2045
|
+
this._requestOptions.user = createTestPrincipal(principal);
|
|
2046
|
+
return this;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
withJwt(claims, options = {}) {
|
|
2050
|
+
if (typeof claims === "string") {
|
|
2051
|
+
return this.bearer(claims);
|
|
2052
|
+
}
|
|
2053
|
+
this._jwt = { claims, options };
|
|
2054
|
+
return this;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
withSession(session, options = {}) {
|
|
2058
|
+
if (typeof session !== "string" || session.length === 0) {
|
|
2059
|
+
throw new TypeError("Sloppy test host session value must be a non-empty string.");
|
|
2060
|
+
}
|
|
2061
|
+
return this.cookie(options.name ?? "sloppy.session", session);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
async send() {
|
|
2065
|
+
if (this._sent !== undefined) {
|
|
2066
|
+
return this._sent;
|
|
2067
|
+
}
|
|
2068
|
+
this._sent = (async () => {
|
|
2069
|
+
if (this._jwt !== undefined) {
|
|
2070
|
+
this.bearer(await createJwt(this._jwt.claims, this._jwt.options));
|
|
2071
|
+
}
|
|
2072
|
+
return this._host.request(this._method, this._target, {
|
|
2073
|
+
...this._requestOptions,
|
|
2074
|
+
...this._body,
|
|
2075
|
+
headers: this._headers,
|
|
2076
|
+
timeoutMs: this._timeoutMs,
|
|
2077
|
+
});
|
|
2078
|
+
})();
|
|
2079
|
+
return this._sent;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
then(resolve, reject) {
|
|
2083
|
+
return this.send().then(resolve, reject);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
catch(reject) {
|
|
2087
|
+
return this.send().catch(reject);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
finally(callback) {
|
|
2091
|
+
return this.send().finally(callback);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async expectStatus(code) {
|
|
2095
|
+
return (await this.send()).expectStatus(code);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
async expectHeader(name, expected) {
|
|
2099
|
+
return (await this.send()).expectHeader(name, expected);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
async expectJson(expectedOrSchema) {
|
|
2103
|
+
return (await this.send()).expectJson(expectedOrSchema);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
async expectText(expected) {
|
|
2107
|
+
return (await this.send()).expectText(expected);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
async expectProblem(expected) {
|
|
2111
|
+
return (await this.send()).expectProblem(expected);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
async expectNoBody() {
|
|
2115
|
+
return (await this.send()).expectNoBody();
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function byteLengthOfWebSocketMessage(kind, value) {
|
|
2120
|
+
if (kind === "binary") {
|
|
2121
|
+
return copyBytes(value, "websocket message").byteLength;
|
|
2122
|
+
}
|
|
2123
|
+
if (kind === "ping" || kind === "pong") {
|
|
2124
|
+
return value === undefined ? 0 : Text.utf8.encode(String(value)).byteLength;
|
|
2125
|
+
}
|
|
2126
|
+
return Text.utf8.encode(kind === "json" ? serializeJson(value) : String(value)).byteLength;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function createAsyncMessageQueue(onShift = undefined) {
|
|
2130
|
+
const values = [];
|
|
2131
|
+
const waiters = [];
|
|
2132
|
+
let closed = false;
|
|
2133
|
+
return {
|
|
2134
|
+
push(value) {
|
|
2135
|
+
if (closed) {
|
|
2136
|
+
return false;
|
|
2137
|
+
}
|
|
2138
|
+
const waiter = waiters.shift();
|
|
2139
|
+
if (waiter !== undefined) {
|
|
2140
|
+
waiter({ value, done: false });
|
|
2141
|
+
} else {
|
|
2142
|
+
values.push(value);
|
|
2143
|
+
}
|
|
2144
|
+
return true;
|
|
2145
|
+
},
|
|
2146
|
+
close() {
|
|
2147
|
+
if (closed) {
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
closed = true;
|
|
2151
|
+
while (waiters.length !== 0) {
|
|
2152
|
+
waiters.shift()({ value: undefined, done: true });
|
|
2153
|
+
}
|
|
2154
|
+
},
|
|
2155
|
+
async next() {
|
|
2156
|
+
if (values.length !== 0) {
|
|
2157
|
+
const value = values.shift();
|
|
2158
|
+
onShift?.(value);
|
|
2159
|
+
return { value, done: false };
|
|
2160
|
+
}
|
|
2161
|
+
if (closed) {
|
|
2162
|
+
return { value: undefined, done: true };
|
|
2163
|
+
}
|
|
2164
|
+
return new Promise((resolve) => {
|
|
2165
|
+
waiters.push((result) => {
|
|
2166
|
+
if (!result.done) {
|
|
2167
|
+
onShift?.(result.value);
|
|
2168
|
+
}
|
|
2169
|
+
resolve(result);
|
|
2170
|
+
});
|
|
2171
|
+
});
|
|
2172
|
+
},
|
|
2173
|
+
async take(timeoutMs = 1000, subject = "message") {
|
|
2174
|
+
let timer;
|
|
2175
|
+
const timeout = new Promise((_, reject) => {
|
|
2176
|
+
timer = setTimeout(() => {
|
|
2177
|
+
reject(new Error(`Sloppy TestHost timed out waiting for WebSocket ${subject}.`));
|
|
2178
|
+
}, timeoutMs);
|
|
2179
|
+
});
|
|
2180
|
+
try {
|
|
2181
|
+
const result = await Promise.race([this.next(), timeout]);
|
|
2182
|
+
if (result.done) {
|
|
2183
|
+
throw new Error(`Sloppy TestHost WebSocket closed while waiting for ${subject}.`);
|
|
2184
|
+
}
|
|
2185
|
+
return result.value;
|
|
2186
|
+
} finally {
|
|
2187
|
+
clearTimeout(timer);
|
|
2188
|
+
}
|
|
2189
|
+
},
|
|
2190
|
+
[Symbol.asyncIterator]() {
|
|
2191
|
+
return this;
|
|
2192
|
+
},
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
function createWebSocketMessage(kind, value) {
|
|
2197
|
+
let jsonCache;
|
|
2198
|
+
if (kind === "json") {
|
|
2199
|
+
jsonCache = value;
|
|
2200
|
+
}
|
|
2201
|
+
return Object.freeze({
|
|
2202
|
+
kind,
|
|
2203
|
+
...(kind === "text" ? { text: String(value) } : {}),
|
|
2204
|
+
...(kind === "binary" ? { bytes: copyBytes(value, "websocket message") } : {}),
|
|
2205
|
+
...(kind === "json" ? { text: serializeJson(value) } : {}),
|
|
2206
|
+
...(kind === "ping" || kind === "pong" ? { text: value === undefined ? "" : String(value) } : {}),
|
|
2207
|
+
...(kind === "close" ? { code: value.code, reason: String(value.reason ?? "") } : {}),
|
|
2208
|
+
json() {
|
|
2209
|
+
if (kind === "json") {
|
|
2210
|
+
return jsonCache;
|
|
2211
|
+
}
|
|
2212
|
+
if (kind !== "text") {
|
|
2213
|
+
throw new TypeError("Sloppy WebSocket message is not text JSON.");
|
|
2214
|
+
}
|
|
2215
|
+
jsonCache ??= JSON.parse(String(value));
|
|
2216
|
+
return jsonCache;
|
|
2217
|
+
},
|
|
2218
|
+
validate(schema) {
|
|
2219
|
+
return Schema.validate(this.json(), schema);
|
|
2220
|
+
},
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function validateWebSocketProtocolToken(value) {
|
|
2225
|
+
if (!isHttpToken(value)) {
|
|
2226
|
+
throw new TypeError("Sloppy TestHost WebSocket protocols must be non-empty WebSocket subprotocol tokens.");
|
|
2227
|
+
}
|
|
2228
|
+
return value;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function websocketOriginsAllow(routeOptions, origin) {
|
|
2232
|
+
const origins = routeOptions.origins;
|
|
2233
|
+
if (origin === undefined || origin.length === 0 || origins === undefined || origins === "*") {
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
return Array.isArray(origins) && origins.includes(origin);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function websocketReject(status, code, message) {
|
|
2240
|
+
const error = new Error(message);
|
|
2241
|
+
error.status = status;
|
|
2242
|
+
error.code = code;
|
|
2243
|
+
return error;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
class TestWebSocket {
|
|
2247
|
+
constructor(state) {
|
|
2248
|
+
this._state = state;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
get closed() {
|
|
2252
|
+
return this._state.closed;
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
get protocol() {
|
|
2256
|
+
return this._state.protocol;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
_send(kind, value) {
|
|
2260
|
+
if (this._state.closed) {
|
|
2261
|
+
throw new Error("Sloppy TestHost WebSocket is closed.");
|
|
2262
|
+
}
|
|
2263
|
+
const bytes = byteLengthOfWebSocketMessage(kind, value);
|
|
2264
|
+
if (bytes > this._state.options.maxMessageBytes) {
|
|
2265
|
+
this._state.close(1009, "message too large");
|
|
2266
|
+
throw new Error("SLOPPY_E_WEBSOCKET_MESSAGE_TOO_LARGE");
|
|
2267
|
+
}
|
|
2268
|
+
this._state.touch?.();
|
|
2269
|
+
this._state.clientToServer.push(createWebSocketMessage(kind, value));
|
|
2270
|
+
this._state.metrics.increment("websocket.messages.in.total", {
|
|
2271
|
+
route: this._state.route,
|
|
2272
|
+
kind,
|
|
2273
|
+
});
|
|
2274
|
+
this._state.metrics.increment("websocket.bytes.in.total", {
|
|
2275
|
+
route: this._state.route,
|
|
2276
|
+
kind,
|
|
2277
|
+
}, bytes);
|
|
2278
|
+
return Promise.resolve();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
sendText(text) {
|
|
2282
|
+
return this._send("text", String(text));
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
sendJson(value) {
|
|
2286
|
+
return this._send("json", value);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
sendBytes(bytes) {
|
|
2290
|
+
return this._send("binary", bytes);
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
sendPing(payload = "") {
|
|
2294
|
+
return this._send("ping", payload);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
sendPong(payload = "") {
|
|
2298
|
+
return this._send("pong", payload);
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
async expectText(expected, options = {}) {
|
|
2302
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "text message");
|
|
2303
|
+
if (message.kind !== "text") {
|
|
2304
|
+
throw new Error(`Sloppy TestHost expected WebSocket text message, got '${message.kind}'.`);
|
|
2305
|
+
}
|
|
2306
|
+
assertExpectedResponseValue(message.text, expected, "WebSocket text");
|
|
2307
|
+
return this;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
async expectJson(expectedOrSchema, options = {}) {
|
|
2311
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "JSON message");
|
|
2312
|
+
if (message.kind !== "json" && message.kind !== "text") {
|
|
2313
|
+
throw new Error(`Sloppy TestHost expected WebSocket JSON message, got '${message.kind}'.`);
|
|
2314
|
+
}
|
|
2315
|
+
let value;
|
|
2316
|
+
try {
|
|
2317
|
+
value = message.json();
|
|
2318
|
+
} catch (error) {
|
|
2319
|
+
throw new Error(`Sloppy TestHost expected WebSocket JSON message, got invalid text JSON: ${error.message}`);
|
|
2320
|
+
}
|
|
2321
|
+
if (Schema.isSchema(expectedOrSchema)) {
|
|
2322
|
+
Schema.validate(value, expectedOrSchema);
|
|
2323
|
+
} else {
|
|
2324
|
+
assertDeepJsonEqual(value, expectedOrSchema, "WebSocket JSON");
|
|
2325
|
+
}
|
|
2326
|
+
return this;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
async receiveJson(options = {}) {
|
|
2330
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "JSON message");
|
|
2331
|
+
if (message.kind !== "json" && message.kind !== "text") {
|
|
2332
|
+
throw new Error(`Sloppy TestHost expected WebSocket JSON message, got '${message.kind}'.`);
|
|
2333
|
+
}
|
|
2334
|
+
try {
|
|
2335
|
+
return message.json();
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
throw new Error(`Sloppy TestHost expected WebSocket JSON message, got invalid text JSON: ${error.message}`);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
async expectBytes(expected, options = {}) {
|
|
2342
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "binary message");
|
|
2343
|
+
if (message.kind !== "binary") {
|
|
2344
|
+
throw new Error(`Sloppy TestHost expected WebSocket binary message, got '${message.kind}'.`);
|
|
2345
|
+
}
|
|
2346
|
+
assertDeepJsonEqual([...message.bytes], [...copyBytes(expected, "expected WebSocket bytes")], "WebSocket bytes");
|
|
2347
|
+
return this;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
async expectPing(options = {}) {
|
|
2351
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "ping");
|
|
2352
|
+
if (message.kind !== "ping") {
|
|
2353
|
+
throw new Error(`Sloppy TestHost expected WebSocket ping, got '${message.kind}'.`);
|
|
2354
|
+
}
|
|
2355
|
+
return this;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
async expectPong(options = {}) {
|
|
2359
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "pong");
|
|
2360
|
+
if (message.kind !== "pong") {
|
|
2361
|
+
throw new Error(`Sloppy TestHost expected WebSocket pong, got '${message.kind}'.`);
|
|
2362
|
+
}
|
|
2363
|
+
return this;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
async expectClose(code = undefined, options = {}) {
|
|
2367
|
+
const message = await this._state.serverToClient.take(options.timeoutMs, "close");
|
|
2368
|
+
if (message.kind !== "close") {
|
|
2369
|
+
throw new Error(`Sloppy TestHost expected WebSocket close, got '${message.kind}'.`);
|
|
2370
|
+
}
|
|
2371
|
+
if (code !== undefined) {
|
|
2372
|
+
assertExpectedResponseValue(message.code, code, "WebSocket close code");
|
|
2373
|
+
}
|
|
2374
|
+
return this;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
close(code = 1000, reason = "") {
|
|
2378
|
+
this._state.close(code, reason);
|
|
2379
|
+
return Promise.resolve();
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
class WebSocketConnectAttempt {
|
|
2384
|
+
constructor(start) {
|
|
2385
|
+
this._start = start;
|
|
2386
|
+
this._promise = undefined;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
_connect() {
|
|
2390
|
+
if (this._promise === undefined) {
|
|
2391
|
+
this._promise = Promise.resolve().then(this._start);
|
|
2392
|
+
this._promise.catch(() => {});
|
|
2393
|
+
}
|
|
2394
|
+
return this._promise;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
then(resolve, reject) {
|
|
2398
|
+
return this._connect().then(resolve, reject);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
catch(reject) {
|
|
2402
|
+
return this._connect().catch(reject);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
finally(callback) {
|
|
2406
|
+
return this._connect().finally(callback);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
async expectRejected(status) {
|
|
2410
|
+
try {
|
|
2411
|
+
await this._connect();
|
|
2412
|
+
} catch (error) {
|
|
2413
|
+
assertExpectedResponseValue(error.status, status, "WebSocket rejection status");
|
|
2414
|
+
return undefined;
|
|
2415
|
+
}
|
|
2416
|
+
throw new Error(`Sloppy TestHost expected WebSocket rejection status ${status}.`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
class WebSocketBuilder {
|
|
2421
|
+
constructor(host, target, options = undefined) {
|
|
2422
|
+
this._host = host;
|
|
2423
|
+
this._target = target;
|
|
2424
|
+
this._headers = {};
|
|
2425
|
+
this._timeoutMs = 1000;
|
|
2426
|
+
this._protocols = [];
|
|
2427
|
+
this._user = undefined;
|
|
2428
|
+
this._jwt = undefined;
|
|
2429
|
+
if (options !== undefined) {
|
|
2430
|
+
if (!isPlainObject(options)) {
|
|
2431
|
+
throw new TypeError("Sloppy TestHost WebSocket options must be a plain object.");
|
|
2432
|
+
}
|
|
2433
|
+
this.headers(options.headers ?? {});
|
|
2434
|
+
if (options.origin !== undefined) {
|
|
2435
|
+
this.origin(options.origin);
|
|
2436
|
+
}
|
|
2437
|
+
if (options.protocols !== undefined) {
|
|
2438
|
+
this.protocols(options.protocols);
|
|
2439
|
+
}
|
|
2440
|
+
if (options.timeoutMs !== undefined) {
|
|
2441
|
+
this.timeout(options.timeoutMs);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
header(name, value) {
|
|
2447
|
+
mergeHeader(this._headers, name, value);
|
|
2448
|
+
return this;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
headers(values) {
|
|
2452
|
+
if (!isPlainObject(values)) {
|
|
2453
|
+
throw new TypeError("Sloppy TestHost WebSocket headers() expects a plain object.");
|
|
2454
|
+
}
|
|
2455
|
+
for (const [name, value] of Object.entries(values)) {
|
|
2456
|
+
this.header(name, value);
|
|
2457
|
+
}
|
|
2458
|
+
return this;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
origin(value) {
|
|
2462
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
2463
|
+
throw new TypeError("Sloppy TestHost WebSocket origin must be a non-empty string.");
|
|
2464
|
+
}
|
|
2465
|
+
return this.header("Origin", value);
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
protocols(values) {
|
|
2469
|
+
if (!Array.isArray(values)) {
|
|
2470
|
+
throw new TypeError("Sloppy TestHost WebSocket protocols must be an array.");
|
|
2471
|
+
}
|
|
2472
|
+
this._protocols = values.map(validateWebSocketProtocolToken);
|
|
2473
|
+
if (this._protocols.length !== 0) {
|
|
2474
|
+
this.header("Sec-WebSocket-Protocol", this._protocols.join(", "));
|
|
2475
|
+
}
|
|
2476
|
+
return this;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
timeout(ms) {
|
|
2480
|
+
if (!Number.isInteger(ms) || ms <= 0) {
|
|
2481
|
+
throw new TypeError("Sloppy TestHost WebSocket timeout must be a positive integer millisecond value.");
|
|
2482
|
+
}
|
|
2483
|
+
this._timeoutMs = ms;
|
|
2484
|
+
return this;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
bearer(token) {
|
|
2488
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
2489
|
+
throw new TypeError("Sloppy TestHost WebSocket bearer token must be a non-empty string.");
|
|
2490
|
+
}
|
|
2491
|
+
return this.header("Authorization", `Bearer ${token}`);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
apiKey(key, options = {}) {
|
|
2495
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
2496
|
+
throw new TypeError("Sloppy TestHost WebSocket API key must be a non-empty string.");
|
|
2497
|
+
}
|
|
2498
|
+
return this.header(options.header ?? "x-api-key", key);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
withSession(session, options = {}) {
|
|
2502
|
+
if (typeof session !== "string" || session.length === 0) {
|
|
2503
|
+
throw new TypeError("Sloppy TestHost WebSocket session value must be a non-empty string.");
|
|
2504
|
+
}
|
|
2505
|
+
appendCookie(this._headers, options.name ?? "sloppy.session", session);
|
|
2506
|
+
return this;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
withJwt(claims, options = {}) {
|
|
2510
|
+
if (typeof claims === "string") {
|
|
2511
|
+
return this.bearer(claims);
|
|
2512
|
+
}
|
|
2513
|
+
this._jwt = { claims, options };
|
|
2514
|
+
return this;
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
asUser(principal) {
|
|
2518
|
+
this._user = createTestPrincipal(principal);
|
|
2519
|
+
return this;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
connect() {
|
|
2523
|
+
return new WebSocketConnectAttempt(async () => {
|
|
2524
|
+
if (this._jwt !== undefined) {
|
|
2525
|
+
this.bearer(await createJwt(this._jwt.claims, this._jwt.options));
|
|
2526
|
+
}
|
|
2527
|
+
return this._host.websocketConnect(this._target, {
|
|
2528
|
+
headers: this._headers,
|
|
2529
|
+
protocols: this._protocols,
|
|
2530
|
+
timeoutMs: this._timeoutMs,
|
|
2531
|
+
user: this._user,
|
|
2532
|
+
});
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
class RealtimeTestClient {
|
|
2538
|
+
constructor(channel, websocket) {
|
|
2539
|
+
this._channel = channel;
|
|
2540
|
+
this._websocket = websocket;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
get closed() {
|
|
2544
|
+
return this._websocket.closed;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
get protocol() {
|
|
2548
|
+
return this._websocket.protocol;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
send(eventName, data, options = undefined) {
|
|
2552
|
+
return this._websocket.sendJson(this._channel.serializeClientMessage(eventName, data, options));
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
async expect(eventName, expectedData = undefined, options = undefined) {
|
|
2556
|
+
const envelope = this._channel.parseServerMessage(await this._websocket.receiveJson(options));
|
|
2557
|
+
assertExpectedResponseValue(envelope.type, eventName, "Realtime event type");
|
|
2558
|
+
assertJsonIncludes(envelope.data, expectedData, `Realtime event '${eventName}' data`);
|
|
2559
|
+
return this;
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
async expectType(eventName, options = undefined) {
|
|
2563
|
+
const envelope = this._channel.parseServerMessage(await this._websocket.receiveJson(options));
|
|
2564
|
+
assertExpectedResponseValue(envelope.type, eventName, "Realtime event type");
|
|
2565
|
+
return this;
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
async expectError(code, options = undefined) {
|
|
2569
|
+
const envelope = await this._websocket.receiveJson(options);
|
|
2570
|
+
assertExpectedResponseValue(envelope.type, "error", "Realtime error type");
|
|
2571
|
+
assertExpectedResponseValue(envelope.error?.code, code, "Realtime error code");
|
|
2572
|
+
return this;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
expectClose(code = undefined, options = undefined) {
|
|
2576
|
+
return this._websocket.expectClose(code, options).then(() => this);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
close(code = 1000, reason = "") {
|
|
2580
|
+
return this._websocket.close(code, reason);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
class RealtimeConnectAttempt {
|
|
2585
|
+
constructor(channel, websocketAttempt) {
|
|
2586
|
+
this._channel = channel;
|
|
2587
|
+
this._websocketAttempt = websocketAttempt;
|
|
2588
|
+
this._promise = undefined;
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
_connect() {
|
|
2592
|
+
if (this._promise === undefined) {
|
|
2593
|
+
this._promise = Promise.resolve(this._websocketAttempt)
|
|
2594
|
+
.then((websocket) => new RealtimeTestClient(this._channel, websocket));
|
|
2595
|
+
this._promise.catch(() => {});
|
|
2596
|
+
}
|
|
2597
|
+
return this._promise;
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
then(resolve, reject) {
|
|
2601
|
+
return this._connect().then(resolve, reject);
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
catch(reject) {
|
|
2605
|
+
return this._connect().catch(reject);
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
finally(callback) {
|
|
2609
|
+
return this._connect().finally(callback);
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
expectRejected(status) {
|
|
2613
|
+
return this._websocketAttempt.expectRejected(status);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
class RealtimeBuilder {
|
|
2618
|
+
constructor(host, target, channel, options = undefined) {
|
|
2619
|
+
if (!isRealtimeChannel(channel)) {
|
|
2620
|
+
throw new TypeError("Sloppy TestHost realtime channel must come from Realtime.channel(...).");
|
|
2621
|
+
}
|
|
2622
|
+
this._channel = channel;
|
|
2623
|
+
this._websocket = new WebSocketBuilder(host, target, options);
|
|
2624
|
+
this._websocket.protocols([channel.metadata.protocol]);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
header(name, value) {
|
|
2628
|
+
this._websocket.header(name, value);
|
|
2629
|
+
return this;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
headers(values) {
|
|
2633
|
+
this._websocket.headers(values);
|
|
2634
|
+
return this;
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
origin(value) {
|
|
2638
|
+
this._websocket.origin(value);
|
|
2639
|
+
return this;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
protocols(values) {
|
|
2643
|
+
this._websocket.protocols(values);
|
|
2644
|
+
return this;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
timeout(ms) {
|
|
2648
|
+
this._websocket.timeout(ms);
|
|
2649
|
+
return this;
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
bearer(token) {
|
|
2653
|
+
this._websocket.bearer(token);
|
|
2654
|
+
return this;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
apiKey(key, options = {}) {
|
|
2658
|
+
this._websocket.apiKey(key, options);
|
|
2659
|
+
return this;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
withSession(session, options = {}) {
|
|
2663
|
+
this._websocket.withSession(session, options);
|
|
2664
|
+
return this;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
withJwt(claims, options = {}) {
|
|
2668
|
+
this._websocket.withJwt(claims, options);
|
|
2669
|
+
return this;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
asUser(principal) {
|
|
2673
|
+
this._websocket.asUser(principal);
|
|
2674
|
+
return this;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
connect() {
|
|
2678
|
+
return new RealtimeConnectAttempt(this._channel, this._websocket.connect());
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
function createFluentHost(base, mode = "inProcess", defaults = {}) {
|
|
2683
|
+
const host = {
|
|
2684
|
+
mode,
|
|
2685
|
+
request(method, target, options) {
|
|
2686
|
+
const merged = {
|
|
2687
|
+
...(defaults.options ?? {}),
|
|
2688
|
+
...(options ?? {}),
|
|
2689
|
+
headers: {
|
|
2690
|
+
...(defaults.headers ?? {}),
|
|
2691
|
+
...(options?.headers ?? {}),
|
|
2692
|
+
},
|
|
2693
|
+
};
|
|
2694
|
+
if (defaults.user !== undefined && merged.user === undefined) {
|
|
2695
|
+
merged.user = defaults.user;
|
|
2696
|
+
}
|
|
2697
|
+
return base.request(method, target, merged);
|
|
2698
|
+
},
|
|
2699
|
+
websocketConnect(target, options) {
|
|
2700
|
+
const merged = {
|
|
2701
|
+
...(defaults.options ?? {}),
|
|
2702
|
+
...(options ?? {}),
|
|
2703
|
+
headers: {
|
|
2704
|
+
...(defaults.headers ?? {}),
|
|
2705
|
+
...(options?.headers ?? {}),
|
|
2706
|
+
},
|
|
2707
|
+
};
|
|
2708
|
+
if (defaults.user !== undefined && merged.user === undefined) {
|
|
2709
|
+
merged.user = defaults.user;
|
|
2710
|
+
}
|
|
2711
|
+
return base.websocketConnect(target, merged);
|
|
2712
|
+
},
|
|
2713
|
+
get(target, options) {
|
|
2714
|
+
return new RequestBuilder(host, "GET", target, options);
|
|
2715
|
+
},
|
|
2716
|
+
post(target, options) {
|
|
2717
|
+
return new RequestBuilder(host, "POST", target, options);
|
|
2718
|
+
},
|
|
2719
|
+
put(target, options) {
|
|
2720
|
+
return new RequestBuilder(host, "PUT", target, options);
|
|
2721
|
+
},
|
|
2722
|
+
patch(target, options) {
|
|
2723
|
+
return new RequestBuilder(host, "PATCH", target, options);
|
|
2724
|
+
},
|
|
2725
|
+
delete(target, options) {
|
|
2726
|
+
return new RequestBuilder(host, "DELETE", target, options);
|
|
2727
|
+
},
|
|
2728
|
+
options(target, options) {
|
|
2729
|
+
return new RequestBuilder(host, "OPTIONS", target, options);
|
|
2730
|
+
},
|
|
2731
|
+
head(target, options) {
|
|
2732
|
+
return new RequestBuilder(host, "HEAD", target, options);
|
|
2733
|
+
},
|
|
2734
|
+
async expectRateLimited(method, target, options = undefined) {
|
|
2735
|
+
const response = await host.request(method, target, options);
|
|
2736
|
+
response.expectStatus(429).expectProblem({
|
|
2737
|
+
status: 429,
|
|
2738
|
+
code: "SLOPPY_E_RATE_LIMIT_EXCEEDED",
|
|
2739
|
+
});
|
|
2740
|
+
return response;
|
|
2741
|
+
},
|
|
2742
|
+
advanceClock(duration) {
|
|
2743
|
+
if (typeof base.clock?.advanceBy !== "function") {
|
|
2744
|
+
throw new Error("Sloppy TestHost advanceClock requires a fake clock with advanceBy().");
|
|
2745
|
+
}
|
|
2746
|
+
base.clock.advanceBy(duration);
|
|
2747
|
+
return host;
|
|
2748
|
+
},
|
|
2749
|
+
websocket(target, options) {
|
|
2750
|
+
return new WebSocketBuilder(host, target, options);
|
|
2751
|
+
},
|
|
2752
|
+
realtime(target, channel, options) {
|
|
2753
|
+
return new RealtimeBuilder(host, target, channel, options);
|
|
2754
|
+
},
|
|
2755
|
+
asUser(principal) {
|
|
2756
|
+
return createFluentHost(base, mode, {
|
|
2757
|
+
...defaults,
|
|
2758
|
+
user: createTestPrincipal(principal),
|
|
2759
|
+
});
|
|
2760
|
+
},
|
|
2761
|
+
withHeader(name, value) {
|
|
2762
|
+
assertHeaderName(name, "request");
|
|
2763
|
+
assertHeaderValue(String(value), "request");
|
|
2764
|
+
return createFluentHost(base, mode, {
|
|
2765
|
+
...defaults,
|
|
2766
|
+
headers: {
|
|
2767
|
+
...(defaults.headers ?? {}),
|
|
2768
|
+
[name]: String(value),
|
|
2769
|
+
},
|
|
2770
|
+
});
|
|
2771
|
+
},
|
|
2772
|
+
close() {
|
|
2773
|
+
return base.close();
|
|
2774
|
+
},
|
|
2775
|
+
dispose() {
|
|
2776
|
+
return base.close();
|
|
2777
|
+
},
|
|
2778
|
+
async [Symbol.asyncDispose]() {
|
|
2779
|
+
await base.close();
|
|
2780
|
+
},
|
|
2781
|
+
diagnostics: base.diagnostics,
|
|
2782
|
+
health: undefined,
|
|
2783
|
+
metrics: base.metrics,
|
|
2784
|
+
jobs: base.jobs,
|
|
2785
|
+
openapi: base.openapi,
|
|
2786
|
+
clock: base.clock,
|
|
2787
|
+
baseUrl: base.baseUrl,
|
|
2788
|
+
port: base.port,
|
|
2789
|
+
};
|
|
2790
|
+
host.health = createHealthHelpers(host);
|
|
2791
|
+
return Object.freeze(host);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function createTestHost(app, options = {}) {
|
|
2795
|
+
if (app === null || typeof app !== "object" || typeof app.__getRoutes !== "function") {
|
|
2796
|
+
throw new TypeError("Sloppy createTestHost expects a Sloppy app.");
|
|
2797
|
+
}
|
|
2798
|
+
if (!isPlainObject(options)) {
|
|
2799
|
+
throw new TypeError("Sloppy TestHost options must be a plain object.");
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
const secretValues = Object.values(options.secrets ?? {});
|
|
2803
|
+
const diagnostics = createDiagnosticsStore(secretValues);
|
|
2804
|
+
const metrics = createMetricsStore();
|
|
2805
|
+
const jobs = createJobsHelpers(options.jobs);
|
|
2806
|
+
if (options.rateLimit !== undefined && !isPlainObject(options.rateLimit)) {
|
|
2807
|
+
throw new TypeError("Sloppy TestHost rateLimit options must be a plain object.");
|
|
2808
|
+
}
|
|
2809
|
+
const rateLimitStores = options.rateLimit?.stores;
|
|
2810
|
+
if (rateLimitStores !== undefined) {
|
|
2811
|
+
if (!isPlainObject(rateLimitStores)) {
|
|
2812
|
+
throw new TypeError("Sloppy TestHost rateLimit.stores must be a plain object.");
|
|
2813
|
+
}
|
|
2814
|
+
for (const [name, store] of Object.entries(rateLimitStores)) {
|
|
2815
|
+
if ((name === "default" || name === "memory") && typeof app.services.__setRateLimitStore === "function") {
|
|
2816
|
+
app.services.__setRateLimitStore(name, store);
|
|
2817
|
+
} else {
|
|
2818
|
+
app.services.addRateLimitStore(name, store);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
app.services.__resetRateLimitStores?.();
|
|
2823
|
+
app.freeze();
|
|
2824
|
+
const routes = snapshotRoutes(app);
|
|
2825
|
+
const serializationOptions = typeof app.__getSerializationOptions === "function"
|
|
2826
|
+
? app.__getSerializationOptions()
|
|
2827
|
+
: DEFAULT_SERIALIZATION_OPTIONS;
|
|
2828
|
+
let closed = false;
|
|
2829
|
+
let activeRequests = 0;
|
|
2830
|
+
const activeSockets = new Set();
|
|
2831
|
+
let closePromise = undefined;
|
|
2832
|
+
let drainWaiters = [];
|
|
2833
|
+
const hostState = Object.freeze({
|
|
2834
|
+
config: createConfigOverlay(app.config, options.config, options.secrets),
|
|
2835
|
+
services: createServiceOverlay(app.services, options.services, options.providers, options.caches, options.httpClients, options.redis ?? options.redisClients),
|
|
2836
|
+
clock: options.clock,
|
|
2837
|
+
diagnostics,
|
|
2838
|
+
metrics,
|
|
2839
|
+
});
|
|
2840
|
+
|
|
2841
|
+
function appMetricsRegistry() {
|
|
2842
|
+
return typeof app.__getMetricsRegistry === "function" ? app.__getMetricsRegistry() : undefined;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
function recordHttpMetric(metricRegistry, labels, response, durationMs, requestBytes) {
|
|
2846
|
+
if (metricRegistry === undefined) {
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
const responseBytes = response.bytes().byteLength;
|
|
2850
|
+
metricRegistry.counter("http.requests.total", { description: "HTTP requests processed by the app host." }).inc(labels);
|
|
2851
|
+
metricRegistry.counter("http.route.hits", { description: "HTTP route hits by route pattern." }).inc(labels);
|
|
2852
|
+
metricRegistry.counter("http.request.bytes", { description: "HTTP request body bytes processed by the app host." }).inc(labels, requestBytes);
|
|
2853
|
+
metricRegistry.counter("http.response.bytes", { description: "HTTP response body bytes written by the app host." }).inc(labels, responseBytes);
|
|
2854
|
+
metricRegistry.histogram("http.request.duration.ms", { description: "HTTP request duration in milliseconds." }).observe(labels, durationMs);
|
|
2855
|
+
metricRegistry.counter("http.status.total", { description: "HTTP responses by status code and class." }).inc({
|
|
2856
|
+
...labels,
|
|
2857
|
+
status: String(response.status),
|
|
2858
|
+
statusClass: `${Math.trunc(response.status / 100)}xx`,
|
|
2859
|
+
});
|
|
2860
|
+
if (response.status >= 500) {
|
|
2861
|
+
metricRegistry.counter("http.errors.total", { description: "HTTP responses with 5xx status." }).inc(labels);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
function ignoreMetricError(callback) {
|
|
2866
|
+
try {
|
|
2867
|
+
callback();
|
|
2868
|
+
} catch {
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
function finishRequest() {
|
|
2873
|
+
activeRequests -= 1;
|
|
2874
|
+
if (closed && activeRequests === 0 && activeSockets.size === 0) {
|
|
2875
|
+
const waiters = drainWaiters;
|
|
2876
|
+
drainWaiters = [];
|
|
2877
|
+
for (const resolve of waiters) {
|
|
2878
|
+
resolve();
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function waitForDrain() {
|
|
2884
|
+
if (activeRequests === 0 && activeSockets.size === 0) {
|
|
2885
|
+
return Promise.resolve();
|
|
2886
|
+
}
|
|
2887
|
+
return new Promise((resolve) => {
|
|
2888
|
+
drainWaiters.push(resolve);
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
function finishSocket(state) {
|
|
2893
|
+
activeSockets.delete(state);
|
|
2894
|
+
if (closed && activeRequests === 0 && activeSockets.size === 0) {
|
|
2895
|
+
const waiters = drainWaiters;
|
|
2896
|
+
drainWaiters = [];
|
|
2897
|
+
for (const resolve of waiters) {
|
|
2898
|
+
resolve();
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
async function request(method, target, options = undefined) {
|
|
2904
|
+
if (closed) {
|
|
2905
|
+
throw new Error("Sloppy test host is closed.");
|
|
2906
|
+
}
|
|
2907
|
+
activeRequests += 1;
|
|
2908
|
+
try {
|
|
2909
|
+
const normalizedMethod = normalizeMethod(method);
|
|
2910
|
+
const normalizedOptions = normalizeOptions(options);
|
|
2911
|
+
const targetParts = splitTarget(target);
|
|
2912
|
+
const headerEntries = headerEntriesFromObject(normalizedOptions.headers, "request");
|
|
2913
|
+
const bodyBytes = normalizeRequestBodyWithOptions(normalizedOptions, headerEntries, serializationOptions);
|
|
2914
|
+
const headers = createHeadersLike(headerEntries);
|
|
2915
|
+
const match = findRoute(routes, normalizedMethod, targetParts.path);
|
|
2916
|
+
diagnostics.record({
|
|
2917
|
+
code: "SLOPPY_TESTHOST_REQUEST",
|
|
2918
|
+
subsystem: "http",
|
|
2919
|
+
severity: "debug",
|
|
2920
|
+
message: "TestHost request started.",
|
|
2921
|
+
fields: { method: normalizedMethod, path: targetParts.path },
|
|
2922
|
+
});
|
|
2923
|
+
|
|
2924
|
+
const policy = typeof app.__getErrorPolicy === "function" ? app.__getErrorPolicy() : undefined;
|
|
2925
|
+
if (policy !== undefined && bodyBytes.byteLength > policy.maxBodyBytes) {
|
|
2926
|
+
diagnostics.record({ code: "SLOPPY_E_REQUEST_BODY_TOO_LARGE", subsystem: "http", severity: "warn" });
|
|
2927
|
+
return finalizeResponse(responseFromErrorStatus(app, 413, undefined, "Payload Too Large\n"), normalizedMethod);
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
if (match.route === undefined) {
|
|
2931
|
+
const staticResponse = await responseFromStaticAssets(app, normalizedMethod, targetParts.path, headers);
|
|
2932
|
+
if (staticResponse !== undefined) {
|
|
2933
|
+
const response = finalizeResponse(staticResponse, normalizedMethod);
|
|
2934
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
2935
|
+
return response;
|
|
2936
|
+
}
|
|
2937
|
+
diagnostics.record({
|
|
2938
|
+
code: match.methodMismatch ? "SLOPPY_E_METHOD_NOT_ALLOWED" : "SLOPPY_E_ROUTE_NOT_FOUND",
|
|
2939
|
+
subsystem: "routing",
|
|
2940
|
+
severity: "warn",
|
|
2941
|
+
fields: { method: normalizedMethod, path: targetParts.path },
|
|
2942
|
+
});
|
|
2943
|
+
const response = finalizeResponse(match.methodMismatch
|
|
2944
|
+
? responseFromText(405, "Method Not Allowed\n")
|
|
2945
|
+
: policy?.missingRoute === true
|
|
2946
|
+
? responseFromErrorStatus(app, 404, undefined, "Not Found\n")
|
|
2947
|
+
: responseFromText(404, "Not Found\n"), normalizedMethod);
|
|
2948
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
2949
|
+
return response;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
const bodyKind = bodyKindForRequest(headers, bodyBytes);
|
|
2953
|
+
if (bodyKind === "malformed-json") {
|
|
2954
|
+
diagnostics.record({ code: "SLOPPY_E_JSON_INVALID", subsystem: "http", severity: "warn" });
|
|
2955
|
+
const response = finalizeResponse(responseFromProblem(validationProblem([
|
|
2956
|
+
{
|
|
2957
|
+
path: [],
|
|
2958
|
+
code: "json.invalid",
|
|
2959
|
+
message: "Request body is not valid JSON.",
|
|
2960
|
+
},
|
|
2961
|
+
])), normalizedMethod);
|
|
2962
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
2963
|
+
return response;
|
|
2964
|
+
}
|
|
2965
|
+
if (bodyKind === "malformed-multipart") {
|
|
2966
|
+
diagnostics.record({ code: "SLOPPY_E_MULTIPART_INVALID", subsystem: "http", severity: "warn" });
|
|
2967
|
+
const response = finalizeResponse(responseFromText(400, "Malformed Multipart\n"), normalizedMethod);
|
|
2968
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
2969
|
+
return response;
|
|
2970
|
+
}
|
|
2971
|
+
if (bodyKind === "unsupported" && bodyBytes.byteLength !== 0) {
|
|
2972
|
+
diagnostics.record({ code: "SLOPPY_E_UNSUPPORTED_MEDIA_TYPE", subsystem: "http", severity: "warn" });
|
|
2973
|
+
const response = finalizeResponse(responseFromErrorStatus(app, 415, undefined, "Unsupported Media Type\n"), normalizedMethod);
|
|
2974
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
2975
|
+
return response;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
const context = createContext(app, hostState, normalizedMethod, targetParts, headers, match.params, match.route, bodyKind, bodyBytes, normalizedOptions);
|
|
2979
|
+
const metricRegistry = appMetricsRegistry();
|
|
2980
|
+
const metricLabels = Object.freeze({
|
|
2981
|
+
method: normalizedMethod,
|
|
2982
|
+
route: match.route.pattern,
|
|
2983
|
+
});
|
|
2984
|
+
ignoreMetricError(() => {
|
|
2985
|
+
metricRegistry?.gauge("http.requests.active", { description: "HTTP requests currently active in the app host." }).inc(metricLabels);
|
|
2986
|
+
});
|
|
2987
|
+
const started = nowMs();
|
|
2988
|
+
try {
|
|
2989
|
+
try {
|
|
2990
|
+
const response = finalizeResponse(negotiatedResponse(
|
|
2991
|
+
responseFromResultWithOptions(await match.route.handler(context), serializationOptions),
|
|
2992
|
+
headers,
|
|
2993
|
+
serializationOptions,
|
|
2994
|
+
), normalizedMethod);
|
|
2995
|
+
const problemCode = problemCodeFromResponse(response);
|
|
2996
|
+
if (problemCode !== undefined) {
|
|
2997
|
+
diagnostics.record({ code: problemCode, subsystem: "http", severity: response.status >= 500 ? "error" : "warn" });
|
|
2998
|
+
}
|
|
2999
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
3000
|
+
ignoreMetricError(() => {
|
|
3001
|
+
recordHttpMetric(metricRegistry, metricLabels, response, Math.max(0, nowMs() - started), bodyBytes.byteLength);
|
|
3002
|
+
});
|
|
3003
|
+
return response;
|
|
3004
|
+
} catch (error) {
|
|
3005
|
+
if (isUnsupportedMediaHelperError(error)) {
|
|
3006
|
+
diagnostics.record({ code: "SLOPPY_E_UNSUPPORTED_MEDIA_TYPE", subsystem: "http", severity: "warn" });
|
|
3007
|
+
const response = finalizeResponse(responseFromText(415, "Unsupported Media Type\n"), normalizedMethod);
|
|
3008
|
+
metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
3009
|
+
ignoreMetricError(() => {
|
|
3010
|
+
recordHttpMetric(metricRegistry, metricLabels, response, Math.max(0, nowMs() - started), bodyBytes.byteLength);
|
|
3011
|
+
});
|
|
3012
|
+
return response;
|
|
3013
|
+
}
|
|
3014
|
+
diagnostics.record({
|
|
3015
|
+
code: "SLOPPY_E_HANDLER_ERROR",
|
|
3016
|
+
subsystem: "http",
|
|
3017
|
+
severity: "error",
|
|
3018
|
+
message: error.message,
|
|
3019
|
+
});
|
|
3020
|
+
throw error;
|
|
3021
|
+
}
|
|
3022
|
+
} finally {
|
|
3023
|
+
try {
|
|
3024
|
+
ignoreMetricError(() => {
|
|
3025
|
+
metricRegistry?.gauge("http.requests.active").dec(metricLabels);
|
|
3026
|
+
});
|
|
3027
|
+
} finally {
|
|
3028
|
+
await context.services.dispose();
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
} finally {
|
|
3032
|
+
finishRequest();
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
async function websocketConnect(target, options = undefined) {
|
|
3037
|
+
if (closed) {
|
|
3038
|
+
throw new Error("Sloppy test host is closed.");
|
|
3039
|
+
}
|
|
3040
|
+
const normalizedOptions = normalizeOptions(options);
|
|
3041
|
+
const targetParts = splitTarget(target);
|
|
3042
|
+
const headerEntries = headerEntriesFromObject(normalizedOptions.headers, "request");
|
|
3043
|
+
const headers = createHeadersLike(headerEntries);
|
|
3044
|
+
const match = findRoute(routes, "GET", targetParts.path);
|
|
3045
|
+
diagnostics.record({
|
|
3046
|
+
code: "SLOPPY_TESTHOST_WEBSOCKET_CONNECT",
|
|
3047
|
+
subsystem: "websocket",
|
|
3048
|
+
severity: "debug",
|
|
3049
|
+
message: "TestHost WebSocket connection started.",
|
|
3050
|
+
fields: { route: match.route?.pattern ?? null },
|
|
3051
|
+
});
|
|
3052
|
+
if (match.route === undefined || match.route.kind !== "websocket") {
|
|
3053
|
+
metrics.increment("websocket.upgrades.rejected.total", { outcome: "not-found" });
|
|
3054
|
+
throw websocketReject(match.route === undefined ? 404 : 405, "SLOPPY_E_WEBSOCKET_ROUTE_NOT_FOUND", "WebSocket route was not found.");
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const routeOptions = match.route.metadata.realtime?.websocket ??
|
|
3058
|
+
match.route.handler?.[WEBSOCKET_ROUTE_OPTIONS] ??
|
|
3059
|
+
Object.freeze({ maxMessageBytes: 64 * 1024, maxSendQueueBytes: 1024 * 1024, closeTimeoutMs: 5000 });
|
|
3060
|
+
const origin = headers.get("origin");
|
|
3061
|
+
if (!websocketOriginsAllow(routeOptions, origin)) {
|
|
3062
|
+
diagnostics.record({
|
|
3063
|
+
code: "SLOPPY_E_WEBSOCKET_ORIGIN_REJECTED",
|
|
3064
|
+
subsystem: "websocket",
|
|
3065
|
+
severity: "warn",
|
|
3066
|
+
fields: { route: match.route.pattern },
|
|
3067
|
+
});
|
|
3068
|
+
metrics.increment("websocket.upgrades.rejected.total", { outcome: "origin" });
|
|
3069
|
+
throw websocketReject(403, "SLOPPY_E_WEBSOCKET_ORIGIN_REJECTED", "WebSocket origin is not allowed.");
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
const requestedProtocols = Array.isArray(normalizedOptions.protocols)
|
|
3073
|
+
? normalizedOptions.protocols
|
|
3074
|
+
: (headers.get("sec-websocket-protocol") ?? "")
|
|
3075
|
+
.split(",")
|
|
3076
|
+
.map((value) => value.trim())
|
|
3077
|
+
.filter((value) => value.length !== 0);
|
|
3078
|
+
let protocol = "";
|
|
3079
|
+
if (Array.isArray(routeOptions.protocols) && routeOptions.protocols.length !== 0) {
|
|
3080
|
+
protocol = requestedProtocols.find((value) => routeOptions.protocols.includes(value)) ?? "";
|
|
3081
|
+
if (protocol.length === 0) {
|
|
3082
|
+
metrics.increment("websocket.upgrades.rejected.total", { outcome: "protocol" });
|
|
3083
|
+
throw websocketReject(400, "SLOPPY_E_WEBSOCKET_PROTOCOL_REJECTED", "WebSocket subprotocol is not allowed.");
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
const clientToServer = createAsyncMessageQueue();
|
|
3088
|
+
let queuedServerBytes = 0;
|
|
3089
|
+
const serverToClient = createAsyncMessageQueue((message) => {
|
|
3090
|
+
if (message.kind === "text") {
|
|
3091
|
+
queuedServerBytes = Math.max(0, queuedServerBytes - byteLengthOfWebSocketMessage("text", message.text));
|
|
3092
|
+
} else if (message.kind === "json") {
|
|
3093
|
+
queuedServerBytes = Math.max(0, queuedServerBytes - byteLengthOfWebSocketMessage("json", message.json()));
|
|
3094
|
+
} else if (message.kind === "binary") {
|
|
3095
|
+
queuedServerBytes = Math.max(0, queuedServerBytes - byteLengthOfWebSocketMessage("binary", message.bytes));
|
|
3096
|
+
} else if (message.kind === "ping" || message.kind === "pong") {
|
|
3097
|
+
queuedServerBytes = Math.max(0, queuedServerBytes - byteLengthOfWebSocketMessage(message.kind, message.text));
|
|
3098
|
+
}
|
|
3099
|
+
});
|
|
3100
|
+
let socketContext;
|
|
3101
|
+
let accepted = false;
|
|
3102
|
+
let finished = false;
|
|
3103
|
+
let heartbeatTimer;
|
|
3104
|
+
let idleTimer;
|
|
3105
|
+
let acceptResolve;
|
|
3106
|
+
let cleanedUp = false;
|
|
3107
|
+
const acceptedPromise = new Promise((resolve) => {
|
|
3108
|
+
acceptResolve = resolve;
|
|
3109
|
+
});
|
|
3110
|
+
const state = {
|
|
3111
|
+
options: routeOptions,
|
|
3112
|
+
route: match.route.pattern,
|
|
3113
|
+
metrics,
|
|
3114
|
+
clientToServer,
|
|
3115
|
+
serverToClient,
|
|
3116
|
+
protocol,
|
|
3117
|
+
closed: false,
|
|
3118
|
+
touch() {
|
|
3119
|
+
if (!Number.isInteger(routeOptions.idleTimeoutMs) || routeOptions.idleTimeoutMs <= 0 || state.closed) {
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
clearTimeout(idleTimer);
|
|
3123
|
+
idleTimer = setTimeout(() => {
|
|
3124
|
+
state.close(1001, "idle timeout");
|
|
3125
|
+
}, routeOptions.idleTimeoutMs);
|
|
3126
|
+
},
|
|
3127
|
+
close(code = 1000, reason = "") {
|
|
3128
|
+
if (state.closed) {
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
state.closed = true;
|
|
3132
|
+
clearInterval(heartbeatTimer);
|
|
3133
|
+
clearTimeout(idleTimer);
|
|
3134
|
+
clientToServer.close();
|
|
3135
|
+
serverToClient.push(createWebSocketMessage("close", { code, reason }));
|
|
3136
|
+
serverToClient.close();
|
|
3137
|
+
metrics.increment("websocket.close.total", {
|
|
3138
|
+
route: match.route.pattern,
|
|
3139
|
+
code: String(code),
|
|
3140
|
+
});
|
|
3141
|
+
},
|
|
3142
|
+
};
|
|
3143
|
+
async function cleanupSocket() {
|
|
3144
|
+
if (cleanedUp) {
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
cleanedUp = true;
|
|
3148
|
+
try {
|
|
3149
|
+
await socketContext?.services?.dispose?.();
|
|
3150
|
+
} finally {
|
|
3151
|
+
finishSocket(state);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
const socket = {
|
|
3155
|
+
get ctx() {
|
|
3156
|
+
return socketContext;
|
|
3157
|
+
},
|
|
3158
|
+
__setContext(ctx) {
|
|
3159
|
+
socketContext = ctx;
|
|
3160
|
+
},
|
|
3161
|
+
get closed() {
|
|
3162
|
+
return state.closed;
|
|
3163
|
+
},
|
|
3164
|
+
get protocol() {
|
|
3165
|
+
return protocol;
|
|
3166
|
+
},
|
|
3167
|
+
id: `test-ws-${activeSockets.size + 1}`,
|
|
3168
|
+
remoteAddress: "test-host",
|
|
3169
|
+
get request() {
|
|
3170
|
+
return socketContext?.request;
|
|
3171
|
+
},
|
|
3172
|
+
async accept() {
|
|
3173
|
+
if (state.closed) {
|
|
3174
|
+
throw new Error("Sloppy WebSocket is closed.");
|
|
3175
|
+
}
|
|
3176
|
+
if (!accepted) {
|
|
3177
|
+
accepted = true;
|
|
3178
|
+
metrics.increment("websocket.upgrades.total", { route: match.route.pattern, outcome: "accepted" });
|
|
3179
|
+
if (Number.isInteger(routeOptions.heartbeatMs) && routeOptions.heartbeatMs > 0) {
|
|
3180
|
+
heartbeatTimer = setInterval(() => {
|
|
3181
|
+
if (state.closed) {
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
serverToClient.push(createWebSocketMessage("ping", ""));
|
|
3185
|
+
metrics.increment("websocket.messages.out.total", {
|
|
3186
|
+
route: match.route.pattern,
|
|
3187
|
+
kind: "ping",
|
|
3188
|
+
});
|
|
3189
|
+
}, routeOptions.heartbeatMs);
|
|
3190
|
+
}
|
|
3191
|
+
state.touch();
|
|
3192
|
+
acceptResolve();
|
|
3193
|
+
}
|
|
3194
|
+
},
|
|
3195
|
+
async close(code = 1000, reason = "") {
|
|
3196
|
+
state.close(code, reason);
|
|
3197
|
+
},
|
|
3198
|
+
async sendText(text) {
|
|
3199
|
+
return sendFromServer("text", String(text));
|
|
3200
|
+
},
|
|
3201
|
+
async sendJson(value) {
|
|
3202
|
+
return sendFromServer("json", value);
|
|
3203
|
+
},
|
|
3204
|
+
async sendBytes(bytes) {
|
|
3205
|
+
return sendFromServer("binary", bytes);
|
|
3206
|
+
},
|
|
3207
|
+
async sendPing(payload = "") {
|
|
3208
|
+
return sendFromServer("ping", payload);
|
|
3209
|
+
},
|
|
3210
|
+
async sendPong(payload = "") {
|
|
3211
|
+
return sendFromServer("pong", payload);
|
|
3212
|
+
},
|
|
3213
|
+
messages() {
|
|
3214
|
+
return clientToServer;
|
|
3215
|
+
},
|
|
3216
|
+
};
|
|
3217
|
+
|
|
3218
|
+
function sendFromServer(kind, value) {
|
|
3219
|
+
if (!accepted) {
|
|
3220
|
+
throw new Error("SLOPPY_E_WEBSOCKET_NOT_ACCEPTED: call socket.accept() before sending.");
|
|
3221
|
+
}
|
|
3222
|
+
if (state.closed) {
|
|
3223
|
+
throw new Error("SLOPPY_E_WEBSOCKET_CLOSED");
|
|
3224
|
+
}
|
|
3225
|
+
const bytes = byteLengthOfWebSocketMessage(kind, value);
|
|
3226
|
+
if (bytes > routeOptions.maxMessageBytes) {
|
|
3227
|
+
state.close(1009, "message too large");
|
|
3228
|
+
throw new Error("SLOPPY_E_WEBSOCKET_MESSAGE_TOO_LARGE");
|
|
3229
|
+
}
|
|
3230
|
+
if (queuedServerBytes + bytes > routeOptions.maxSendQueueBytes) {
|
|
3231
|
+
metrics.increment("websocket.backpressure.total", { route: match.route.pattern, outcome: routeOptions.slowClientPolicy ?? "error" });
|
|
3232
|
+
if (routeOptions.slowClientPolicy === "close") {
|
|
3233
|
+
state.close(1013, "send queue full");
|
|
3234
|
+
return Promise.resolve();
|
|
3235
|
+
}
|
|
3236
|
+
throw new Error("SLOPPY_E_WEBSOCKET_BACKPRESSURE");
|
|
3237
|
+
}
|
|
3238
|
+
queuedServerBytes += bytes;
|
|
3239
|
+
const message = createWebSocketMessage(kind, value);
|
|
3240
|
+
serverToClient.push(message);
|
|
3241
|
+
metrics.increment("websocket.messages.out.total", { route: match.route.pattern, kind });
|
|
3242
|
+
metrics.increment("websocket.bytes.out.total", { route: match.route.pattern, kind }, bytes);
|
|
3243
|
+
return Promise.resolve();
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
const context = createContext(
|
|
3247
|
+
app,
|
|
3248
|
+
hostState,
|
|
3249
|
+
"GET",
|
|
3250
|
+
targetParts,
|
|
3251
|
+
headers,
|
|
3252
|
+
match.params,
|
|
3253
|
+
match.route,
|
|
3254
|
+
"none",
|
|
3255
|
+
new Uint8Array(0),
|
|
3256
|
+
normalizedOptions,
|
|
3257
|
+
);
|
|
3258
|
+
socketContext = {
|
|
3259
|
+
...context,
|
|
3260
|
+
__sloppyWebSocketHandshake: true,
|
|
3261
|
+
__sloppyWebSocket: socket,
|
|
3262
|
+
connection: Object.freeze({
|
|
3263
|
+
id: socket.id,
|
|
3264
|
+
protocol: "websocket",
|
|
3265
|
+
scheme: "test",
|
|
3266
|
+
secure: false,
|
|
3267
|
+
remoteAddress: normalizedOptions.remoteAddress ?? "test-host",
|
|
3268
|
+
}),
|
|
3269
|
+
};
|
|
3270
|
+
|
|
3271
|
+
activeSockets.add(state);
|
|
3272
|
+
const handlerPromise = Promise.resolve(match.route.handler(socketContext)).then(
|
|
3273
|
+
(value) => {
|
|
3274
|
+
finished = true;
|
|
3275
|
+
if (!accepted) {
|
|
3276
|
+
return value;
|
|
3277
|
+
}
|
|
3278
|
+
if (!state.closed) {
|
|
3279
|
+
state.close(1000, "handler complete");
|
|
3280
|
+
}
|
|
3281
|
+
return undefined;
|
|
3282
|
+
},
|
|
3283
|
+
(error) => {
|
|
3284
|
+
finished = true;
|
|
3285
|
+
diagnostics.record({
|
|
3286
|
+
code: "SLOPPY_E_WEBSOCKET_HANDLER_ERROR",
|
|
3287
|
+
subsystem: "websocket",
|
|
3288
|
+
severity: "error",
|
|
3289
|
+
message: error.message,
|
|
3290
|
+
fields: { route: match.route.pattern },
|
|
3291
|
+
});
|
|
3292
|
+
state.close(1011, "handler error");
|
|
3293
|
+
return undefined;
|
|
3294
|
+
},
|
|
3295
|
+
).finally(async () => {
|
|
3296
|
+
await cleanupSocket();
|
|
3297
|
+
});
|
|
3298
|
+
|
|
3299
|
+
const timeoutMs = normalizedOptions.timeoutMs ?? 1000;
|
|
3300
|
+
let timer;
|
|
3301
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
3302
|
+
timer = setTimeout(() => {
|
|
3303
|
+
diagnostics.record({
|
|
3304
|
+
code: "SLOPPY_E_WEBSOCKET_ACCEPT_TIMEOUT",
|
|
3305
|
+
subsystem: "websocket",
|
|
3306
|
+
severity: "warn",
|
|
3307
|
+
message: "WebSocket handler did not accept before timeout.",
|
|
3308
|
+
fields: { route: match.route.pattern },
|
|
3309
|
+
});
|
|
3310
|
+
state.close(1008, "accept timeout");
|
|
3311
|
+
cleanupSocket().finally(() => {
|
|
3312
|
+
reject(websocketReject(504, "SLOPPY_E_WEBSOCKET_ACCEPT_TIMEOUT", "WebSocket handler did not accept before timeout."));
|
|
3313
|
+
});
|
|
3314
|
+
}, timeoutMs);
|
|
3315
|
+
});
|
|
3316
|
+
try {
|
|
3317
|
+
const outcome = await Promise.race([
|
|
3318
|
+
acceptedPromise.then(() => Object.freeze({ kind: "accepted" })),
|
|
3319
|
+
handlerPromise.then((value) => Object.freeze({ kind: "handler", value })),
|
|
3320
|
+
timeoutPromise,
|
|
3321
|
+
]);
|
|
3322
|
+
if (outcome.kind === "handler" && !accepted) {
|
|
3323
|
+
const response = outcome.value === undefined
|
|
3324
|
+
? undefined
|
|
3325
|
+
: responseFromResultWithOptions(outcome.value, serializationOptions);
|
|
3326
|
+
throw websocketReject(
|
|
3327
|
+
response?.status ?? 500,
|
|
3328
|
+
response === undefined
|
|
3329
|
+
? "SLOPPY_E_WEBSOCKET_REJECTED"
|
|
3330
|
+
: (problemCodeFromResponse(response) ?? "SLOPPY_E_WEBSOCKET_REJECTED"),
|
|
3331
|
+
"WebSocket upgrade was rejected.",
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
} finally {
|
|
3335
|
+
clearTimeout(timer);
|
|
3336
|
+
}
|
|
3337
|
+
if (!accepted) {
|
|
3338
|
+
throw websocketReject(500, "SLOPPY_E_WEBSOCKET_NOT_ACCEPTED", "WebSocket was not accepted.");
|
|
3339
|
+
}
|
|
3340
|
+
metrics.increment("websocket.connections.total", { route: match.route.pattern });
|
|
3341
|
+
return Object.freeze(new TestWebSocket(state));
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
const host = {
|
|
3345
|
+
request,
|
|
3346
|
+
websocketConnect,
|
|
3347
|
+
get(target, options) {
|
|
3348
|
+
return request("GET", target, options);
|
|
3349
|
+
},
|
|
3350
|
+
post(target, options) {
|
|
3351
|
+
return request("POST", target, options);
|
|
3352
|
+
},
|
|
3353
|
+
put(target, options) {
|
|
3354
|
+
return request("PUT", target, options);
|
|
3355
|
+
},
|
|
3356
|
+
patch(target, options) {
|
|
3357
|
+
return request("PATCH", target, options);
|
|
3358
|
+
},
|
|
3359
|
+
delete(target, options) {
|
|
3360
|
+
return request("DELETE", target, options);
|
|
3361
|
+
},
|
|
3362
|
+
options(target, options) {
|
|
3363
|
+
return request("OPTIONS", target, options);
|
|
3364
|
+
},
|
|
3365
|
+
head(target, options) {
|
|
3366
|
+
return request("HEAD", target, options);
|
|
3367
|
+
},
|
|
3368
|
+
websocket(target, options) {
|
|
3369
|
+
return new WebSocketBuilder(host, target, options);
|
|
3370
|
+
},
|
|
3371
|
+
async close() {
|
|
3372
|
+
if (closePromise !== undefined) {
|
|
3373
|
+
return closePromise;
|
|
3374
|
+
}
|
|
3375
|
+
if (typeof app.__beginShutdown === "function") {
|
|
3376
|
+
app.__beginShutdown();
|
|
3377
|
+
}
|
|
3378
|
+
closed = true;
|
|
3379
|
+
for (const socket of [...activeSockets]) {
|
|
3380
|
+
socket.close(1001, "test host closed");
|
|
3381
|
+
}
|
|
3382
|
+
closePromise = (async () => {
|
|
3383
|
+
await waitForDrain();
|
|
3384
|
+
await hostState.services.dispose();
|
|
3385
|
+
return app.services.dispose();
|
|
3386
|
+
})();
|
|
3387
|
+
return closePromise;
|
|
3388
|
+
},
|
|
3389
|
+
diagnostics,
|
|
3390
|
+
metrics,
|
|
3391
|
+
jobs,
|
|
3392
|
+
openapi: createOpenApiHelpers(() => openApiFromRoutes(routes)),
|
|
3393
|
+
clock: hostState.clock,
|
|
3394
|
+
};
|
|
3395
|
+
return Object.freeze(host);
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
function findBytes(bytes, pattern) {
|
|
3399
|
+
outer:
|
|
3400
|
+
for (let index = 0; index <= bytes.byteLength - pattern.byteLength; index += 1) {
|
|
3401
|
+
for (let offset = 0; offset < pattern.byteLength; offset += 1) {
|
|
3402
|
+
if (bytes[index + offset] !== pattern[offset]) {
|
|
3403
|
+
continue outer;
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
return index;
|
|
3407
|
+
}
|
|
3408
|
+
return -1;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
function parseHttpResponseBytes(bytes) {
|
|
3412
|
+
const separators = [
|
|
3413
|
+
Text.utf8.encode("\r\n\r\n"),
|
|
3414
|
+
Text.utf8.encode("\r\r\n\r\r\n"),
|
|
3415
|
+
Text.utf8.encode("\n\n"),
|
|
3416
|
+
];
|
|
3417
|
+
let split = -1;
|
|
3418
|
+
let separatorLength = 0;
|
|
3419
|
+
for (const separator of separators) {
|
|
3420
|
+
split = findBytes(bytes, separator);
|
|
3421
|
+
if (split >= 0) {
|
|
3422
|
+
separatorLength = separator.byteLength;
|
|
3423
|
+
break;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
if (split < 0) {
|
|
3427
|
+
throw new Error("Sloppy TestHost could not parse the HTTP response emitted by sloppy run.");
|
|
3428
|
+
}
|
|
3429
|
+
const head = Text.utf8.decode(bytes.slice(0, split)).replace(/\r+\n/gu, "\n");
|
|
3430
|
+
const body = bytes.slice(split + separatorLength);
|
|
3431
|
+
const lines = head.split("\n");
|
|
3432
|
+
const statusMatch = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\b/u.exec(lines[0] ?? "");
|
|
3433
|
+
if (statusMatch === null) {
|
|
3434
|
+
throw new Error(`Sloppy TestHost received an invalid HTTP status line: ${lines[0] ?? ""}`);
|
|
3435
|
+
}
|
|
3436
|
+
const headers = [];
|
|
3437
|
+
for (const line of lines.slice(1)) {
|
|
3438
|
+
const colon = line.indexOf(":");
|
|
3439
|
+
if (colon <= 0) {
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
headers.push([line.slice(0, colon).trim(), line.slice(colon + 1).trim()]);
|
|
3443
|
+
}
|
|
3444
|
+
return responseFromParts(Number.parseInt(statusMatch[1], 10), headers, body);
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
function cliPath(options = {}) {
|
|
3448
|
+
if (options.cliPath !== undefined) {
|
|
3449
|
+
if (typeof options.cliPath !== "string" || options.cliPath.length === 0) {
|
|
3450
|
+
throw new TypeError("Sloppy TestHost cliPath must be a non-empty string.");
|
|
3451
|
+
}
|
|
3452
|
+
return options.cliPath;
|
|
3453
|
+
}
|
|
3454
|
+
return SloppyProcess.info().executablePath;
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
function pathRunArgs(kind, targetPath, mode) {
|
|
3458
|
+
const args = ["run"];
|
|
3459
|
+
if (kind === "artifacts") {
|
|
3460
|
+
args.push("--artifacts", targetPath);
|
|
3461
|
+
} else {
|
|
3462
|
+
args.push(targetPath);
|
|
3463
|
+
}
|
|
3464
|
+
if (mode === "loopback") {
|
|
3465
|
+
return args;
|
|
3466
|
+
}
|
|
3467
|
+
return args;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
function requestHeaderArgs(headers) {
|
|
3471
|
+
const args = [];
|
|
3472
|
+
for (const [name, value] of headers) {
|
|
3473
|
+
if (name.toLowerCase() === "content-length") {
|
|
3474
|
+
continue;
|
|
3475
|
+
}
|
|
3476
|
+
args.push("--header", `${name}: ${value}`);
|
|
3477
|
+
}
|
|
3478
|
+
return args;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
async function withRequestBodyFile(options, callback) {
|
|
3482
|
+
const headerEntries = headerEntriesFromObject(options.headers, "request");
|
|
3483
|
+
const bytes = normalizeRequestBody(options, headerEntries);
|
|
3484
|
+
const headers = headerEntries.filter(([name]) => name.toLowerCase() !== "content-length");
|
|
3485
|
+
if (bytes.byteLength === 0) {
|
|
3486
|
+
return callback(headers, undefined);
|
|
3487
|
+
}
|
|
3488
|
+
const root = options.tempDirectory ?? SloppySystem.tempDirectory ?? ".sloppy/testhost";
|
|
3489
|
+
await Directory.create(root, { recursive: true });
|
|
3490
|
+
const tempDir = await Directory.createTemp(root, { prefix: "request-" });
|
|
3491
|
+
const bodyPath = `${tempDir.replace(/[\\/]$/u, "")}/body.bin`;
|
|
3492
|
+
try {
|
|
3493
|
+
await File.writeBytes(bodyPath, bytes);
|
|
3494
|
+
return await callback(headers, bodyPath);
|
|
3495
|
+
} finally {
|
|
3496
|
+
await Directory.delete(tempDir, { recursive: true }).catch(() => {});
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
async function openApiFromCli(kind, targetPath, options = {}) {
|
|
3501
|
+
const command = cliPath(options);
|
|
3502
|
+
const args = kind === "artifacts"
|
|
3503
|
+
? ["openapi", "--artifacts", targetPath]
|
|
3504
|
+
: ["openapi", `${targetPath.replace(/[\\/]$/u, "")}/artifacts`];
|
|
3505
|
+
const result = await SloppyProcess.run(command, args, {
|
|
3506
|
+
cwd: options.cwd,
|
|
3507
|
+
env: options.env,
|
|
3508
|
+
capture: "bytes",
|
|
3509
|
+
timeoutMs: options.openapiTimeoutMs ?? options.timeoutMs ?? 30000,
|
|
3510
|
+
maxStdoutBytes: options.maxStdoutBytes ?? 16 * 1024 * 1024,
|
|
3511
|
+
maxStderrBytes: options.maxStderrBytes ?? 1024 * 1024,
|
|
3512
|
+
});
|
|
3513
|
+
if (result.exitCode !== 0) {
|
|
3514
|
+
const stderr = result.stderr instanceof Uint8Array ? Text.utf8.decode(result.stderr) : String(result.stderr ?? "");
|
|
3515
|
+
throw new Error(`Sloppy TestHost OpenAPI failed with exit code ${result.exitCode}.${stderr.length === 0 ? "" : `\n${stderr.trimEnd()}`}`);
|
|
3516
|
+
}
|
|
3517
|
+
return JSON.parse(Text.utf8.decode(result.stdout));
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
function processPlanPath(kind, targetPath) {
|
|
3521
|
+
const root = targetPath.replace(/[\\/]$/u, "");
|
|
3522
|
+
return kind === "artifacts"
|
|
3523
|
+
? `${root}/app.plan.json`
|
|
3524
|
+
: `${root}/artifacts/app.plan.json`;
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
async function readProcessPlan(kind, targetPath) {
|
|
3528
|
+
try {
|
|
3529
|
+
return await File.readJson(processPlanPath(kind, targetPath));
|
|
3530
|
+
} catch {
|
|
3531
|
+
return undefined;
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
function configKeyToEnvironmentName(key) {
|
|
3536
|
+
return String(key).split(":").join("__");
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
function generatedClientConfigKey(name) {
|
|
3540
|
+
const text = String(name);
|
|
3541
|
+
return `${text.length === 0 ? text : text[0].toUpperCase() + text.slice(1)}:BaseUrl`;
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
function httpMockConfigKeys(name, plan) {
|
|
3545
|
+
const keys = [];
|
|
3546
|
+
if (Array.isArray(plan?.httpClients)) {
|
|
3547
|
+
for (const client of plan.httpClients) {
|
|
3548
|
+
if (client?.name === name && typeof client.baseUrlConfigKey === "string") {
|
|
3549
|
+
keys.push(client.baseUrlConfigKey);
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
keys.push(`${name}:BaseUrl`, generatedClientConfigKey(name));
|
|
3554
|
+
return [...new Set(keys)];
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
async function createProcessHttpClientMocks(kind, targetPath, options = {}) {
|
|
3558
|
+
if (options.httpClients === undefined) {
|
|
3559
|
+
return Object.freeze({ env: options.env, async close() {} });
|
|
3560
|
+
}
|
|
3561
|
+
if (!isPlainObject(options.httpClients)) {
|
|
3562
|
+
throw new TypeError("Sloppy TestHost httpClients overrides must be a plain object.");
|
|
3563
|
+
}
|
|
3564
|
+
createTestHttpServiceOverrides(options.httpClients);
|
|
3565
|
+
const plan = await readProcessPlan(kind, targetPath);
|
|
3566
|
+
const env = { ...(options.env ?? {}) };
|
|
3567
|
+
const servers = [];
|
|
3568
|
+
try {
|
|
3569
|
+
for (const [name, mock] of Object.entries(options.httpClients)) {
|
|
3570
|
+
if (typeof mock?._dispatchRaw !== "function") {
|
|
3571
|
+
throw new TypeError(`Sloppy TestHost httpClients.${name} must come from TestHttp.mock().`);
|
|
3572
|
+
}
|
|
3573
|
+
const server = await startHttpMockServer(name, mock, options);
|
|
3574
|
+
servers.push(server);
|
|
3575
|
+
for (const key of httpMockConfigKeys(name, plan)) {
|
|
3576
|
+
env[configKeyToEnvironmentName(key)] = server.baseUrl;
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
} catch (error) {
|
|
3580
|
+
await Promise.all(servers.map((server) => server.close().catch(() => {})));
|
|
3581
|
+
throw error;
|
|
3582
|
+
}
|
|
3583
|
+
return Object.freeze({
|
|
3584
|
+
env,
|
|
3585
|
+
async close() {
|
|
3586
|
+
await Promise.all(servers.map((server) => server.close()));
|
|
3587
|
+
},
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
function createProcessHelpers(kind, targetPath, options) {
|
|
3592
|
+
return Object.freeze({
|
|
3593
|
+
diagnostics: createDiagnosticsStore(Object.values(options.secrets ?? {})),
|
|
3594
|
+
metrics: createMetricsStore(),
|
|
3595
|
+
jobs: createJobsHelpers(options.jobs),
|
|
3596
|
+
openapi: createOpenApiHelpers(() => openApiFromCli(kind, targetPath, options)),
|
|
3597
|
+
});
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
function unsupportedProcessWebSocketConnect(helpers, mode) {
|
|
3601
|
+
return async function websocketConnect() {
|
|
3602
|
+
helpers.diagnostics.record({
|
|
3603
|
+
code: "SLOPPY_E_TESTHOST_WEBSOCKET_UNSUPPORTED",
|
|
3604
|
+
subsystem: "websocket",
|
|
3605
|
+
severity: "warn",
|
|
3606
|
+
message: `Sloppy TestHost ${mode} WebSocket connections are not supported by this runtime lane.`,
|
|
3607
|
+
});
|
|
3608
|
+
throw websocketReject(
|
|
3609
|
+
501,
|
|
3610
|
+
"SLOPPY_E_TESTHOST_WEBSOCKET_UNSUPPORTED",
|
|
3611
|
+
`Sloppy TestHost ${mode} WebSocket connections are not supported by this runtime lane.`,
|
|
3612
|
+
);
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
async function createProcessOnceHost(kind, targetPath, options = {}) {
|
|
3617
|
+
const command = cliPath(options);
|
|
3618
|
+
const processMocks = await createProcessHttpClientMocks(kind, targetPath, options);
|
|
3619
|
+
const effectiveOptions = { ...options, env: processMocks.env };
|
|
3620
|
+
const helpers = createProcessHelpers(kind, targetPath, effectiveOptions);
|
|
3621
|
+
let closed = false;
|
|
3622
|
+
async function request(method, target, requestOptions = undefined) {
|
|
3623
|
+
if (closed) {
|
|
3624
|
+
throw new Error("Sloppy TestHost one-off CLI host is closed.");
|
|
3625
|
+
}
|
|
3626
|
+
const normalizedMethod = normalizeMethod(method);
|
|
3627
|
+
splitTarget(target);
|
|
3628
|
+
const normalizedOptions = normalizeOptions(requestOptions);
|
|
3629
|
+
return withRequestBodyFile({
|
|
3630
|
+
...normalizedOptions,
|
|
3631
|
+
tempDirectory: normalizedOptions.tempDirectory ?? effectiveOptions.tempDirectory,
|
|
3632
|
+
}, async (headers, bodyPath) => {
|
|
3633
|
+
const args = [
|
|
3634
|
+
...pathRunArgs(kind, targetPath, "inProcess"),
|
|
3635
|
+
"--once",
|
|
3636
|
+
normalizedMethod,
|
|
3637
|
+
target,
|
|
3638
|
+
...requestHeaderArgs(headers),
|
|
3639
|
+
];
|
|
3640
|
+
if (bodyPath !== undefined) {
|
|
3641
|
+
args.push("--body-file", bodyPath);
|
|
3642
|
+
}
|
|
3643
|
+
const result = await SloppyProcess.run(command, args, {
|
|
3644
|
+
cwd: effectiveOptions.cwd,
|
|
3645
|
+
env: effectiveOptions.env,
|
|
3646
|
+
capture: "bytes",
|
|
3647
|
+
timeoutMs: normalizedOptions.timeoutMs ?? effectiveOptions.timeoutMs ?? 30000,
|
|
3648
|
+
maxStdoutBytes: effectiveOptions.maxStdoutBytes ?? 16 * 1024 * 1024,
|
|
3649
|
+
maxStderrBytes: effectiveOptions.maxStderrBytes ?? 1024 * 1024,
|
|
3650
|
+
});
|
|
3651
|
+
if (result.exitCode !== 0) {
|
|
3652
|
+
const stdout = result.stdout instanceof Uint8Array ? Text.utf8.decode(result.stdout) : String(result.stdout ?? "");
|
|
3653
|
+
const stderr = result.stderr instanceof Uint8Array ? Text.utf8.decode(result.stderr) : String(result.stderr ?? "");
|
|
3654
|
+
helpers.diagnostics.record({
|
|
3655
|
+
code: "SLOPPY_E_TESTHOST_PROCESS_REQUEST",
|
|
3656
|
+
subsystem: "process",
|
|
3657
|
+
severity: "error",
|
|
3658
|
+
message: processDiagnosticPreview(stderr),
|
|
3659
|
+
fields: {
|
|
3660
|
+
exitCode: result.exitCode,
|
|
3661
|
+
stdout: processDiagnosticPreview(stdout),
|
|
3662
|
+
stderr: processDiagnosticPreview(stderr),
|
|
3663
|
+
},
|
|
3664
|
+
});
|
|
3665
|
+
throw new Error(`Sloppy TestHost request failed with exit code ${result.exitCode}.${stderr.length === 0 ? "" : `\n${stderr.trimEnd()}`}`);
|
|
3666
|
+
}
|
|
3667
|
+
const response = finalizeResponse(parseHttpResponseBytes(result.stdout), normalizedMethod);
|
|
3668
|
+
helpers.metrics.increment("http.requests.total", { method: normalizedMethod, status: String(response.status) });
|
|
3669
|
+
return response;
|
|
3670
|
+
});
|
|
3671
|
+
}
|
|
3672
|
+
return Object.freeze({
|
|
3673
|
+
request,
|
|
3674
|
+
websocketConnect: unsupportedProcessWebSocketConnect(helpers, "one-off CLI"),
|
|
3675
|
+
async close() {
|
|
3676
|
+
closed = true;
|
|
3677
|
+
await processMocks.close();
|
|
3678
|
+
},
|
|
3679
|
+
...helpers,
|
|
3680
|
+
});
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
async function readProcessPipeText(pipe, maxBytes) {
|
|
3684
|
+
if (pipe === undefined || typeof pipe.readText !== "function") {
|
|
3685
|
+
return "";
|
|
3686
|
+
}
|
|
3687
|
+
try {
|
|
3688
|
+
return await pipe.readText(maxBytes);
|
|
3689
|
+
} catch {
|
|
3690
|
+
return "";
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
async function processExitIfAvailable(child) {
|
|
3695
|
+
try {
|
|
3696
|
+
const result = await child.wait({ timeoutMs: 0 });
|
|
3697
|
+
return result?.timedOut === true ? undefined : result;
|
|
3698
|
+
} catch {
|
|
3699
|
+
return undefined;
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
async function processOutputSnapshot(child, options = {}) {
|
|
3704
|
+
const maxStdoutBytes = options.maxStdoutBytes ?? 64 * 1024;
|
|
3705
|
+
const maxStderrBytes = options.maxStderrBytes ?? 64 * 1024;
|
|
3706
|
+
const [stdout, stderr] = await Promise.all([
|
|
3707
|
+
readProcessPipeText(child.stdout, maxStdoutBytes),
|
|
3708
|
+
readProcessPipeText(child.stderr, maxStderrBytes),
|
|
3709
|
+
]);
|
|
3710
|
+
return { stdout, stderr };
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
function processDiagnosticPreview(value, maxChars = 4096) {
|
|
3714
|
+
const text = String(value ?? "").trimEnd();
|
|
3715
|
+
if (text.length <= maxChars) {
|
|
3716
|
+
return text;
|
|
3717
|
+
}
|
|
3718
|
+
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
function recordLoopbackStartupFailure(helpers, details) {
|
|
3722
|
+
helpers.diagnostics.record({
|
|
3723
|
+
code: "SLOPPY_E_TESTHOST_LOOPBACK_STARTUP",
|
|
3724
|
+
subsystem: "process",
|
|
3725
|
+
severity: "error",
|
|
3726
|
+
message: processDiagnosticPreview(details.message),
|
|
3727
|
+
fields: {
|
|
3728
|
+
host: details.host,
|
|
3729
|
+
port: details.port,
|
|
3730
|
+
exitCode: details.exitCode,
|
|
3731
|
+
stdout: processDiagnosticPreview(details.stdout),
|
|
3732
|
+
stderr: processDiagnosticPreview(details.stderr),
|
|
3733
|
+
},
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
function isRetryableLoopbackStartupFailure(error) {
|
|
3738
|
+
return /listen|bind|address|port|in use|EADDRINUSE|denied/iu.test(String(error?.message ?? error));
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
async function waitForLoopbackReady(host, port, child, helpers, options = {}) {
|
|
3742
|
+
const timeoutMs = options.startTimeoutMs ?? 10000;
|
|
3743
|
+
const stabilityMs = options.startStabilityMs ?? 250;
|
|
3744
|
+
const authority = loopbackAuthority(host, port);
|
|
3745
|
+
const startedAt = Date.now();
|
|
3746
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
3747
|
+
const exit = await processExitIfAvailable(child);
|
|
3748
|
+
if (exit !== undefined) {
|
|
3749
|
+
const output = await processOutputSnapshot(child, options);
|
|
3750
|
+
const message = `Sloppy TestHost loopback server exited before startup with exit code ${exit.exitCode}.`;
|
|
3751
|
+
recordLoopbackStartupFailure(helpers, {
|
|
3752
|
+
message,
|
|
3753
|
+
host,
|
|
3754
|
+
port,
|
|
3755
|
+
exitCode: exit.exitCode,
|
|
3756
|
+
...output,
|
|
3757
|
+
});
|
|
3758
|
+
throw new Error(`${message}${output.stderr.length === 0 ? "" : `\n${output.stderr.trimEnd()}`}`);
|
|
3759
|
+
}
|
|
3760
|
+
try {
|
|
3761
|
+
const probe = await HttpClient.get(`http://${authority}/__sloppy_testhost_ready__`, {
|
|
3762
|
+
timeoutMs: 100,
|
|
3763
|
+
});
|
|
3764
|
+
if (probe.status < 100) {
|
|
3765
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
3766
|
+
continue;
|
|
3767
|
+
}
|
|
3768
|
+
} catch {
|
|
3769
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
3770
|
+
continue;
|
|
3771
|
+
}
|
|
3772
|
+
await new Promise((resolve) => setTimeout(resolve, stabilityMs));
|
|
3773
|
+
const stableExit = await processExitIfAvailable(child);
|
|
3774
|
+
if (stableExit !== undefined) {
|
|
3775
|
+
const output = await processOutputSnapshot(child, options);
|
|
3776
|
+
const message = `Sloppy TestHost loopback server exited during startup with exit code ${stableExit.exitCode}.`;
|
|
3777
|
+
recordLoopbackStartupFailure(helpers, {
|
|
3778
|
+
message,
|
|
3779
|
+
host,
|
|
3780
|
+
port,
|
|
3781
|
+
exitCode: stableExit.exitCode,
|
|
3782
|
+
...output,
|
|
3783
|
+
});
|
|
3784
|
+
throw new Error(`${message}${output.stderr.length === 0 ? "" : `\n${output.stderr.trimEnd()}`}`);
|
|
3785
|
+
}
|
|
3786
|
+
return;
|
|
3787
|
+
}
|
|
3788
|
+
const output = await processOutputSnapshot(child, options);
|
|
3789
|
+
const message = "Sloppy TestHost loopback server did not start before timeout.";
|
|
3790
|
+
recordLoopbackStartupFailure(helpers, { message, host, port, ...output });
|
|
3791
|
+
throw new Error(`${message}${output.stderr.length === 0 ? "" : `\n${output.stderr.trimEnd()}`}`);
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
async function createProcessLoopbackHost(kind, targetPath, options = {}) {
|
|
3795
|
+
const command = cliPath(options);
|
|
3796
|
+
const processMocks = await createProcessHttpClientMocks(kind, targetPath, options);
|
|
3797
|
+
const effectiveOptions = { ...options, env: processMocks.env };
|
|
3798
|
+
const helpers = createProcessHelpers(kind, targetPath, effectiveOptions);
|
|
3799
|
+
const host = effectiveOptions.host ?? "127.0.0.1";
|
|
3800
|
+
const startupAttempts = effectiveOptions.port === undefined ? (effectiveOptions.portReservationAttempts ?? 64) : 1;
|
|
3801
|
+
let port;
|
|
3802
|
+
let child;
|
|
3803
|
+
try {
|
|
3804
|
+
for (let attempt = 0; attempt < startupAttempts; attempt += 1) {
|
|
3805
|
+
let reservation;
|
|
3806
|
+
try {
|
|
3807
|
+
reservation = await reserveLoopbackPort(host, effectiveOptions);
|
|
3808
|
+
} catch (error) {
|
|
3809
|
+
const failedPort = effectiveOptions.port === undefined ? undefined : validateLoopbackPort(effectiveOptions.port);
|
|
3810
|
+
recordLoopbackStartupFailure(helpers, {
|
|
3811
|
+
message: `Sloppy TestHost loopback port reservation failed.${error?.message === undefined ? "" : ` ${error.message}`}`,
|
|
3812
|
+
host,
|
|
3813
|
+
port: failedPort,
|
|
3814
|
+
});
|
|
3815
|
+
throw error;
|
|
3816
|
+
}
|
|
3817
|
+
port = reservation.port;
|
|
3818
|
+
await releaseLoopbackReservation(reservation);
|
|
3819
|
+
child = await SloppyProcess.start(command, [
|
|
3820
|
+
...pathRunArgs(kind, targetPath, "loopback"),
|
|
3821
|
+
"--host",
|
|
3822
|
+
host,
|
|
3823
|
+
"--port",
|
|
3824
|
+
String(port),
|
|
3825
|
+
], {
|
|
3826
|
+
cwd: effectiveOptions.cwd,
|
|
3827
|
+
env: effectiveOptions.env,
|
|
3828
|
+
stdout: "pipe",
|
|
3829
|
+
stderr: "pipe",
|
|
3830
|
+
});
|
|
3831
|
+
try {
|
|
3832
|
+
await waitForLoopbackReady(host, port, child, helpers, effectiveOptions);
|
|
3833
|
+
break;
|
|
3834
|
+
} catch (error) {
|
|
3835
|
+
await child.terminate().catch(() => {});
|
|
3836
|
+
await child.wait({ timeoutMs: effectiveOptions.stopTimeoutMs ?? 5000 }).catch(() => {});
|
|
3837
|
+
await child.dispose().catch(() => {});
|
|
3838
|
+
child = undefined;
|
|
3839
|
+
if (effectiveOptions.port === undefined && attempt + 1 < startupAttempts && isRetryableLoopbackStartupFailure(error)) {
|
|
3840
|
+
continue;
|
|
3841
|
+
}
|
|
3842
|
+
throw error;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
if (child === undefined || port === undefined) {
|
|
3846
|
+
throw new Error("Sloppy TestHost loopback server did not start.");
|
|
3847
|
+
}
|
|
3848
|
+
let closed = false;
|
|
3849
|
+
|
|
3850
|
+
const baseUrl = `http://${loopbackAuthority(host, port)}`;
|
|
3851
|
+
return Object.freeze({
|
|
3852
|
+
baseUrl,
|
|
3853
|
+
port,
|
|
3854
|
+
async request(method, target, requestOptions = undefined) {
|
|
3855
|
+
if (closed) {
|
|
3856
|
+
throw new Error("Sloppy TestHost loopback host is closed.");
|
|
3857
|
+
}
|
|
3858
|
+
const exit = await processExitIfAvailable(child);
|
|
3859
|
+
if (exit !== undefined) {
|
|
3860
|
+
const output = await processOutputSnapshot(child, effectiveOptions);
|
|
3861
|
+
helpers.diagnostics.record({
|
|
3862
|
+
code: "SLOPPY_E_TESTHOST_LOOPBACK_EXITED",
|
|
3863
|
+
subsystem: "process",
|
|
3864
|
+
severity: "error",
|
|
3865
|
+
message: `Sloppy TestHost loopback server exited with code ${exit.exitCode}.`,
|
|
3866
|
+
fields: {
|
|
3867
|
+
exitCode: exit.exitCode,
|
|
3868
|
+
stdout: processDiagnosticPreview(output.stdout),
|
|
3869
|
+
stderr: processDiagnosticPreview(output.stderr),
|
|
3870
|
+
},
|
|
3871
|
+
});
|
|
3872
|
+
throw new Error(`Sloppy TestHost loopback server exited with code ${exit.exitCode}.`);
|
|
3873
|
+
}
|
|
3874
|
+
const normalizedMethod = normalizeMethod(method);
|
|
3875
|
+
const normalizedOptions = normalizeOptions(requestOptions);
|
|
3876
|
+
const headerEntries = headerEntriesFromObject(normalizedOptions.headers, "request");
|
|
3877
|
+
const body = normalizeRequestBody(normalizedOptions, headerEntries);
|
|
3878
|
+
const requestHeaders = Object.fromEntries(
|
|
3879
|
+
headerEntries.filter(([name]) => name.toLowerCase() !== "content-length"),
|
|
3880
|
+
);
|
|
3881
|
+
const response = await HttpClient.request({
|
|
3882
|
+
url: `${baseUrl}${target}`,
|
|
3883
|
+
method: normalizedMethod,
|
|
3884
|
+
headers: requestHeaders,
|
|
3885
|
+
bytes: body.byteLength === 0 ? undefined : body,
|
|
3886
|
+
timeoutMs: normalizedOptions.timeoutMs ?? effectiveOptions.timeoutMs,
|
|
3887
|
+
});
|
|
3888
|
+
const testResponse = finalizeResponse(responseFromParts(response.status, responseHeaderEntries(response), await response.bytes()), normalizedMethod);
|
|
3889
|
+
helpers.metrics.increment("http.requests.total", { method: normalizedMethod, status: String(testResponse.status) });
|
|
3890
|
+
return testResponse;
|
|
3891
|
+
},
|
|
3892
|
+
async close() {
|
|
3893
|
+
if (closed) {
|
|
3894
|
+
return;
|
|
3895
|
+
}
|
|
3896
|
+
closed = true;
|
|
3897
|
+
await child.terminate().catch(() => {});
|
|
3898
|
+
await child.wait({ timeoutMs: effectiveOptions.stopTimeoutMs ?? 5000 }).catch(() => {});
|
|
3899
|
+
await child.dispose().catch(() => {});
|
|
3900
|
+
await processMocks.close();
|
|
3901
|
+
},
|
|
3902
|
+
websocketConnect: unsupportedProcessWebSocketConnect(helpers, "loopback"),
|
|
3903
|
+
...helpers,
|
|
3904
|
+
});
|
|
3905
|
+
} catch (error) {
|
|
3906
|
+
await processMocks.close().catch(() => {});
|
|
3907
|
+
throw error;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
function runtimeHostToFluent(runtimeHost, mode) {
|
|
3912
|
+
return Object.freeze({
|
|
3913
|
+
...createFluentHost({
|
|
3914
|
+
request(method, target, options) {
|
|
3915
|
+
return runtimeHost.request(method, target, options);
|
|
3916
|
+
},
|
|
3917
|
+
websocketConnect(target, options) {
|
|
3918
|
+
if (typeof runtimeHost.websocketConnect === "function") {
|
|
3919
|
+
return runtimeHost.websocketConnect(target, options);
|
|
3920
|
+
}
|
|
3921
|
+
return unsupportedProcessWebSocketConnect(runtimeHost, mode)(target, options);
|
|
3922
|
+
},
|
|
3923
|
+
close() {
|
|
3924
|
+
return runtimeHost.close?.();
|
|
3925
|
+
},
|
|
3926
|
+
diagnostics: runtimeHost.diagnostics,
|
|
3927
|
+
metrics: runtimeHost.metrics,
|
|
3928
|
+
jobs: runtimeHost.jobs,
|
|
3929
|
+
openapi: runtimeHost.openapi,
|
|
3930
|
+
baseUrl: runtimeHost.baseUrl,
|
|
3931
|
+
port: runtimeHost.port,
|
|
3932
|
+
}, mode),
|
|
3933
|
+
baseUrl: runtimeHost.baseUrl,
|
|
3934
|
+
port: runtimeHost.port,
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
async function runtimeOrProcessHost(kind, targetPath, mode, options) {
|
|
3939
|
+
const bridgeName = kind === "artifacts"
|
|
3940
|
+
? (mode === "loopback" ? "fromArtifactsLoopback" : "fromArtifacts")
|
|
3941
|
+
: (mode === "loopback" ? "fromPackageLoopback" : "fromPackage");
|
|
3942
|
+
const bridge = globalThis.__sloppy?.testHost;
|
|
3943
|
+
if (bridge !== undefined && typeof bridge[bridgeName] === "function") {
|
|
3944
|
+
return runtimeHostToFluent(await bridge[bridgeName](targetPath, options), mode);
|
|
3945
|
+
}
|
|
3946
|
+
const host = mode === "loopback"
|
|
3947
|
+
? await createProcessLoopbackHost(kind, targetPath, options)
|
|
3948
|
+
: await createProcessOnceHost(kind, targetPath, options);
|
|
3949
|
+
return runtimeHostToFluent(host, mode);
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
async function createArtifactHost(targetPath, options = {}) {
|
|
3953
|
+
if (typeof targetPath !== "string" || targetPath.length === 0) {
|
|
3954
|
+
throw new TypeError("Sloppy TestHost artifact/package path must be a non-empty string.");
|
|
3955
|
+
}
|
|
3956
|
+
const mode = options.mode ?? "inProcess";
|
|
3957
|
+
if (mode === "inProcess" || mode === "loopback") {
|
|
3958
|
+
return runtimeOrProcessHost("artifacts", targetPath, mode, options);
|
|
3959
|
+
}
|
|
3960
|
+
throw new Error(`Sloppy TestHost mode '${mode}' is not supported.`);
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
async function createPackageHost(targetPath, options = {}) {
|
|
3964
|
+
if (typeof targetPath !== "string" || targetPath.length === 0) {
|
|
3965
|
+
throw new TypeError("Sloppy TestHost artifact/package path must be a non-empty string.");
|
|
3966
|
+
}
|
|
3967
|
+
const mode = options.mode ?? "inProcess";
|
|
3968
|
+
if (mode === "inProcess" || mode === "loopback") {
|
|
3969
|
+
return runtimeOrProcessHost("package", targetPath, mode, options);
|
|
3970
|
+
}
|
|
3971
|
+
throw new Error(`Sloppy TestHost mode '${mode}' is not supported.`);
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
function ensureSupportedCreateMode(mode) {
|
|
3975
|
+
if (mode === "inProcess") {
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
if (mode === "loopback") {
|
|
3979
|
+
throw new Error("Sloppy TestHost loopback mode requires fromArtifacts() or fromPackage() so the real runtime server can start.");
|
|
3980
|
+
}
|
|
3981
|
+
throw new Error(`Sloppy TestHost mode '${mode}' is not supported.`);
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
const FakeClock = Object.freeze({
|
|
3985
|
+
fixed(value) {
|
|
3986
|
+
const start = value instanceof Date ? value.getTime() : Date.parse(String(value));
|
|
3987
|
+
if (!Number.isFinite(start)) {
|
|
3988
|
+
throw new TypeError("Sloppy FakeClock.fixed expects a Date or ISO timestamp.");
|
|
3989
|
+
}
|
|
3990
|
+
let current = start;
|
|
3991
|
+
return Object.freeze({
|
|
3992
|
+
now() {
|
|
3993
|
+
return new Date(current);
|
|
3994
|
+
},
|
|
3995
|
+
monotonicNowMs() {
|
|
3996
|
+
return current - start;
|
|
3997
|
+
},
|
|
3998
|
+
advanceBy(duration) {
|
|
3999
|
+
const ms = duration?.milliseconds ?? duration?.ms ??
|
|
4000
|
+
(duration?.seconds ?? 0) * 1000 +
|
|
4001
|
+
(duration?.minutes ?? 0) * 60 * 1000 +
|
|
4002
|
+
(duration?.hours ?? 0) * 60 * 60 * 1000;
|
|
4003
|
+
if (!Number.isFinite(ms)) {
|
|
4004
|
+
throw new TypeError("Sloppy FakeClock.advanceBy duration must be finite.");
|
|
4005
|
+
}
|
|
4006
|
+
current += ms;
|
|
4007
|
+
},
|
|
4008
|
+
delay(ms) {
|
|
4009
|
+
if (!Number.isFinite(ms)) {
|
|
4010
|
+
throw new TypeError("Sloppy FakeClock.delay expects a finite millisecond value.");
|
|
4011
|
+
}
|
|
4012
|
+
current += ms;
|
|
4013
|
+
return Promise.resolve();
|
|
4014
|
+
},
|
|
4015
|
+
});
|
|
4016
|
+
},
|
|
4017
|
+
});
|
|
4018
|
+
|
|
4019
|
+
const TestData = Object.freeze({
|
|
4020
|
+
sqliteMemory(options = {}) {
|
|
4021
|
+
return Object.freeze({
|
|
4022
|
+
kind: "sqlite",
|
|
4023
|
+
database: ":memory:",
|
|
4024
|
+
migrations: options.migrations,
|
|
4025
|
+
seed: options.seed,
|
|
4026
|
+
open() {
|
|
4027
|
+
const db = data.sqlite.open({ database: ":memory:" });
|
|
4028
|
+
return Promise.resolve()
|
|
4029
|
+
.then(() => options.migrations === undefined ? undefined : Migrations.apply(db, {
|
|
4030
|
+
provider: "sqlite",
|
|
4031
|
+
path: options.migrations,
|
|
4032
|
+
}))
|
|
4033
|
+
.then(() => typeof options.seed === "function" ? options.seed(db) : undefined)
|
|
4034
|
+
.then(() => db);
|
|
4035
|
+
},
|
|
4036
|
+
});
|
|
4037
|
+
},
|
|
4038
|
+
sqliteTempFile(options = {}) {
|
|
4039
|
+
return Object.freeze({
|
|
4040
|
+
kind: "sqlite",
|
|
4041
|
+
async open() {
|
|
4042
|
+
const directory = options.directory ?? ".sloppy/testhost";
|
|
4043
|
+
const dir = await Directory.createTemp(directory, { prefix: "sqlite-" });
|
|
4044
|
+
const database = `${dir.replace(/[\\/]$/u, "")}/test.db`;
|
|
4045
|
+
const db = data.sqlite.open({ database });
|
|
4046
|
+
const originalClose = db.close?.bind(db);
|
|
4047
|
+
return Object.freeze({
|
|
4048
|
+
...db,
|
|
4049
|
+
close() {
|
|
4050
|
+
originalClose?.();
|
|
4051
|
+
return Directory.delete(dir, { recursive: true });
|
|
4052
|
+
},
|
|
4053
|
+
});
|
|
4054
|
+
},
|
|
4055
|
+
});
|
|
4056
|
+
},
|
|
4057
|
+
});
|
|
4058
|
+
|
|
4059
|
+
const TestHost = Object.freeze({
|
|
4060
|
+
async create(app, options = {}) {
|
|
4061
|
+
ensureSupportedCreateMode(options.mode ?? "inProcess");
|
|
4062
|
+
return createFluentHost(createTestHost(app, options), "inProcess");
|
|
4063
|
+
},
|
|
4064
|
+
fromArtifacts(pathValue, options = {}) {
|
|
4065
|
+
return createArtifactHost(pathValue, options);
|
|
4066
|
+
},
|
|
4067
|
+
fromPackage(pathValue, options = {}) {
|
|
4068
|
+
return createPackageHost(pathValue, options);
|
|
4069
|
+
},
|
|
4070
|
+
});
|
|
4071
|
+
|
|
4072
|
+
const Testing = Object.freeze({
|
|
4073
|
+
createHost: createTestHost,
|
|
4074
|
+
TestHost,
|
|
4075
|
+
TestServices,
|
|
4076
|
+
FakeClock,
|
|
4077
|
+
TestData,
|
|
4078
|
+
TestHttp,
|
|
4079
|
+
});
|
|
4080
|
+
|
|
4081
|
+
export { createTestHost, FakeClock, TestData, TestHost, TestHttp, TestServices, Testing };
|